Reorganize repository into pc web shared monorepo
Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content. Co-authored-by: Cursor <cursoragent@cursor.com>
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,226 @@
|
||||
"""Locate the 10 top-bar hero slots automatically, no manual box drawing.
|
||||
|
||||
The top bar puts a player-coloured strip above every portrait, and those ten
|
||||
colours are fixed by the game. Finding them gives both the horizontal position
|
||||
and the slot order for free, at any resolution.
|
||||
|
||||
Usage:
|
||||
python autocalibrate.py samples/raw/draft_141704.png
|
||||
python autocalibrate.py samples/raw/draft_141704.png --check # inspect only
|
||||
|
||||
Writes slot geometry into config.json and preview/autocalibrate_check.png.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT, load_config, save_config
|
||||
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
|
||||
# Dota 2 player colours as RGB: radiant slots 1-5 then dire slots 6-10
|
||||
PLAYER_COLORS = [
|
||||
(51, 117, 255),
|
||||
(102, 255, 191),
|
||||
(191, 0, 191),
|
||||
(243, 240, 11),
|
||||
(255, 107, 0),
|
||||
(254, 134, 194),
|
||||
(161, 180, 71),
|
||||
(101, 217, 247),
|
||||
(0, 131, 33),
|
||||
(164, 105, 0),
|
||||
]
|
||||
|
||||
COLOR_TOLERANCE = 60
|
||||
|
||||
|
||||
def find_color_bar_rows(img: np.ndarray) -> tuple[int, int]:
|
||||
"""Rows spanned by the player-colour strips."""
|
||||
h, w = img.shape[:2]
|
||||
targets = np.array([(b, g, r) for (r, g, b) in PLAYER_COLORS], dtype=np.int16)
|
||||
search = img[: int(h * 0.08)].astype(np.int16)
|
||||
|
||||
hits = []
|
||||
for y in range(search.shape[0]):
|
||||
d = np.linalg.norm(search[y][:, None, :] - targets[None, :, :], axis=2)
|
||||
hits.append(int((d.min(axis=1) < COLOR_TOLERANCE).sum()))
|
||||
|
||||
hits = np.array(hits)
|
||||
strong = np.where(hits > w * 0.15)[0]
|
||||
if strong.size == 0:
|
||||
raise SystemExit(
|
||||
"no player colour bars found - is this really a draft/strategy-time frame?"
|
||||
)
|
||||
runs = np.split(strong, np.where(np.diff(strong) > 2)[0] + 1)
|
||||
run = max(runs, key=len)
|
||||
return int(run[0]), int(run[-1])
|
||||
|
||||
|
||||
def find_slots(img: np.ndarray, y0: int, y1: int) -> tuple[list[float], float]:
|
||||
"""Slot centre x for all ten slots, plus the common slot width."""
|
||||
targets = np.array([(b, g, r) for (r, g, b) in PLAYER_COLORS], dtype=np.int16)
|
||||
band = np.median(img[y0 : y1 + 1].astype(np.int16), axis=0)
|
||||
|
||||
d = np.linalg.norm(band[:, None, :] - targets[None, :, :], axis=2)
|
||||
best, dist = d.argmin(axis=1), d.min(axis=1)
|
||||
ok = dist < COLOR_TOLERANCE
|
||||
|
||||
centers: list[float | None] = []
|
||||
widths: list[int | None] = []
|
||||
for idx in range(10):
|
||||
xs = np.where(ok & (best == idx))[0]
|
||||
if xs.size == 0:
|
||||
centers.append(None)
|
||||
widths.append(None)
|
||||
continue
|
||||
runs = np.split(xs, np.where(np.diff(xs) > 5)[0] + 1)
|
||||
run = max(runs, key=len)
|
||||
centers.append(float(run[0] + run[-1]) / 2)
|
||||
widths.append(int(run[-1] - run[0] + 1))
|
||||
|
||||
if sum(c is not None for c in centers) < 8:
|
||||
raise SystemExit("found fewer than 8 colour bars - frame is probably not a full top bar")
|
||||
|
||||
# A bar whose colour bleeds into the portrait behind it comes out too wide,
|
||||
# and its centre is then wrong by several pixels. Least squares would let
|
||||
# such a bar drag the whole row; judge each bar by its width first and only
|
||||
# trust the well-formed ones.
|
||||
width = float(np.median([wd for wd in widths if wd is not None]))
|
||||
reliable = [
|
||||
c is not None and wd is not None and abs(wd - width) <= width * 0.15
|
||||
for c, wd in zip(centers, widths)
|
||||
]
|
||||
|
||||
# slot pitch is identical for both teams, so take it from every good pair
|
||||
steps = [
|
||||
(centers[j] - centers[i]) / (j - i)
|
||||
for team in (range(0, 5), range(5, 10))
|
||||
for i in team
|
||||
for j in team
|
||||
if j > i and reliable[i] and reliable[j]
|
||||
]
|
||||
if not steps:
|
||||
raise SystemExit("no reliable colour bars to measure slot spacing from")
|
||||
pitch = float(np.median(steps))
|
||||
|
||||
fitted: list[float] = []
|
||||
for team in (range(0, 5), range(5, 10)):
|
||||
idx = [i for i in team if reliable[i]] or list(team)
|
||||
base = float(np.median([centers[i] - pitch * (i - team[0]) for i in idx]))
|
||||
fitted += [base + pitch * (i - team[0]) for i in team]
|
||||
|
||||
return fitted, width
|
||||
|
||||
|
||||
def find_portrait_bottom(img: np.ndarray, bar_bottom: int, centers: list[float], width: float) -> int:
|
||||
"""Row where the portraits give way to the name plates.
|
||||
|
||||
Uses the brightness gap between portrait columns and the gaps between
|
||||
portraits: it is large while portraits are present and collapses to zero
|
||||
the moment they end. A plain row-to-row delta does not work here because
|
||||
the player names further down produce an even bigger jump.
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
half = width / 2
|
||||
|
||||
inside = np.concatenate(
|
||||
[np.arange(int(c - half) + 6, int(c + half) - 6) for c in centers]
|
||||
)
|
||||
gaps = np.concatenate(
|
||||
[
|
||||
np.arange(int(a + half) + 10, int(b - half) - 10)
|
||||
for team in (centers[:5], centers[5:])
|
||||
for a, b in zip(team[:-1], team[1:])
|
||||
]
|
||||
)
|
||||
inside = inside[(inside >= 0) & (inside < w)]
|
||||
gaps = gaps[(gaps >= 0) & (gaps < w)]
|
||||
|
||||
top = bar_bottom + 1
|
||||
end = min(h, bar_bottom + int(h * 0.15))
|
||||
strip = img[top:end].astype(np.int16)
|
||||
contrast = np.abs(strip[:, inside].mean(axis=(1, 2)) - strip[:, gaps].mean(axis=(1, 2)))
|
||||
|
||||
faded = np.where(contrast < contrast.max() * 0.05)[0]
|
||||
if faded.size == 0:
|
||||
raise SystemExit("could not find the bottom edge of the portraits")
|
||||
return top + int(faded[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
path = sys.argv[1]
|
||||
check_only = "--check" in sys.argv
|
||||
|
||||
img = cv2.imread(path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {path}")
|
||||
h, w = img.shape[:2]
|
||||
|
||||
bar_top, bar_bottom = find_color_bar_rows(img)
|
||||
centers, width = find_slots(img, bar_top, bar_bottom)
|
||||
portrait_top = bar_bottom + 1
|
||||
portrait_bottom = find_portrait_bottom(img, bar_bottom, centers, width)
|
||||
height = portrait_bottom - portrait_top
|
||||
|
||||
print(f"image : {w}x{h}")
|
||||
print(f"colour bar rows : {bar_top}-{bar_bottom}")
|
||||
print(f"portrait rows : {portrait_top}-{portrait_bottom} (height {height})")
|
||||
print(f"slot width : {width:.0f}")
|
||||
print(f"slot centres : {', '.join(f'{c:.0f}' for c in centers)}")
|
||||
|
||||
if height < 20 or width < 20:
|
||||
sys.exit("detected geometry looks wrong - refusing to write config")
|
||||
|
||||
cy = portrait_top + height / 2
|
||||
slots = [
|
||||
{"index": i + 1, "cx_rel": (c - w / 2) / h, "cy_rel": cy / h}
|
||||
for i, c in enumerate(centers)
|
||||
]
|
||||
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
check = img.copy()
|
||||
for s, c in zip(slots, centers):
|
||||
x0, x1 = int(c - width / 2), int(c + width / 2)
|
||||
cv2.rectangle(check, (x0, portrait_top), (x1, portrait_bottom), (0, 0, 255), 2)
|
||||
cv2.putText(check, str(s["index"]), (x0 + 4, portrait_bottom + 26),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
||||
cv2.imwrite(str(PREVIEW_DIR / "autocalibrate_check.png"), check[: portrait_bottom + 40])
|
||||
|
||||
tiles = [
|
||||
cv2.copyMakeBorder(
|
||||
img[portrait_top:portrait_bottom, int(c - width / 2) : int(c + width / 2)],
|
||||
2, 2, 2, 2, cv2.BORDER_CONSTANT, value=(0, 0, 255),
|
||||
)
|
||||
for c in centers
|
||||
]
|
||||
cv2.imwrite(str(PREVIEW_DIR / "autocalibrate_slots.png"), np.hstack(tiles))
|
||||
print("wrote preview/autocalibrate_check.png and preview/autocalibrate_slots.png")
|
||||
|
||||
if check_only:
|
||||
print("--check given, config.json untouched")
|
||||
return
|
||||
|
||||
cfg = load_config()
|
||||
cfg["calibrated_on"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
cfg["calibrated_from"] = str(Path(path).name)
|
||||
cfg["slots"] = slots
|
||||
cfg["slot_w_rel"] = width / h
|
||||
cfg["slot_h_rel"] = height / h
|
||||
# the ROI is already just the portrait, so nothing left to trim away
|
||||
cfg["crop_trim"] = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
|
||||
save_config(cfg)
|
||||
print("config.json updated")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""One-time ROI calibration.
|
||||
|
||||
Usage:
|
||||
python calibrate.py samples/full_1080p.png # interactive: drag 10 slot boxes
|
||||
python calibrate.py samples/full_1080p.png --check # draw current config on image
|
||||
|
||||
Interactive mode: for each of the 10 hero slots (any order), drag a box around
|
||||
the portrait (include the whole parallelogram, exclude neighbors), then press
|
||||
SPACE/ENTER. Press ESC when all 10 are done. Slots are sorted left-to-right
|
||||
and stored as resolution-independent relative coordinates.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import time
|
||||
|
||||
import cv2
|
||||
|
||||
from common import load_config, save_config, slot_rect_px
|
||||
|
||||
|
||||
def calibrate(image_path: str) -> None:
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
ih, iw = img.shape[:2]
|
||||
print(f"image size: {iw}x{ih}")
|
||||
print("Drag a box per slot (10 total), SPACE/ENTER to confirm each, ESC to finish.")
|
||||
|
||||
rois = cv2.selectROIs("calibrate - drag 10 slots", img, showCrosshair=True)
|
||||
cv2.destroyAllWindows()
|
||||
if len(rois) != 10:
|
||||
sys.exit(f"expected 10 boxes, got {len(rois)} - please rerun")
|
||||
|
||||
rois = sorted(rois.tolist(), key=lambda r: r[0])
|
||||
cfg = load_config()
|
||||
cfg["slots"] = []
|
||||
avg_w = sum(r[2] for r in rois) / 10
|
||||
avg_h = sum(r[3] for r in rois) / 10
|
||||
cfg["slot_w_rel"] = round(avg_w / ih, 5)
|
||||
cfg["slot_h_rel"] = round(avg_h / ih, 5)
|
||||
for i, (x, y, w, h) in enumerate(rois):
|
||||
cfg["slots"].append(
|
||||
{
|
||||
"index": i + 1,
|
||||
"cx_rel": round((x + w / 2 - iw / 2) / ih, 5),
|
||||
"cy_rel": round((y + h / 2) / ih, 5),
|
||||
}
|
||||
)
|
||||
cfg["calibrated_on"] = f"{iw}x{ih} {time.strftime('%Y-%m-%d %H:%M')}"
|
||||
save_config(cfg)
|
||||
print(f"saved {len(cfg['slots'])} slots to config.json")
|
||||
check(image_path)
|
||||
|
||||
|
||||
def check(image_path: str) -> None:
|
||||
"""Draw configured slot rects onto the image for visual verification."""
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
ih, iw = img.shape[:2]
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run calibration first")
|
||||
for slot in cfg["slots"]:
|
||||
x, y, w, h = slot_rect_px(slot, cfg, iw, ih)
|
||||
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
|
||||
cv2.putText(img, str(slot["index"]), (x, y - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
||||
out = "calibrate_check.png"
|
||||
cv2.imwrite(out, img)
|
||||
print(f"wrote {out} - open it and verify the boxes sit on the 10 portraits")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
if "--check" in sys.argv:
|
||||
check(sys.argv[1])
|
||||
else:
|
||||
calibrate(sys.argv[1])
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Screen capture helper.
|
||||
|
||||
Usage:
|
||||
python capture.py # single full-screen shot -> samples/raw/
|
||||
python capture.py --loop 300 2 # capture every 2s for 300s (Ctrl+C to stop early)
|
||||
|
||||
Notes:
|
||||
- Dota 2 must run in borderless window or windowed mode; exclusive
|
||||
fullscreen may capture a black frame with GDI-based grabbers.
|
||||
- Frames are saved as PNG at native resolution, named cap_HHMMSS.png.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
RAW_DIR = Path(__file__).parent / "samples" / "raw"
|
||||
GSI_JSONL = "gsi.jsonl"
|
||||
DOTA_EXE = "dota2.exe"
|
||||
|
||||
|
||||
def is_dota_foreground() -> bool:
|
||||
"""True when the foreground process is dota2.exe.
|
||||
|
||||
Uses Win32 GetForegroundWindow + QueryFullProcessImageNameW. On non-Windows
|
||||
or if the probe fails, returns True so recognition is not blocked.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
hwnd = user32.GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return False
|
||||
|
||||
pid = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if not pid.value:
|
||||
return False
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
buf = ctypes.create_unicode_buffer(32768)
|
||||
size = wintypes.DWORD(len(buf))
|
||||
# QueryFullProcessImageNameW(hProcess, dwFlags, lpExeName, lpdwSize)
|
||||
QueryFullProcessImageNameW = kernel32.QueryFullProcessImageNameW
|
||||
QueryFullProcessImageNameW.argtypes = [
|
||||
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)
|
||||
]
|
||||
QueryFullProcessImageNameW.restype = wintypes.BOOL
|
||||
if not QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
|
||||
return False
|
||||
return Path(buf.value).name.lower() == DOTA_EXE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def raw_dir_for_match(match_id: str | None = None) -> Path:
|
||||
"""Per-match screenshot folder under samples/raw/{match_id}/.
|
||||
|
||||
Manual capture (no match) still uses samples/raw/ itself.
|
||||
"""
|
||||
if not match_id:
|
||||
return RAW_DIR
|
||||
safe = "".join(c for c in str(match_id) if c.isalnum() or c in "-_") or "no-match"
|
||||
return RAW_DIR / safe
|
||||
|
||||
|
||||
def append_gsi_payload(match_id: str | None, payload: dict) -> Path:
|
||||
"""Append one full GSI POST body to samples/raw/{match_id}/gsi.jsonl.
|
||||
|
||||
Each line is {"t": <unix seconds>, "payload": <original body>}.
|
||||
"""
|
||||
out = raw_dir_for_match(match_id)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = out / GSI_JSONL
|
||||
record = {"t": time.time(), "payload": payload}
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def grab_frame(sct=None) -> np.ndarray:
|
||||
"""Grab the primary monitor as a BGR image."""
|
||||
if sct is None:
|
||||
with mss.MSS() as own:
|
||||
return grab_frame(own)
|
||||
shot = sct.grab(sct.monitors[1])
|
||||
return cv2.cvtColor(np.asarray(shot), cv2.COLOR_BGRA2BGR)
|
||||
|
||||
|
||||
def save_frame(img: np.ndarray, out_dir: Path = RAW_DIR, prefix: str = "cap") -> str:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%H%M%S")
|
||||
path = out_dir / f"{prefix}_{stamp}.png"
|
||||
n = 1
|
||||
while path.exists():
|
||||
path = out_dir / f"{prefix}_{stamp}_{n}.png"
|
||||
n += 1
|
||||
cv2.imwrite(str(path), img)
|
||||
return str(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with mss.MSS() as sct:
|
||||
if "--loop" in sys.argv:
|
||||
i = sys.argv.index("--loop")
|
||||
duration = float(sys.argv[i + 1])
|
||||
interval = float(sys.argv[i + 2]) if len(sys.argv) > i + 2 else 2.0
|
||||
end = time.time() + duration
|
||||
n = 0
|
||||
print(f"capturing every {interval}s for {duration}s -> {RAW_DIR}")
|
||||
try:
|
||||
while time.time() < end:
|
||||
path = save_frame(grab_frame(sct))
|
||||
n += 1
|
||||
print(f"[{n}] {path}")
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
print(f"done: {n} frames")
|
||||
else:
|
||||
print(save_frame(grab_frame(sct)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""PC-side helpers: config IO, slot geometry, crop preprocessing, NCC matching.
|
||||
|
||||
ROOT is the pc/ directory: every runtime path built from it (samples/,
|
||||
preview/, results/, failures/, templates/, assets/role_icons/) stays inside
|
||||
the PC subproject. Shared locations (heroes.json, CDN templates) are
|
||||
re-exported from shared.paths so existing ``from common import X`` call
|
||||
sites keep working.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from shared.paths import HEROES_JSON, TEMPLATES_CDN # noqa: F401 (re-export)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def slot_rect_px(slot: dict, cfg: dict, img_w: int, img_h: int) -> tuple[int, int, int, int]:
|
||||
"""Convert relative slot coords to pixel rect (x, y, w, h) for this image size."""
|
||||
w = cfg["slot_w_rel"] * img_h
|
||||
h = cfg["slot_h_rel"] * img_h
|
||||
cx = img_w / 2 + slot["cx_rel"] * img_h
|
||||
cy = slot["cy_rel"] * img_h
|
||||
return int(round(cx - w / 2)), int(round(cy - h / 2)), int(round(w)), int(round(h))
|
||||
|
||||
|
||||
def crop_slot(img: np.ndarray, slot: dict, cfg: dict) -> np.ndarray | None:
|
||||
"""Crop one slot, trim UI chrome (color bar / name plate), resize to canonical size."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = slot_rect_px(slot, cfg, iw, ih)
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
x, y = max(0, x), max(0, y)
|
||||
roi = img[y : min(y + h, ih), x : min(x + w, iw)]
|
||||
if roi.size == 0:
|
||||
return None
|
||||
|
||||
t = cfg["crop_trim"]
|
||||
rh, rw = roi.shape[:2]
|
||||
y0 = int(rh * t["top"])
|
||||
y1 = int(rh * (1 - t["bottom"]))
|
||||
x0 = int(rw * t["left"])
|
||||
x1 = int(rw * (1 - t["right"]))
|
||||
inner = roi[y0:y1, x0:x1]
|
||||
if inner.size == 0:
|
||||
return None
|
||||
|
||||
size = cfg["canonical_size"]
|
||||
return cv2.resize(inner, (size, size), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def match_score(crop: np.ndarray, template: np.ndarray, mask: np.ndarray | None = None) -> float:
|
||||
"""Normalized cross-correlation between two same-sized BGR images.
|
||||
|
||||
If mask is given (uint8, nonzero = use), only those pixels contribute.
|
||||
Used to ignore the ranked-medal banner that sits on the bottom/right of
|
||||
every top-bar portrait in ranked matchmaking.
|
||||
"""
|
||||
if crop.shape != template.shape:
|
||||
template = cv2.resize(template, (crop.shape[1], crop.shape[0]), interpolation=cv2.INTER_AREA)
|
||||
if mask is None:
|
||||
res = cv2.matchTemplate(crop, template, cv2.TM_CCOEFF_NORMED)
|
||||
return float(res[0][0])
|
||||
|
||||
if mask.shape[:2] != crop.shape[:2]:
|
||||
mask = cv2.resize(mask, (crop.shape[1], crop.shape[0]), interpolation=cv2.INTER_NEAREST)
|
||||
sel = mask > 0
|
||||
if int(sel.sum()) < 32:
|
||||
return -1.0
|
||||
a = crop[sel].astype(np.float32).ravel()
|
||||
b = template[sel].astype(np.float32).ravel()
|
||||
a -= a.mean()
|
||||
b -= b.mean()
|
||||
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
|
||||
return float(a @ b / denom) if denom > 1e-6 else -1.0
|
||||
|
||||
|
||||
def ranked_match_mask(size: int, cfg: dict) -> np.ndarray:
|
||||
"""Canonical-size mask that zeroes the bottom rank bar and right medal."""
|
||||
rm = cfg.get("match", {}).get("ranked_mask", {})
|
||||
bottom = float(rm.get("bottom", 0.32))
|
||||
right = float(rm.get("right", 0.22))
|
||||
mask = np.ones((size, size), np.uint8) * 255
|
||||
mask[int(size * (1.0 - bottom)) :, :] = 0
|
||||
mask[:, int(size * (1.0 - right)) :] = 0
|
||||
return mask
|
||||
|
||||
|
||||
def has_ranked_overlay(img: np.ndarray, cfg: dict) -> bool:
|
||||
"""True when most slots show the gold rank medal on the right edge.
|
||||
|
||||
Bot / unranked strategy-time frames have no medals, so this stays false
|
||||
and recognition keeps using the full portrait.
|
||||
"""
|
||||
if not cfg.get("slots"):
|
||||
return False
|
||||
ih, iw = img.shape[:2]
|
||||
hits = 0
|
||||
checked = 0
|
||||
for slot in cfg["slots"]:
|
||||
x, y, w, h = slot_rect_px(slot, cfg, iw, ih)
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
roi = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if roi.size == 0:
|
||||
continue
|
||||
checked += 1
|
||||
rh, rw = roi.shape[:2]
|
||||
corner = roi[int(rh * 0.35) :, int(rw * 0.68) :]
|
||||
if corner.size == 0:
|
||||
continue
|
||||
hsv = cv2.cvtColor(corner, cv2.COLOR_BGR2HSV)
|
||||
gold = cv2.inRange(hsv, (8, 70, 90), (40, 255, 255))
|
||||
if float(gold.mean()) > 18.0:
|
||||
hits += 1
|
||||
return checked > 0 and hits >= max(6, checked * 0.6)
|
||||
|
||||
|
||||
def load_template_library() -> list[tuple[str, np.ndarray]]:
|
||||
"""Return list of (hero_key, image) from Steam CDN portraits."""
|
||||
lib: list[tuple[str, np.ndarray]] = []
|
||||
if not TEMPLATES_CDN.is_dir():
|
||||
return lib
|
||||
for png in sorted(TEMPLATES_CDN.glob("*.png")):
|
||||
img = cv2.imread(str(png))
|
||||
if img is not None:
|
||||
lib.append((png.stem, img))
|
||||
return lib
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"comment": "All coordinates are relative: x is offset from screen center divided by screen height; y/w/h are divided by screen height. Filled in by calibrate.py.",
|
||||
"calibrated_on": "2026-07-25 15:01:11",
|
||||
"slots": [
|
||||
{
|
||||
"index": 1,
|
||||
"cx_rel": -0.6409722222222223,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"cx_rel": -0.5263888888888889,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"cx_rel": -0.41180555555555554,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"cx_rel": -0.2972222222222222,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"cx_rel": -0.18263888888888888,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"cx_rel": 0.18055555555555555,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"cx_rel": 0.2951388888888889,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"cx_rel": 0.4097222222222222,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"cx_rel": 0.5243055555555556,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"cx_rel": 0.6388888888888888,
|
||||
"cy_rel": 0.03611111111111111
|
||||
}
|
||||
],
|
||||
"slot_w_rel": 0.07708333333333334,
|
||||
"slot_h_rel": 0.06111111111111111,
|
||||
"crop_trim": {
|
||||
"top": 0.0,
|
||||
"bottom": 0.0,
|
||||
"left": 0.0,
|
||||
"right": 0.0
|
||||
},
|
||||
"canonical_size": 96,
|
||||
"match": {
|
||||
"min_score": 0.45,
|
||||
"min_margin": 0.04,
|
||||
"ranked_mask": {
|
||||
"comment": "Ignore the bottom rank-title bar and right-side medal when ranked overlays are detected.",
|
||||
"bottom": 0.32,
|
||||
"right": 0.22
|
||||
}
|
||||
},
|
||||
"mode_label": {
|
||||
"comment": "Strip under the draft timer that shows 全英雄选择 / 队长模式 / ...",
|
||||
"y0_rel": 0.045,
|
||||
"y1_rel": 0.072,
|
||||
"x0_rel": 0.40,
|
||||
"x1_rel": 0.60,
|
||||
"min_score": 0.55
|
||||
},
|
||||
"grid": {
|
||||
"comment": "Hero-selection grid. min_std separates cards from gaps; unavailable_std sits in the gap between banned/taken cards (8-21 measured) and live ones (33+).",
|
||||
"min_std": 18.0,
|
||||
"unavailable_std": 26.0
|
||||
},
|
||||
"text_rows": {
|
||||
"comment": "Rows of text under each top-bar portrait, relative to screen height.",
|
||||
"name": {
|
||||
"y0_rel": 0.075,
|
||||
"y1_rel": 0.09444
|
||||
},
|
||||
"role": {
|
||||
"y0_rel": 0.09722,
|
||||
"y1_rel": 0.11319,
|
||||
"min_value": 110,
|
||||
"max_sat": 0.08
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"comment": "min_iou gates role-label matching. Own slot comes from GSI team_slot.",
|
||||
"min_iou": 0.55
|
||||
},
|
||||
"recommend": {
|
||||
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). top_n<=0 = no cap. No AI.",
|
||||
"enabled": true,
|
||||
"top_n": 0,
|
||||
"min_enemies": 1,
|
||||
"min_heroes_for_gaps": 2,
|
||||
"archetypes": true,
|
||||
"relations_path": "shared/data/relations.json",
|
||||
"role_tags": {
|
||||
"1": ["Carry"],
|
||||
"2": ["Carry", "Nuker", "Escape"],
|
||||
"3": ["Initiator", "Durable", "Carry"],
|
||||
"4": ["Support"],
|
||||
"5": ["Support"]
|
||||
}
|
||||
},
|
||||
"gsi": {
|
||||
"comment": "Either trigger state starts one tracking session per match; strategy time is the fallback for joining late.",
|
||||
"port": 3223,
|
||||
"trigger_states": [
|
||||
"DOTA_GAMERULES_STATE_HERO_SELECTION",
|
||||
"DOTA_GAMERULES_STATE_STRATEGY_TIME"
|
||||
],
|
||||
"poll_interval": 1.0,
|
||||
"confirm_polls": 2,
|
||||
"revise_gain": 0.15,
|
||||
"session_timeout": 300,
|
||||
"keep_event_frames": true,
|
||||
"dump_selection_every": 0,
|
||||
"strategy_tail_polls": 8,
|
||||
"strategy_gsi_wait": 3.0,
|
||||
"require_foreground": true,
|
||||
"capture_interval": 1.0,
|
||||
"target_slots": 10,
|
||||
"dump_payloads": true
|
||||
},
|
||||
"overlay": {
|
||||
"comment": "Role tags under top-bar + 克/搭/补 marks + lineup analysis banner.",
|
||||
"enabled": true,
|
||||
"y_gap_rel": 0.008,
|
||||
"icon_h_rel": 0.016,
|
||||
"icon_gap_rel": 0.002,
|
||||
"mark_size_rel": 0.018,
|
||||
"mark_pad_rel": 0.004,
|
||||
"mark_gap_rel": 0.002,
|
||||
"counter_color": "#2ec4b6",
|
||||
"synergy_color": "#e9a825",
|
||||
"fill_color": "#9b7ebd",
|
||||
"mark_text_color": "#0b1220",
|
||||
"analysis_y_rel": 0.12,
|
||||
"analysis_h_rel": 0.028,
|
||||
"analysis_font_rel": 0.014,
|
||||
"analysis_bg": "#1a2332",
|
||||
"analysis_fg": "#e8eef7"
|
||||
},
|
||||
"calibrated_from": "draft_141704.png"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Rule-based draft lineup archetypes and gap analysis (no AI).
|
||||
|
||||
Detects push / global enemy shapes, enemy & ally tag gaps, answer heroes,
|
||||
and short Chinese analysis / reason strings for recommend marks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from collections import Counter
|
||||
from typing import Iterable
|
||||
|
||||
from shared.hero_tags import TAG_ORDER
|
||||
|
||||
# Strong push cores: one hit can flag push even before 2+ Pusher tags.
|
||||
PUSH_CORE = frozenset({
|
||||
"lycan",
|
||||
"furion",
|
||||
"broodmother",
|
||||
"chen",
|
||||
"enchantress",
|
||||
"visage",
|
||||
"beastmaster",
|
||||
"naga_siren",
|
||||
"lone_druid",
|
||||
"undying",
|
||||
})
|
||||
|
||||
GLOBAL_SET = frozenset({
|
||||
"furion",
|
||||
"spectre",
|
||||
"wisp",
|
||||
"abyssal_underlord",
|
||||
"zuus",
|
||||
"ancient_apparition",
|
||||
"spirit_breaker",
|
||||
"storm_spirit",
|
||||
"rattletrap",
|
||||
})
|
||||
|
||||
HARD_GLOBAL = frozenset({
|
||||
"furion",
|
||||
"spectre",
|
||||
"wisp",
|
||||
})
|
||||
|
||||
# Archetype -> answer hero keys (marked 克 with reason 对推进 / 对全球流).
|
||||
ARCHETYPE_ANSWERS: dict[str, tuple[str, ...]] = {
|
||||
"push": (
|
||||
"medusa",
|
||||
"terrorblade",
|
||||
"naga_siren",
|
||||
"jakiro",
|
||||
"gyrocopter",
|
||||
"dragon_knight",
|
||||
"shredder",
|
||||
),
|
||||
"global": (
|
||||
"storm_spirit",
|
||||
"anti_mage",
|
||||
"riki",
|
||||
"bounty_hunter",
|
||||
"queenofpain",
|
||||
"ember_spirit",
|
||||
),
|
||||
}
|
||||
|
||||
ARCHETYPE_REASON = {
|
||||
"push": "对推进",
|
||||
"global": "对全球流",
|
||||
}
|
||||
|
||||
ARCHETYPE_LABEL = {
|
||||
"push": "偏推进",
|
||||
"global": "全球流",
|
||||
}
|
||||
|
||||
# Gaps we report (user-facing). 输出 is proxied by 核心.
|
||||
GAP_TAGS = ("控制", "爆发", "核心", "先手")
|
||||
|
||||
GAP_DISPLAY = {
|
||||
"控制": "控制",
|
||||
"爆发": "爆发",
|
||||
"核心": "输出",
|
||||
"先手": "先手",
|
||||
}
|
||||
|
||||
# Enemy gap -> candidate tags that punish it (marked 克).
|
||||
ENEMY_GAP_PUNISH: dict[str, tuple[str, ...]] = {
|
||||
"控制": ("控制", "先手"),
|
||||
"爆发": ("耐久", "核心"),
|
||||
"核心": ("爆发", "控制"),
|
||||
"先手": ("先手", "爆发"),
|
||||
}
|
||||
|
||||
MAX_REASONS = 3
|
||||
MAX_REASON_LEN = 12
|
||||
|
||||
|
||||
def tag_profile(keys: Iterable[str], tags_by_key: dict[str, list[str]]) -> dict[str, int]:
|
||||
counts: Counter[str] = Counter()
|
||||
for key in keys:
|
||||
for tag in tags_by_key.get(key) or []:
|
||||
if tag in TAG_ORDER:
|
||||
counts[tag] += 1
|
||||
return {t: counts[t] for t in TAG_ORDER if counts.get(t)}
|
||||
|
||||
|
||||
def detect_archetypes(
|
||||
enemies: list[str],
|
||||
tags_by_key: dict[str, list[str]],
|
||||
*,
|
||||
push_tag_min: int = 2,
|
||||
) -> list[str]:
|
||||
"""Return ordered archetype ids present in the enemy lineup."""
|
||||
out: list[str] = []
|
||||
push_n = sum(1 for e in enemies if "推进" in (tags_by_key.get(e) or []))
|
||||
if push_n >= push_tag_min or any(e in PUSH_CORE for e in enemies):
|
||||
out.append("push")
|
||||
global_hits = [e for e in enemies if e in GLOBAL_SET]
|
||||
if len(global_hits) >= 2 or any(e in HARD_GLOBAL for e in enemies):
|
||||
out.append("global")
|
||||
return out
|
||||
|
||||
|
||||
def detect_gaps(
|
||||
profile: dict[str, int],
|
||||
*,
|
||||
hero_count: int,
|
||||
min_heroes: int = 2,
|
||||
) -> list[str]:
|
||||
"""Return missing GAP_TAGS when enough heroes are locked."""
|
||||
if hero_count < max(1, int(min_heroes)):
|
||||
return []
|
||||
missing = []
|
||||
for tag in GAP_TAGS:
|
||||
if int(profile.get(tag) or 0) <= 0:
|
||||
missing.append(tag)
|
||||
return missing
|
||||
|
||||
|
||||
def format_analysis(
|
||||
*,
|
||||
archetypes: list[str],
|
||||
enemy_gaps: list[str],
|
||||
ally_gaps: list[str],
|
||||
ally_count: int,
|
||||
) -> str:
|
||||
"""One short Chinese lineup summary (may be empty)."""
|
||||
enemy_bits: list[str] = []
|
||||
for arch in archetypes:
|
||||
lab = ARCHETYPE_LABEL.get(arch)
|
||||
if lab and lab not in enemy_bits:
|
||||
enemy_bits.append(lab)
|
||||
for gap in enemy_gaps:
|
||||
disp = GAP_DISPLAY.get(gap, gap)
|
||||
bit = f"缺{disp}"
|
||||
if bit not in enemy_bits:
|
||||
enemy_bits.append(bit)
|
||||
|
||||
ally_bits: list[str] = []
|
||||
if ally_count <= 0:
|
||||
if enemy_bits:
|
||||
ally_bits.append("缺口尚不明")
|
||||
else:
|
||||
for gap in ally_gaps:
|
||||
disp = GAP_DISPLAY.get(gap, gap)
|
||||
bit = f"缺{disp}"
|
||||
if bit not in ally_bits:
|
||||
ally_bits.append(bit)
|
||||
|
||||
parts: list[str] = []
|
||||
if enemy_bits:
|
||||
parts.append("敌:" + "·".join(enemy_bits))
|
||||
if ally_bits:
|
||||
parts.append("我:" + "·".join(ally_bits))
|
||||
text = " | ".join(parts)
|
||||
if len(text) > 40:
|
||||
text = text[:39] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _trim_reason(s: str) -> str:
|
||||
s = (s or "").strip()
|
||||
if len(s) <= MAX_REASON_LEN:
|
||||
return s
|
||||
return s[: MAX_REASON_LEN - 1] + "…"
|
||||
|
||||
|
||||
def collect_reasons(
|
||||
*,
|
||||
names: dict[str, str],
|
||||
beats: list[dict],
|
||||
with_allies: list[dict],
|
||||
archetype_hits: list[str],
|
||||
punish_gaps: list[str],
|
||||
fill_gaps: list[str],
|
||||
) -> list[str]:
|
||||
"""Build up to MAX_REASONS short reason phrases for one candidate."""
|
||||
reasons: list[str] = []
|
||||
|
||||
def add(phrase: str) -> None:
|
||||
p = _trim_reason(phrase)
|
||||
if p and p not in reasons and len(reasons) < MAX_REASONS:
|
||||
reasons.append(p)
|
||||
|
||||
# Prefer one signal per mark type (克 / 补 / 搭) before extras.
|
||||
for edge in beats[:1]:
|
||||
add(f"克{names.get(edge['enemy'], edge['enemy'])}")
|
||||
for gap in fill_gaps[:1]:
|
||||
add(f"补{GAP_DISPLAY.get(gap, gap)}")
|
||||
for edge in with_allies[:1]:
|
||||
add(f"搭{names.get(edge['ally'], edge['ally'])}")
|
||||
for arch in archetype_hits:
|
||||
add(ARCHETYPE_REASON.get(arch, arch))
|
||||
for gap in punish_gaps:
|
||||
add(f"打缺{GAP_DISPLAY.get(gap, gap)}")
|
||||
for edge in beats[1:]:
|
||||
add(f"克{names.get(edge['enemy'], edge['enemy'])}")
|
||||
for gap in fill_gaps[1:]:
|
||||
add(f"补{GAP_DISPLAY.get(gap, gap)}")
|
||||
for edge in with_allies[1:]:
|
||||
add(f"搭{names.get(edge['ally'], edge['ally'])}")
|
||||
return reasons
|
||||
|
||||
|
||||
def answer_for_candidate(
|
||||
key: str,
|
||||
cand_tags: list[str],
|
||||
*,
|
||||
archetypes: list[str],
|
||||
enemy_gaps: list[str],
|
||||
ally_gaps: list[str],
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (archetype_hits, punish_gaps, fill_gaps) that apply to this hero."""
|
||||
tag_set = set(cand_tags or [])
|
||||
arch_hits = [a for a in archetypes if key in ARCHETYPE_ANSWERS.get(a, ())]
|
||||
punish = []
|
||||
for gap in enemy_gaps:
|
||||
wanted = ENEMY_GAP_PUNISH.get(gap) or ()
|
||||
if tag_set.intersection(wanted):
|
||||
punish.append(gap)
|
||||
fill = [g for g in ally_gaps if g in tag_set]
|
||||
# 核心 gap displays as 输出; candidate must have 核心 tag to fill.
|
||||
return arch_hits, punish, fill
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ARCHETYPE_ANSWERS",
|
||||
"ARCHETYPE_LABEL",
|
||||
"ARCHETYPE_REASON",
|
||||
"ENEMY_GAP_PUNISH",
|
||||
"GAP_DISPLAY",
|
||||
"GAP_TAGS",
|
||||
"answer_for_candidate",
|
||||
"collect_reasons",
|
||||
"detect_archetypes",
|
||||
"detect_gaps",
|
||||
"format_analysis",
|
||||
"tag_profile",
|
||||
]
|
||||
@@ -0,0 +1,686 @@
|
||||
"""Follow a whole draft instead of taking one snapshot at the end.
|
||||
|
||||
Ranked All Pick reveals picks in waves rather than one at a time (official
|
||||
rules: two rounds of 2 picks per team at 25s, then a final round of 1 at 20s,
|
||||
with each round's picks hidden until the round ends). A single grab at
|
||||
strategy time therefore loses the order completely, which is exactly the
|
||||
information you need to reason about what to counter-pick.
|
||||
|
||||
This polls the screen for as long as GSI says we are still drafting and
|
||||
appends a timeline event whenever the confirmed set of picks changes. A pick
|
||||
only becomes confirmed after the same hero lands in the same slot on
|
||||
`confirm_polls` consecutive frames, because the top bar animates portraits in
|
||||
and a single frame catches half-faded artwork.
|
||||
|
||||
While the hero grid is still up it also reads the ban list off it (see
|
||||
grid.py), which the top bar never shows.
|
||||
|
||||
Top-bar portraits use the default icon until everyone has picked; skins
|
||||
land only after the draft is complete. Vision therefore runs through both
|
||||
HERO_SELECTION and early STRATEGY_TIME until all ten slots are filled - the
|
||||
last reveal often lands right as strategy begins, and a player who already
|
||||
locked may be staring at the strategy UI while others are still picking.
|
||||
|
||||
Skinned portraits are not templated (too many variants). Instead:
|
||||
- during STRATEGY_TIME only empty slots may be filled; confirmed picks are
|
||||
never revised (skin art must not overwrite a settled default face);
|
||||
- the saved best lineup frame prefers HERO_SELECTION when recognition
|
||||
counts tie, so draft_best_* stays on default faces when possible.
|
||||
|
||||
Once ten heroes are confirmed, vision stops and only GSI is waited on for self.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import time
|
||||
|
||||
import mss
|
||||
|
||||
from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_frame
|
||||
from shared.grid import bans, hero_table, read_grid
|
||||
from modes import detect_mode, load_mode_templates
|
||||
from recognize import recognize_image
|
||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
||||
from roles import ROLES, detect_roles, load_role_templates
|
||||
|
||||
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
|
||||
STRATEGY_TIME = "DOTA_GAMERULES_STATE_STRATEGY_TIME"
|
||||
DRAFT_STATES = (HERO_SELECTION, STRATEGY_TIME)
|
||||
|
||||
|
||||
def pick_round(per_team_max: int) -> int:
|
||||
"""Which of the three All Pick rounds a given pick count belongs to."""
|
||||
if per_team_max <= 2:
|
||||
return 1
|
||||
if per_team_max <= 4:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
class DraftSession:
|
||||
def __init__(self, cfg: dict, library, log=print, overlay=None):
|
||||
self.cfg = cfg
|
||||
self.library = library
|
||||
self.log = log
|
||||
self.overlay = overlay
|
||||
g = cfg.get("gsi", {})
|
||||
self.poll_interval = g.get("poll_interval", 1.0)
|
||||
self.confirm_polls = g.get("confirm_polls", 2)
|
||||
self.timeout = g.get("session_timeout", 300)
|
||||
# how much better a later reading must score before it may overwrite
|
||||
# an already confirmed pick
|
||||
self.revise_gain = g.get("revise_gain", 0.15)
|
||||
self.target = g.get("target_slots", 10)
|
||||
self.keep_frames = g.get("keep_event_frames", True)
|
||||
# >0 saves a frame every N seconds while the hero grid is up
|
||||
self.dump_every = g.get("dump_selection_every", 0)
|
||||
# after selection ends, keep reading strategy frames until 10/10 or
|
||||
# this many polls - catches the last reveal without hanging forever
|
||||
self.strategy_tail = g.get("strategy_tail_polls", 8)
|
||||
self.gsi_wait = g.get("strategy_gsi_wait", 3.0)
|
||||
# skip grab/recognize when Dota is not the foreground window
|
||||
self.require_foreground = bool(g.get("require_foreground", True))
|
||||
self.role_templates = load_role_templates()
|
||||
self.mode_templates = load_mode_templates()
|
||||
self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
self.frame_dir = raw_dir_for_match(None)
|
||||
rec = cfg.get("recommend") or {}
|
||||
self.recommend_enabled = bool(rec.get("enabled", True))
|
||||
self.recommend_top_n = int(rec.get("top_n", 0))
|
||||
self.recommend_min_enemies = int(rec.get("min_enemies", 1))
|
||||
self.recommend_min_heroes_for_gaps = int(rec.get("min_heroes_for_gaps", 2))
|
||||
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
||||
self.recommend_role_tags = rec.get("role_tags")
|
||||
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
|
||||
self._rec_warned = False
|
||||
self._last_rec_sig: tuple | None = None
|
||||
self._last_enemy_profile: dict = {}
|
||||
self._last_rec_meta: dict = {}
|
||||
|
||||
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
||||
if self.overlay is None:
|
||||
return
|
||||
try:
|
||||
self.overlay.set_roster(confirmed)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay update failed: {e}")
|
||||
|
||||
def _push_rec_overlay(
|
||||
self,
|
||||
cells: dict | None,
|
||||
picks: list[dict],
|
||||
analysis: str = "",
|
||||
) -> None:
|
||||
if self.overlay is None:
|
||||
return
|
||||
try:
|
||||
marks = {p["key"]: list(p.get("labels") or []) for p in picks if p.get("key")}
|
||||
self.overlay.set_grid_marks(cells or {}, marks)
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis(analysis or "")
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] rec overlay update failed: {e}")
|
||||
|
||||
def run(self, match_id: str, state_fn, gsi_fn=None) -> dict:
|
||||
"""Poll until the draft is over. state_fn returns the live GSI state."""
|
||||
self.frame_dir = raw_dir_for_match(match_id)
|
||||
self.log(f"[draft] frames -> {self.frame_dir}")
|
||||
if self.overlay is not None:
|
||||
try:
|
||||
self.overlay.set_roster({})
|
||||
self.overlay.set_grid_marks({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
self.overlay.show()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay show failed: {e}")
|
||||
if self.recommend_enabled and not self._rec_warned:
|
||||
rel = self.relations or {}
|
||||
if not rel.get("counters") and not rel.get("synergies"):
|
||||
self._rec_warned = True
|
||||
self.log("[rec] relations empty — edit shared/data/relations.json "
|
||||
"or run import_relations_xlsx.py")
|
||||
started = time.monotonic()
|
||||
pending: dict[int, tuple[str, int]] = {}
|
||||
confirmed: dict[int, str] = {}
|
||||
scores: dict[int, float] = {}
|
||||
revisions: list[dict] = []
|
||||
timeline: list[dict] = []
|
||||
info = {
|
||||
"self_slot": None,
|
||||
"self_team": None,
|
||||
"roles": {},
|
||||
"unavailable": None,
|
||||
"cells": None,
|
||||
"mode": None,
|
||||
"recommendations": [],
|
||||
}
|
||||
polls = 0
|
||||
last_frame = None
|
||||
best: dict | None = None
|
||||
next_dump = 0.0
|
||||
saved_milestones: set[int] = set()
|
||||
vision_done = False
|
||||
strategy_polls = 0
|
||||
freeze_at = 0.0
|
||||
skipped_fg = False
|
||||
|
||||
with mss.MSS() as sct:
|
||||
while True:
|
||||
state = state_fn()
|
||||
if state not in DRAFT_STATES:
|
||||
self.log(f"[draft] session ended (state={state})")
|
||||
break
|
||||
if time.monotonic() - started > self.timeout:
|
||||
self.log(f"[draft] session timed out after {self.timeout}s")
|
||||
break
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
# Vision finished: only wait on GSI / short timeout (no grab).
|
||||
if vision_done:
|
||||
gsi_hero = (gsi_fn() or {}).get("hero") if gsi_fn else None
|
||||
if gsi_hero or (elapsed - freeze_at) >= self.gsi_wait:
|
||||
break
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
|
||||
# Desktop / other apps must not burn strategy_tail or confirm streaks.
|
||||
if self.require_foreground and not is_dota_foreground():
|
||||
if not skipped_fg:
|
||||
self.log("[draft] Dota 2 not foreground - skipping frames")
|
||||
skipped_fg = True
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
if skipped_fg:
|
||||
self.log("[draft] Dota 2 foreground again - resuming vision")
|
||||
skipped_fg = False
|
||||
|
||||
frame = grab_frame(sct)
|
||||
last_frame = frame
|
||||
polls += 1
|
||||
self._sync_self_from_gsi(info, gsi_fn)
|
||||
|
||||
# Keep reading in strategy until the roster is full - the last
|
||||
# pick is often revealed on the same tick selection ends, and
|
||||
# a player who already locked may only see strategy UI.
|
||||
do_vision = (
|
||||
state == HERO_SELECTION
|
||||
or (state == STRATEGY_TIME and strategy_polls < self.strategy_tail)
|
||||
)
|
||||
if state == STRATEGY_TIME:
|
||||
strategy_polls += 1
|
||||
|
||||
if do_vision:
|
||||
if self.dump_every and state == HERO_SELECTION and elapsed >= next_dump:
|
||||
next_dump = elapsed + self.dump_every
|
||||
self.log(f"[draft] grid frame: {save_frame(frame, self.frame_dir, prefix='select')}")
|
||||
|
||||
if info["mode"] is None:
|
||||
found = detect_mode(frame, self.cfg, self.mode_templates)
|
||||
if found:
|
||||
info["mode"] = found
|
||||
self.log(f"[draft] mode: {found['label']} ({found['score']:.2f})")
|
||||
|
||||
result = recognize_image(frame, self.cfg, self.library)
|
||||
self._absorb_roles(frame, info)
|
||||
if state == HERO_SELECTION:
|
||||
self._absorb_grid(frame, info)
|
||||
|
||||
best = self._remember_best(best, frame, result, state, elapsed)
|
||||
# Strategy frames may show skins; only fill empty slots there.
|
||||
added, revised = self._absorb_picks(
|
||||
result, pending, confirmed, scores,
|
||||
allow_revise=(state == HERO_SELECTION),
|
||||
)
|
||||
for rev in revised:
|
||||
rev["t"] = round(elapsed, 1)
|
||||
revisions.append(rev)
|
||||
self._log_revision(rev)
|
||||
if added or revised:
|
||||
self._push_overlay(confirmed)
|
||||
if added:
|
||||
event = self._event(added, confirmed, state, elapsed)
|
||||
if self.keep_frames:
|
||||
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
||||
timeline.append(event)
|
||||
self._log_event(event, info)
|
||||
if state == HERO_SELECTION:
|
||||
self._refresh_recommendations(confirmed, info, gsi_fn)
|
||||
|
||||
n = len(confirmed)
|
||||
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
|
||||
saved_milestones.add(n)
|
||||
path = save_frame(frame, self.frame_dir, prefix=f"draft_n{n}")
|
||||
self.log(f"[draft] milestone {n}/10: {path}")
|
||||
|
||||
if n >= self.target or (
|
||||
state == STRATEGY_TIME and strategy_polls >= self.strategy_tail
|
||||
):
|
||||
self._finalize_vision(best, confirmed, scores)
|
||||
vision_done = True
|
||||
freeze_at = elapsed
|
||||
self.log("[draft] vision done - waiting on GSI for self hero")
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
if best and not vision_done:
|
||||
self._finalize_vision(best, confirmed, scores)
|
||||
self._push_overlay(confirmed)
|
||||
self._refresh_recommendations(confirmed, info, gsi_fn, force=True)
|
||||
if self.overlay is not None:
|
||||
try:
|
||||
self.overlay.set_grid_marks({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
self.overlay.hide()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay hide failed: {e}")
|
||||
keep = best["frame"] if best is not None else last_frame
|
||||
return self._summary(match_id, timeline, confirmed, info, polls, started, keep,
|
||||
gsi_fn, revisions, best)
|
||||
|
||||
def _finalize_vision(self, best: dict | None, confirmed: dict, scores: dict) -> None:
|
||||
if not best:
|
||||
return
|
||||
if best.get("path"):
|
||||
return
|
||||
self.log(f"[draft] best lineup frame: {best['recognized']}/10 "
|
||||
f"at t={best['t']:.1f}s ({best['state']})")
|
||||
filled = self._backfill(confirmed, scores, best)
|
||||
self._push_overlay(confirmed)
|
||||
if filled:
|
||||
self.log(f"[draft] backfilled {filled} slots from best frame")
|
||||
if self.keep_frames:
|
||||
best["path"] = save_frame(best["frame"], self.frame_dir, prefix="draft_best")
|
||||
self.log(f"[draft] saved best: {best['path']}")
|
||||
|
||||
def _backfill(self, confirmed: dict, scores: dict, best: dict) -> int:
|
||||
"""Copy threshold-passed heroes from the best frame into empty slots."""
|
||||
n = 0
|
||||
for i, hero in enumerate(best.get("heroes") or [], 1):
|
||||
if hero and i not in confirmed:
|
||||
confirmed[i] = hero
|
||||
scores[i] = 0.0
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def _remember_best(self, best: dict | None, frame, result: dict, state: str, t: float) -> dict:
|
||||
"""Track the clearest top-bar reading seen so far.
|
||||
|
||||
Prefer more recognized slots first (so a late last-pick still wins).
|
||||
On a tie, prefer HERO_SELECTION over STRATEGY_TIME so skinned strategy
|
||||
portraits do not replace a cleaner default-face frame. Score sum is
|
||||
the final tie-breaker within the same state preference.
|
||||
"""
|
||||
n = int(result.get("recognized") or 0)
|
||||
if n == 0:
|
||||
return best
|
||||
score_sum = sum(float(r.get("score") or 0) for r in result["slots"] if r.get("hero"))
|
||||
selection = state == HERO_SELECTION
|
||||
cand = {
|
||||
"recognized": n,
|
||||
"score_sum": score_sum,
|
||||
"selection": selection,
|
||||
"t": t,
|
||||
"state": state.replace("DOTA_GAMERULES_STATE_", ""),
|
||||
"frame": frame.copy(),
|
||||
"heroes": [r.get("hero") for r in result["slots"]],
|
||||
}
|
||||
if best is None:
|
||||
return cand
|
||||
if n > best["recognized"]:
|
||||
return cand
|
||||
if n < best["recognized"]:
|
||||
return best
|
||||
# same recognized count: prefer selection-phase default faces
|
||||
if selection and not best.get("selection", False):
|
||||
return cand
|
||||
if selection == best.get("selection", False) and score_sum > best["score_sum"]:
|
||||
return cand
|
||||
return best
|
||||
|
||||
def _sync_self_from_gsi(self, info: dict, gsi_fn) -> None:
|
||||
"""Own top-bar slot comes only from GSI team_slot."""
|
||||
if not gsi_fn:
|
||||
return
|
||||
gsi = gsi_fn() or {}
|
||||
slot = gsi_slot(gsi)
|
||||
if slot is not None:
|
||||
info["self_slot"] = slot
|
||||
if gsi.get("team") in ("radiant", "dire"):
|
||||
info["self_team"] = gsi["team"]
|
||||
|
||||
def _absorb_roles(self, frame, info: dict) -> None:
|
||||
"""Lane labels never change mid-draft; stop scanning once found."""
|
||||
if info["roles"]:
|
||||
return
|
||||
found = detect_roles(frame, self.cfg, self.role_templates)
|
||||
if found["roles"]:
|
||||
info["roles"] = found["roles"]
|
||||
if found["self_team"] and not info["self_team"]:
|
||||
info["self_team"] = found["self_team"]
|
||||
|
||||
def _absorb_grid(self, frame, info: dict) -> None:
|
||||
"""Read bans (once) and cell rects (whenever the lattice is readable).
|
||||
|
||||
The set of banned heroes is fixed before the first pick, so the
|
||||
earliest readable frame is also the cleanest. Cell geometry is
|
||||
refreshed so recommend badges stay aligned while the grid is up.
|
||||
"""
|
||||
res = read_grid(frame, self.cfg)
|
||||
if not res["ok"]:
|
||||
return
|
||||
if res.get("cells"):
|
||||
info["cells"] = res["cells"]
|
||||
if info["unavailable"] is not None:
|
||||
return
|
||||
info["unavailable"] = res["unavailable"]
|
||||
names = ", ".join(self.hero_names.get(k, k) for k in res["unavailable"])
|
||||
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
||||
f"(contrast margin {res['margin']}) - {names}")
|
||||
|
||||
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
|
||||
if not self.recommend_enabled:
|
||||
return
|
||||
if not self.relations and not self.recommend_archetypes:
|
||||
return
|
||||
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
||||
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
||||
self_team = gsi.get("team") or info.get("self_team")
|
||||
role = info["roles"].get(self_slot) if self_slot else None
|
||||
position = role["position"] if role else None
|
||||
# Stop suggesting once you have locked a hero.
|
||||
if self_slot and confirmed.get(self_slot):
|
||||
if info.get("recommendations") or self._last_rec_sig is not None:
|
||||
info["recommendations"] = []
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._last_rec_sig = ("locked",)
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
return
|
||||
enemies = enemy_keys(confirmed, self_team)
|
||||
allies = ally_keys(confirmed, self_team, self_slot)
|
||||
exclude = set(confirmed.values())
|
||||
if info.get("unavailable"):
|
||||
exclude.update(info["unavailable"])
|
||||
sig = (position, self_team, tuple(enemies), tuple(allies), tuple(sorted(exclude)))
|
||||
if not force and sig == self._last_rec_sig:
|
||||
return
|
||||
self._last_rec_sig = sig
|
||||
if len(enemies) < self.recommend_min_enemies:
|
||||
info["recommendations"] = []
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
return
|
||||
result = suggest_marks(
|
||||
position=position,
|
||||
enemies=enemies,
|
||||
allies=allies,
|
||||
exclude=exclude,
|
||||
relations=self.relations,
|
||||
top_n=self.recommend_top_n,
|
||||
role_tags=self.recommend_role_tags,
|
||||
min_enemies=self.recommend_min_enemies,
|
||||
min_heroes_for_gaps=self.recommend_min_heroes_for_gaps,
|
||||
archetypes_enabled=self.recommend_archetypes,
|
||||
)
|
||||
picks = result.get("marks") or []
|
||||
profile = result.get("enemy_profile") or {}
|
||||
analysis = result.get("analysis") or ""
|
||||
info["recommendations"] = picks
|
||||
self._last_enemy_profile = profile
|
||||
self._last_rec_meta = {
|
||||
"analysis": analysis,
|
||||
"enemy_archetypes": list(result.get("enemy_archetypes") or []),
|
||||
"enemy_gaps": list(result.get("enemy_gaps") or []),
|
||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||
"ally_profile": dict(result.get("ally_profile") or {}),
|
||||
}
|
||||
self._push_rec_overlay(info.get("cells"), picks, analysis)
|
||||
if picks or analysis:
|
||||
bits = []
|
||||
for p in picks[:12]:
|
||||
labs = "".join(p.get("labels") or [])
|
||||
why = "、".join(p.get("reasons") or [])
|
||||
extra = f":{why}" if why else ""
|
||||
bits.append(f"{p['name_loc']}[{labs}]{extra}")
|
||||
pos_s = f"pos{position}" if position is not None else "all"
|
||||
ally_n = [self.hero_names.get(a, a) for a in allies]
|
||||
enemy_n = [self.hero_names.get(e, e) for e in enemies]
|
||||
analysis_s = analysis or "-"
|
||||
self.log(
|
||||
f"[rec] {pos_s} {analysis_s} | with {ally_n} vs {enemy_n}: "
|
||||
f"{len(picks)} marks — {', '.join(bits)}"
|
||||
)
|
||||
|
||||
def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
|
||||
scores: dict, *, allow_revise: bool = True,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Promote picks seen on enough consecutive frames.
|
||||
|
||||
Returns (new picks, revisions). During HERO_SELECTION a slot stays
|
||||
open to revision because the frame that first reveals a portrait is
|
||||
the worst one to judge it on: the art is still fading in and the
|
||||
ranked title bar covers the lower face. Once the portrait settles it
|
||||
scores far higher, and a clearly better reading may overwrite.
|
||||
|
||||
During STRATEGY_TIME set allow_revise=False: only empty slots may be
|
||||
filled. Skinned portraits must not replace a confirmed default face.
|
||||
"""
|
||||
added, revised = [], []
|
||||
for r in result["slots"]:
|
||||
slot, hero, score = r["slot"], r["hero"], r["score"]
|
||||
if hero is None:
|
||||
continue
|
||||
|
||||
if slot in confirmed:
|
||||
if hero == confirmed[slot]:
|
||||
scores[slot] = max(scores.get(slot, 0.0), score)
|
||||
pending.pop(slot, None)
|
||||
continue
|
||||
if not allow_revise:
|
||||
continue
|
||||
if score < scores.get(slot, 0.0) + self.revise_gain:
|
||||
continue
|
||||
prev_hero, streak = pending.get(slot, (None, 0))
|
||||
streak = streak + 1 if hero == prev_hero else 1
|
||||
pending[slot] = (hero, streak)
|
||||
if streak >= self.confirm_polls:
|
||||
revised.append({"slot": slot, "team": team_of(slot),
|
||||
"hero": hero, "was": confirmed[slot],
|
||||
"score": score, "was_score": scores.get(slot, 0.0)})
|
||||
confirmed[slot] = hero
|
||||
scores[slot] = score
|
||||
pending.pop(slot, None)
|
||||
continue
|
||||
|
||||
prev_hero, streak = pending.get(slot, (None, 0))
|
||||
streak = streak + 1 if hero == prev_hero else 1
|
||||
pending[slot] = (hero, streak)
|
||||
if streak >= self.confirm_polls:
|
||||
confirmed[slot] = hero
|
||||
scores[slot] = score
|
||||
pending.pop(slot, None)
|
||||
added.append({"slot": slot, "team": team_of(slot), "hero": hero})
|
||||
return added, revised
|
||||
|
||||
def _event(self, added: list[dict], confirmed: dict, state: str, elapsed: float) -> dict:
|
||||
radiant = sorted(s for s in confirmed if s <= 5)
|
||||
dire = sorted(s for s in confirmed if s > 5)
|
||||
return {
|
||||
"t": round(elapsed, 1),
|
||||
"state": state.replace("DOTA_GAMERULES_STATE_", ""),
|
||||
"round": pick_round(max(len(radiant), len(dire))),
|
||||
"added": added,
|
||||
"radiant": [confirmed[s] for s in radiant],
|
||||
"dire": [confirmed[s] for s in dire],
|
||||
"count": len(confirmed),
|
||||
}
|
||||
|
||||
def _log_event(self, event: dict, info: dict) -> None:
|
||||
for a in event["added"]:
|
||||
mine = " <- you" if a["slot"] == info["self_slot"] else ""
|
||||
role = info["roles"].get(a["slot"])
|
||||
tag = f" [{role['label']}]" if role else ""
|
||||
self.log(
|
||||
f"[draft] +{event['t']:6.1f}s round{event['round']} "
|
||||
f"{a['team']:7s} slot{a['slot']:<2d} {loc(a['hero'], self.hero_names)}{tag}{mine}"
|
||||
)
|
||||
|
||||
def _log_revision(self, rev: dict) -> None:
|
||||
self.log(
|
||||
f"[draft] ~{rev['t']:6.1f}s slot{rev['slot']:<2d} "
|
||||
f"{loc(rev['was'], self.hero_names)} -> {loc(rev['hero'], self.hero_names)} "
|
||||
f"(score {rev['was_score']:.2f} -> {rev['score']:.2f})"
|
||||
)
|
||||
|
||||
def _summary(self, match_id, timeline, confirmed, info, polls, started, frame, gsi_fn,
|
||||
revisions=None, best=None) -> dict:
|
||||
gsi = gsi_fn() if gsi_fn else {}
|
||||
self_slot = gsi_slot(gsi) or info["self_slot"]
|
||||
|
||||
# GSI knows your own hero with certainty once it is locked. Prefer it.
|
||||
gsi_hero = gsi.get("hero")
|
||||
if self_slot and gsi_hero and confirmed.get(self_slot) != gsi_hero:
|
||||
prev = confirmed.get(self_slot)
|
||||
confirmed[self_slot] = gsi_hero
|
||||
self.log(
|
||||
f"[draft] self hero {loc(prev, self.hero_names)} -> "
|
||||
f"{loc(gsi_hero, self.hero_names)} (GSI)"
|
||||
)
|
||||
|
||||
role = info["roles"].get(self_slot) if self_slot else None
|
||||
team = gsi.get("team") or info["self_team"]
|
||||
enemies = enemy_keys(confirmed, team)
|
||||
allies = ally_keys(confirmed, team, self_slot)
|
||||
summary = {
|
||||
"match_id": match_id,
|
||||
"captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"duration_s": round(time.monotonic() - started, 1),
|
||||
"polls": polls,
|
||||
"mode": info.get("mode"),
|
||||
"self": {
|
||||
"slot": self_slot,
|
||||
"team": gsi.get("team") or info["self_team"] or (team_of(self_slot) if self_slot else None),
|
||||
"hero": (confirmed.get(self_slot) if self_slot else None) or gsi_hero,
|
||||
"role": role["role"] if role else None,
|
||||
"role_label": role["label"] if role else None,
|
||||
"position": role["position"] if role else None,
|
||||
"gsi_name": gsi.get("name"),
|
||||
"accountid": gsi.get("accountid"),
|
||||
"steamid": gsi.get("steamid"),
|
||||
},
|
||||
"team_roles": {
|
||||
str(s): {"position": r["position"], "label": r["label"], "hero": confirmed.get(s)}
|
||||
for s, r in sorted(info["roles"].items())
|
||||
},
|
||||
"final": {
|
||||
"radiant": [confirmed.get(s) for s in range(1, 6)],
|
||||
"dire": [confirmed.get(s) for s in range(6, 11)],
|
||||
},
|
||||
"recognized": len(confirmed),
|
||||
"revisions": revisions or [],
|
||||
"timeline": timeline,
|
||||
"recommendations": {
|
||||
"position": role["position"] if role else None,
|
||||
"enemies": enemies,
|
||||
"allies": allies,
|
||||
"enemy_profile": dict(self._last_enemy_profile or {}),
|
||||
"ally_profile": dict((self._last_rec_meta or {}).get("ally_profile") or {}),
|
||||
"enemy_archetypes": list((self._last_rec_meta or {}).get("enemy_archetypes") or []),
|
||||
"enemy_gaps": list((self._last_rec_meta or {}).get("enemy_gaps") or []),
|
||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
||||
"picks": info.get("recommendations") or [],
|
||||
},
|
||||
}
|
||||
if info["unavailable"] is not None:
|
||||
banned = bans({"unavailable": info["unavailable"]}, list(confirmed.values()))
|
||||
summary["bans"] = banned
|
||||
summary["bans_loc"] = [self.hero_names.get(k, k) for k in banned]
|
||||
if best:
|
||||
summary["best_lineup"] = {
|
||||
"recognized": best["recognized"],
|
||||
"t": round(best["t"], 1),
|
||||
"state": best["state"],
|
||||
"heroes": best["heroes"],
|
||||
"frame": best.get("path"),
|
||||
}
|
||||
if frame is not None and self.keep_frames:
|
||||
# `frame` is already the best readable lineup when one was found
|
||||
summary["last_frame"] = best.get("path") if best and best.get("path") else \
|
||||
save_frame(frame, self.frame_dir, prefix="draft")
|
||||
return summary
|
||||
|
||||
|
||||
def team_of(slot: int) -> str:
|
||||
return "radiant" if slot <= 5 else "dire"
|
||||
|
||||
|
||||
def gsi_slot(gsi: dict) -> int | None:
|
||||
"""Top-bar slot from GSI's own team_slot, which beats any pixel heuristic.
|
||||
|
||||
The bar is ordered by team slot, radiant on the left. GSI leaves the
|
||||
player block out until a match is loaded, hence the None path.
|
||||
"""
|
||||
team_slot = gsi.get("team_slot")
|
||||
team = gsi.get("team")
|
||||
if team_slot is None or team not in ("radiant", "dire"):
|
||||
return None
|
||||
return int(team_slot) + (1 if team == "radiant" else 6)
|
||||
|
||||
|
||||
def loc(key: str | None, names: dict[str, str] | None = None) -> str:
|
||||
"""English hero key -> in-client Chinese name, for human-facing output."""
|
||||
if not key:
|
||||
return "?"
|
||||
if names is None:
|
||||
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
return names.get(key, key)
|
||||
|
||||
|
||||
def describe(summary: dict) -> list[str]:
|
||||
"""Human-readable recap printed when a session ends."""
|
||||
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
me = summary["self"]
|
||||
lines = []
|
||||
mode = summary.get("mode")
|
||||
if mode:
|
||||
lines.append(f"mode : {mode.get('label') or mode.get('key')}")
|
||||
for side in ("radiant", "dire"):
|
||||
heroes = [loc(h, names) for h in summary["final"][side]]
|
||||
lines.append(f"{side:7s}: {', '.join(heroes)}")
|
||||
if me["slot"]:
|
||||
pos = f"position {me['position']} ({me['role_label']})" if me["position"] else "position unknown"
|
||||
lines.append(f"you : slot {me['slot']} {me['team']} {loc(me['hero'], names)} - {pos}")
|
||||
if summary["team_roles"]:
|
||||
order = ", ".join(
|
||||
f"{v['position']}:{loc(v['hero'], names)}"
|
||||
for v in sorted(summary["team_roles"].values(), key=lambda v: v["position"])
|
||||
)
|
||||
lines.append(f"lanes : {order}")
|
||||
lines.append(f"rounds : {len(summary['timeline'])} reveal events over {summary['duration_s']}s")
|
||||
if summary.get("bans_loc"):
|
||||
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
|
||||
rec = summary.get("recommendations") or {}
|
||||
analysis = rec.get("analysis") or ""
|
||||
if analysis:
|
||||
lines.append(f"draft : {analysis}")
|
||||
picks = rec.get("picks") or []
|
||||
if picks:
|
||||
bits = []
|
||||
for p in picks[:15]:
|
||||
labs = "".join(p.get("labels") or []) or "?"
|
||||
why = "、".join(p.get("reasons") or [])
|
||||
name = p.get("name_loc") or loc(p["key"], names)
|
||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
||||
return lines
|
||||
|
||||
|
||||
__all__ = ["DraftSession", "DRAFT_STATES", "HERO_SELECTION", "STRATEGY_TIME", "describe", "loc", "ROLES"]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Score the recogniser against every labelled frame at once.
|
||||
|
||||
Labels live in samples/labels.json as {frame filename: 10 hero keys}, '?' for
|
||||
slots nobody has identified yet. Those slots are skipped, not counted wrong.
|
||||
|
||||
Usage:
|
||||
python evaluate.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
|
||||
import cv2
|
||||
|
||||
from common import ROOT, load_config, load_template_library
|
||||
from recognize import recognize_image
|
||||
|
||||
LABELS_PATH = ROOT / "samples" / "labels.json"
|
||||
RAW_DIR = ROOT / "samples" / "raw"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not LABELS_PATH.is_file():
|
||||
sys.exit(f"missing {LABELS_PATH}")
|
||||
frames = json.loads(LABELS_PATH.read_text(encoding="utf-8"))["frames"]
|
||||
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run autocalibrate.py first")
|
||||
library = load_template_library()
|
||||
print(f"library: {len(library)} CDN templates")
|
||||
|
||||
graded = correct = skipped = 0
|
||||
misses: list[str] = []
|
||||
for name, truth in frames.items():
|
||||
path = RAW_DIR / name
|
||||
img = cv2.imread(str(path))
|
||||
if img is None:
|
||||
print(f" {name}: MISSING, skipped")
|
||||
continue
|
||||
result = recognize_image(img, cfg, library)
|
||||
|
||||
hits = frame_graded = 0
|
||||
worst = 1.0
|
||||
for slot, expected in zip(result["slots"], truth):
|
||||
if expected == "?":
|
||||
skipped += 1
|
||||
continue
|
||||
frame_graded += 1
|
||||
worst = min(worst, slot["score"])
|
||||
if slot["hero"] == expected:
|
||||
hits += 1
|
||||
else:
|
||||
misses.append(
|
||||
f" {name} slot {slot['slot']}: expected {expected}, "
|
||||
f"got {slot['hero']} (raw {slot['raw_best']} "
|
||||
f"score {slot['score']} margin {slot['margin']})"
|
||||
)
|
||||
graded += frame_graded
|
||||
correct += hits
|
||||
flag = " ranked" if result.get("ranked_overlay") else ""
|
||||
print(f" {name}: {hits}/{frame_graded} lowest score {worst:.3f} {result['elapsed_ms']}ms{flag}")
|
||||
|
||||
if misses:
|
||||
print("\nmisses:")
|
||||
print("\n".join(misses))
|
||||
pct = 100 * correct / graded if graded else 0
|
||||
print(f"\ntotal: {correct}/{graded} ({pct:.1f}%){f', {skipped} unlabelled slots skipped' if skipped else ''}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Download all hero portraits from Steam CDN as fallback templates.
|
||||
|
||||
Usage:
|
||||
python fetch_cdn_templates.py
|
||||
|
||||
Writes:
|
||||
shared/data/heroes.json - hero id / key / English name / roles / aliases / base stats
|
||||
templates/cdn/{key}.png - face-centered square crop resized to canonical size
|
||||
|
||||
Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import HEROES_JSON, TEMPLATES_CDN, load_config
|
||||
from shared.hero_tags import tags_for_hero
|
||||
from shared.http_utils import fetch_hero_list, http_bytes, http_json
|
||||
|
||||
# Steam CDN landscape hero art; cropped to a face window for top-bar matching.
|
||||
IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
|
||||
# OpenDota constants: roles + base combat / attribute stats.
|
||||
ROLES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/heroes.json"
|
||||
ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
|
||||
|
||||
# Level-1 display vitals (match Valve herodata / dota2.com hero strip).
|
||||
HP_PER_STR = 22
|
||||
MANA_PER_INT = 12
|
||||
HP_REGEN_PER_STR = 0.1
|
||||
MANA_REGEN_PER_INT = 0.05
|
||||
ARMOR_PER_AGI = 1.0 / 6.0
|
||||
|
||||
# The top bar shows a fixed window of the landscape hero art, not a centred
|
||||
# square. These bounds were fitted against 15 portraits captured in game:
|
||||
# they lift the mean match score from 0.65 to 0.94. Stored as fractions of
|
||||
# the source width so they hold whatever size the CDN serves.
|
||||
CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256
|
||||
|
||||
|
||||
def _num(raw: dict, key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(raw.get(key, default) or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _round1(v: float) -> float:
|
||||
return round(v + 1e-9, 1)
|
||||
|
||||
|
||||
def _round2(v: float) -> float:
|
||||
return round(v + 1e-9, 2)
|
||||
|
||||
|
||||
def stats_from_opendota(raw: dict) -> dict:
|
||||
"""Derive level-1 strip + combat fields from an OpenDota heroes.json row."""
|
||||
base_str = _num(raw, "base_str")
|
||||
base_agi = _num(raw, "base_agi")
|
||||
base_int = _num(raw, "base_int")
|
||||
primary = str(raw.get("primary_attr") or "all")
|
||||
atk_min = _num(raw, "base_attack_min")
|
||||
atk_max = _num(raw, "base_attack_max")
|
||||
if primary == "str":
|
||||
dmg_bonus = base_str
|
||||
elif primary == "agi":
|
||||
dmg_bonus = base_agi
|
||||
elif primary == "int":
|
||||
dmg_bonus = base_int
|
||||
else:
|
||||
dmg_bonus = 0.7 * (base_str + base_agi + base_int)
|
||||
result = {
|
||||
"base_str": int(base_str) if base_str == int(base_str) else base_str,
|
||||
"str_gain": _round1(_num(raw, "str_gain")),
|
||||
"base_agi": int(base_agi) if base_agi == int(base_agi) else base_agi,
|
||||
"agi_gain": _round1(_num(raw, "agi_gain")),
|
||||
"base_int": int(base_int) if base_int == int(base_int) else base_int,
|
||||
"int_gain": _round1(_num(raw, "int_gain")),
|
||||
"health": int(round(_num(raw, "base_health", 120) + base_str * HP_PER_STR)),
|
||||
"mana": int(round(_num(raw, "base_mana", 75) + base_int * MANA_PER_INT)),
|
||||
"health_regen": _round2(
|
||||
_num(raw, "base_health_regen") + base_str * HP_REGEN_PER_STR
|
||||
),
|
||||
"mana_regen": _round2(
|
||||
_num(raw, "base_mana_regen") + base_int * MANA_REGEN_PER_INT
|
||||
),
|
||||
"armor": _round2(_num(raw, "base_armor") + base_agi * ARMOR_PER_AGI),
|
||||
"damage_min": int(round(atk_min + dmg_bonus)),
|
||||
"damage_max": int(round(atk_max + dmg_bonus)),
|
||||
"move_speed": int(_num(raw, "move_speed")),
|
||||
"attack_range": int(_num(raw, "attack_range")),
|
||||
# OpenDota attack_rate == in-game BAT (base attack time).
|
||||
"attack_rate": _round1(_num(raw, "attack_rate")),
|
||||
"projectile_speed": int(_num(raw, "projectile_speed")),
|
||||
"magic_resist": int(_num(raw, "base_mr", 25)),
|
||||
"vision_day": int(_num(raw, "day_vision", 1800)),
|
||||
"vision_night": int(_num(raw, "night_vision", 800)),
|
||||
}
|
||||
# turn_rate is null in OpenDota for heroes using the game default — omit
|
||||
# rather than inventing 0.6 so the UI only shows an explicit value.
|
||||
turn = raw.get("turn_rate")
|
||||
if turn is not None:
|
||||
try:
|
||||
result["turn_rate"] = _round1(float(turn))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def fetch_opendota_by_id() -> dict[int, dict]:
|
||||
"""Map hero id -> OpenDota row (roles + base stats)."""
|
||||
data = http_json(ROLES_URL, timeout=30)
|
||||
out: dict[int, dict] = {}
|
||||
for raw in data.values():
|
||||
hid = raw.get("id")
|
||||
if hid is None:
|
||||
continue
|
||||
out[int(hid)] = raw
|
||||
return out
|
||||
|
||||
|
||||
def fetch_roles_by_id() -> dict[int, list[str]]:
|
||||
"""Map hero id -> OpenDota role tags (Carry, Nuker, Initiator, ...)."""
|
||||
return {
|
||||
hid: list(raw.get("roles") or [])
|
||||
for hid, raw in fetch_opendota_by_id().items()
|
||||
}
|
||||
|
||||
|
||||
def load_existing_str_lists(field: str) -> dict[str, list[str]]:
|
||||
"""key -> curated string list for `aliases` or `abbr` (empty if file missing)."""
|
||||
if not HEROES_JSON.is_file():
|
||||
return {}
|
||||
try:
|
||||
rows = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
key = row.get("key")
|
||||
if not key:
|
||||
continue
|
||||
vals = row.get(field)
|
||||
if isinstance(vals, list):
|
||||
cleaned = [a.strip().lower() if field == "abbr" else a.strip()
|
||||
for a in vals if isinstance(a, str) and a.strip()]
|
||||
out[str(key)] = cleaned
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
size = cfg["canonical_size"]
|
||||
TEMPLATES_CDN.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("fetching hero list from the Dota 2 data feed...")
|
||||
heroes = fetch_hero_list()
|
||||
print("fetching roles + base stats from dotaconstants...")
|
||||
odota_by_id = fetch_opendota_by_id()
|
||||
aliases_by_key = load_existing_str_lists("aliases")
|
||||
abbr_by_key = load_existing_str_lists("abbr")
|
||||
|
||||
table = []
|
||||
ok, fail = 0, 0
|
||||
for h in heroes:
|
||||
key = h["name"].removeprefix("npc_dota_hero_")
|
||||
odota = odota_by_id.get(int(h["id"])) or {}
|
||||
roles = list(odota.get("roles") or [])
|
||||
row = {
|
||||
"id": h["id"],
|
||||
"key": key,
|
||||
"name": h["name_english_loc"],
|
||||
"attr": ATTRS.get(h["primary_attr"], "all"),
|
||||
"name_loc": h["name_loc"],
|
||||
"roles": roles,
|
||||
"tags": tags_for_hero(key, roles),
|
||||
"aliases": aliases_by_key.get(key, []),
|
||||
"abbr": abbr_by_key.get(key, []),
|
||||
}
|
||||
if odota:
|
||||
row.update(stats_from_opendota(odota))
|
||||
table.append(row)
|
||||
out = TEMPLATES_CDN / f"{key}.png"
|
||||
if out.exists():
|
||||
ok += 1
|
||||
continue
|
||||
try:
|
||||
raw = http_bytes(IMG_URL.format(key=key), timeout=30)
|
||||
img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise ValueError("decode failed")
|
||||
iw = img.shape[1]
|
||||
window = img[:, int(iw * CROP_X0) : int(iw * CROP_X1)]
|
||||
cv2.imwrite(str(out), cv2.resize(window, (size, size), interpolation=cv2.INTER_AREA))
|
||||
ok += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" FAILED {key}: {e}")
|
||||
fail += 1
|
||||
|
||||
HEROES_JSON.write_text(
|
||||
json.dumps(sorted(table, key=lambda t: t["id"]), ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in {HEROES_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Install the Game State Integration config that lets Dota 2 talk to gsi_watch.py.
|
||||
|
||||
Usage:
|
||||
python gsi_setup.py # auto-detect Dota 2 and write the cfg
|
||||
python gsi_setup.py --path "D:\\Steam\\steamapps\\common\\dota 2 beta"
|
||||
python gsi_setup.py --remove # uninstall the cfg
|
||||
python gsi_setup.py --check # only report where things are
|
||||
|
||||
After installing, add -gamestateintegration to Dota 2's launch options
|
||||
(Steam library -> right-click Dota 2 -> Properties) and restart the game.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import re
|
||||
|
||||
from common import load_config
|
||||
|
||||
CFG_NAME = "gamestate_integration_climperor.cfg"
|
||||
|
||||
CFG_TEMPLATE = """"Climperor"
|
||||
{{
|
||||
"uri" "http://127.0.0.1:{port}/"
|
||||
"timeout" "5.0"
|
||||
"buffer" "0.1"
|
||||
"throttle" "0.5"
|
||||
"heartbeat" "30.0"
|
||||
"data"
|
||||
{{
|
||||
"provider" "1"
|
||||
"map" "1"
|
||||
"player" "1"
|
||||
"hero" "1"
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
FALLBACK_ROOTS = [
|
||||
r"C:\Program Files (x86)\Steam",
|
||||
r"C:\Steam",
|
||||
r"D:\Steam",
|
||||
r"D:\SteamLibrary",
|
||||
r"E:\SteamLibrary",
|
||||
]
|
||||
|
||||
|
||||
def steam_roots() -> list[Path]:
|
||||
"""Candidate Steam library roots, from the registry plus libraryfolders.vdf."""
|
||||
roots: list[Path] = []
|
||||
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam") as key:
|
||||
roots.append(Path(winreg.QueryValueEx(key, "SteamPath")[0]))
|
||||
except Exception: # noqa: BLE001 - registry is best-effort
|
||||
pass
|
||||
|
||||
roots += [Path(p) for p in FALLBACK_ROOTS]
|
||||
|
||||
# libraryfolders.vdf lists every additional install drive
|
||||
for root in list(roots):
|
||||
vdf = root / "steamapps" / "libraryfolders.vdf"
|
||||
if not vdf.is_file():
|
||||
continue
|
||||
try:
|
||||
text = vdf.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
for match in re.finditer(r'"path"\s+"([^"]+)"', text):
|
||||
roots.append(Path(match.group(1).replace("\\\\", "\\")))
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: list[Path] = []
|
||||
for r in roots:
|
||||
k = str(r).lower()
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
def find_dota() -> Path | None:
|
||||
"""Locate the 'dota 2 beta' install directory."""
|
||||
for root in steam_roots():
|
||||
candidate = root / "steamapps" / "common" / "dota 2 beta"
|
||||
if (candidate / "game" / "dota").is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def gsi_dir(dota: Path) -> Path:
|
||||
return dota / "game" / "dota" / "cfg" / "gamestate_integration"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
if "--path" in args:
|
||||
dota = Path(args[args.index("--path") + 1])
|
||||
if not (dota / "game" / "dota").is_dir():
|
||||
sys.exit(f"not a Dota 2 install directory: {dota}")
|
||||
else:
|
||||
dota = find_dota()
|
||||
if dota is None:
|
||||
sys.exit(
|
||||
"could not find Dota 2 automatically.\n"
|
||||
"Pass it explicitly, e.g.:\n"
|
||||
' python gsi_setup.py --path "D:\\Steam\\steamapps\\common\\dota 2 beta"'
|
||||
)
|
||||
|
||||
target = gsi_dir(dota) / CFG_NAME
|
||||
print(f"dota 2 : {dota}")
|
||||
print(f"gsi cfg : {target}")
|
||||
|
||||
if "--check" in args:
|
||||
print(f"installed: {target.is_file()}")
|
||||
return
|
||||
|
||||
if "--remove" in args:
|
||||
if target.is_file():
|
||||
target.unlink()
|
||||
print("removed.")
|
||||
else:
|
||||
print("nothing to remove.")
|
||||
return
|
||||
|
||||
port = load_config().get("gsi", {}).get("port", 3223)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(CFG_TEMPLATE.format(port=port), encoding="utf-8")
|
||||
|
||||
print(f"installed, endpoint http://127.0.0.1:{port}/")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" 1. Steam library -> Dota 2 -> Properties -> Launch Options:")
|
||||
print(" add -gamestateintegration")
|
||||
print(" 2. Restart Dota 2.")
|
||||
print(" 3. Run python gsi_watch.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Watch Dota 2 via Game State Integration and recognize the draft automatically.
|
||||
|
||||
Usage:
|
||||
python gsi_setup.py # once: install the GSI cfg into Dota 2
|
||||
python gsi_watch.py # then leave this running while you play
|
||||
python gsi_watch.py --once # capture+recognize right now, no GSI
|
||||
python gsi_watch.py --port 3223
|
||||
python gsi_watch.py --states HERO_SELECTION,STRATEGY_TIME
|
||||
python gsi_watch.py --dump-gsi # force full GSI JSONL logging
|
||||
python gsi_watch.py --no-dump-gsi # disable it
|
||||
|
||||
When the game enters hero selection the watcher follows the whole draft,
|
||||
polling the screen and logging each pick as it is revealed (All Pick reveals
|
||||
them in waves of 2/2/1 per team). It also works out which slot is you and
|
||||
which lane role you queued for. The result lands in results/draft_<ts>.json.
|
||||
|
||||
If config.json has no calibrated slots yet the watcher runs in capture-only
|
||||
mode: it still saves frames to samples/raw/<matchid>/ so you can calibrate from them.
|
||||
|
||||
With gsi.dump_payloads (default on), every POST body is appended to
|
||||
samples/raw/<matchid>/gsi.jsonl for later analysis.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import mss
|
||||
|
||||
from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_for_match, save_frame
|
||||
from common import ROOT, load_config, load_template_library
|
||||
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
|
||||
from overlay import DraftOverlay
|
||||
from recognize import recognize_image
|
||||
from roles import detect_roles
|
||||
|
||||
RESULTS_DIR = ROOT / "results"
|
||||
|
||||
# entering any of these means a new match is starting - allow triggering again
|
||||
RESET_STATES = {
|
||||
"DOTA_GAMERULES_STATE_INIT",
|
||||
"DOTA_GAMERULES_STATE_WAIT_FOR_PLAYERS_TO_LOAD",
|
||||
"DOTA_GAMERULES_STATE_POST_GAME",
|
||||
"DOTA_GAMERULES_STATE_DISCONNECT",
|
||||
}
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""Turns GSI state changes into capture+recognize runs."""
|
||||
|
||||
def __init__(self, cfg: dict, calibrated: bool, verbose: bool = False):
|
||||
self.cfg = cfg
|
||||
self.calibrated = calibrated
|
||||
self.verbose = verbose
|
||||
gsi = cfg.get("gsi", {})
|
||||
self.trigger_states = set(gsi.get("trigger_states", ["DOTA_GAMERULES_STATE_STRATEGY_TIME"]))
|
||||
self.interval = gsi.get("capture_interval", 1.0)
|
||||
self.dump_payloads = bool(gsi.get("dump_payloads", True))
|
||||
|
||||
self.library = load_template_library() if calibrated else []
|
||||
self.last_state: str | None = None
|
||||
self.last_match_id: str | None = None
|
||||
self.handled_matches: set[str] = set()
|
||||
self.connected = False
|
||||
self.busy = threading.Lock()
|
||||
self.dump_lock = threading.Lock()
|
||||
self.self_info: dict = {}
|
||||
self._dump_announced: set[str] = set()
|
||||
# One overlay for the whole process - Tk does not like create/destroy per match.
|
||||
self.overlay = None
|
||||
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
|
||||
try:
|
||||
self.overlay = DraftOverlay(cfg)
|
||||
print("[draft] role-tag overlay ready", flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[draft] overlay disabled: {e}", flush=True)
|
||||
self.overlay = None
|
||||
|
||||
def on_payload(self, payload: dict) -> None:
|
||||
if not self.connected:
|
||||
self.connected = True
|
||||
name = (payload.get("provider") or {}).get("name", "Dota 2")
|
||||
print(f"[gsi] connected to {name}", flush=True)
|
||||
|
||||
p = payload.get("player") or {}
|
||||
h = payload.get("hero") or {}
|
||||
self.self_info = {
|
||||
"name": p.get("name"),
|
||||
"team": p.get("team_name"),
|
||||
"team_slot": p.get("team_slot"),
|
||||
"hero": (h.get("name") or "").replace("npc_dota_hero_", "") or None,
|
||||
"accountid": p.get("accountid"),
|
||||
"steamid": p.get("steamid"),
|
||||
}
|
||||
|
||||
state = (payload.get("map") or {}).get("game_state")
|
||||
match_id = str((payload.get("map") or {}).get("matchid") or "no-match")
|
||||
self.last_match_id = match_id
|
||||
if self.dump_payloads:
|
||||
self._dump_gsi(match_id, payload)
|
||||
if state is None:
|
||||
# main menu / no active match
|
||||
if self.last_state is not None:
|
||||
print("[gsi] left match (back in menu)", flush=True)
|
||||
self.last_state = None
|
||||
return
|
||||
|
||||
if state in RESET_STATES and self.handled_matches:
|
||||
self.handled_matches.clear()
|
||||
|
||||
if state != self.last_state:
|
||||
m = payload.get("map") or {}
|
||||
extras = {k: m[k] for k in ("game_mode", "lobby_type", "customgamename", "name") if k in m}
|
||||
print(f"[gsi] {self.last_state} -> {state} (match {match_id}) {extras or ''}", flush=True)
|
||||
if not getattr(self, "_dumped_map_keys", False) and m:
|
||||
self._dumped_map_keys = True
|
||||
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
|
||||
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
|
||||
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
|
||||
self.last_state = state
|
||||
|
||||
if state not in self.trigger_states:
|
||||
return
|
||||
# one tracking session per match, however far into the draft we joined
|
||||
key = f"{match_id}:draft"
|
||||
if key in self.handled_matches:
|
||||
return
|
||||
threading.Thread(target=self.track, args=(match_id, state, key), daemon=True).start()
|
||||
|
||||
def _dump_gsi(self, match_id: str, payload: dict) -> None:
|
||||
"""Persist the full POST body; announce the path once per match folder."""
|
||||
try:
|
||||
with self.dump_lock:
|
||||
path = append_gsi_payload(match_id, payload)
|
||||
if match_id not in self._dump_announced:
|
||||
self._dump_announced.add(match_id)
|
||||
print(f"[gsi] dumping payloads -> {path}", flush=True)
|
||||
except OSError as e:
|
||||
print(f"[gsi] dump failed: {e}", flush=True)
|
||||
|
||||
def track(self, match_id: str, state: str, key: str) -> dict | None:
|
||||
"""Follow the draft from here to the end, recording every reveal."""
|
||||
if not self.busy.acquire(blocking=False):
|
||||
return None
|
||||
try:
|
||||
if key in self.handled_matches:
|
||||
return None
|
||||
self.handled_matches.add(key)
|
||||
if not self.calibrated:
|
||||
return self._capture_only(match_id, state)
|
||||
|
||||
late = " (joined late)" if state != HERO_SELECTION else ""
|
||||
print(f"[draft] tracking match {match_id} from {state}{late}", flush=True)
|
||||
session = DraftSession(self.cfg, self.library, overlay=self.overlay)
|
||||
summary = session.run(match_id, lambda: self.last_state, lambda: self.self_info)
|
||||
if not summary or summary["recognized"] == 0:
|
||||
print("[draft] nothing recognized - no result written", flush=True)
|
||||
return None
|
||||
self.report_session(summary)
|
||||
return summary
|
||||
finally:
|
||||
self.busy.release()
|
||||
|
||||
def _capture_only(self, match_id: str, state: str) -> None:
|
||||
"""No calibration yet: just bank a few frames to calibrate from later."""
|
||||
out = raw_dir_for_match(match_id)
|
||||
require_fg = bool(self.cfg.get("gsi", {}).get("require_foreground", True))
|
||||
print(f"[run] capture-only -> {out} (state={state})", flush=True)
|
||||
saved = 0
|
||||
skipped_fg = False
|
||||
with mss.MSS() as sct:
|
||||
# Retry until 3 usable frames or a short deadline so Alt-Tab does not
|
||||
# bank desktop screenshots for calibration.
|
||||
deadline = time.monotonic() + max(self.interval * 12, 15.0)
|
||||
while saved < 3 and time.monotonic() < deadline:
|
||||
if require_fg and not is_dota_foreground():
|
||||
if not skipped_fg:
|
||||
print("[run] Dota 2 not foreground - waiting to capture", flush=True)
|
||||
skipped_fg = True
|
||||
time.sleep(self.interval)
|
||||
continue
|
||||
if skipped_fg:
|
||||
print("[run] Dota 2 foreground again - capturing", flush=True)
|
||||
skipped_fg = False
|
||||
saved += 1
|
||||
path = save_frame(grab_frame(sct), out, prefix="draft")
|
||||
print(f"[run] capture-only {saved}/3: {path}", flush=True)
|
||||
time.sleep(self.interval)
|
||||
if saved == 0:
|
||||
print("[run] capture-only got no frames (Dota never foreground)", flush=True)
|
||||
else:
|
||||
print("[run] no calibrated slots - run calibrate.py on one of these frames", flush=True)
|
||||
return None
|
||||
|
||||
def report_session(self, summary: dict) -> None:
|
||||
RESULTS_DIR.mkdir(exist_ok=True)
|
||||
out_path = RESULTS_DIR / f"draft_{time.strftime('%Y%m%d_%H%M%S')}.json"
|
||||
out_path.write_text(json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print("", flush=True)
|
||||
for line in describe(summary):
|
||||
print(line, flush=True)
|
||||
print(f"saved : {out_path}", flush=True)
|
||||
|
||||
def run_once(self) -> dict | None:
|
||||
"""One snapshot of whatever is on screen right now, for manual checks."""
|
||||
if not is_dota_foreground():
|
||||
print("[run] warning: Dota 2 is not the foreground window", flush=True)
|
||||
frame = grab_frame()
|
||||
out = raw_dir_for_match(self.last_match_id)
|
||||
if not self.calibrated:
|
||||
print(f"[run] capture-only: {save_frame(frame, out, prefix='draft')}", flush=True)
|
||||
return None
|
||||
|
||||
result = recognize_image(frame, self.cfg, self.library)
|
||||
found = detect_roles(frame, self.cfg)
|
||||
print(f"[run] {result['recognized']}/10 slots, {result['elapsed_ms']}ms", flush=True)
|
||||
print(f"radiant: {', '.join(loc(r['hero']) for r in result['radiant'])}", flush=True)
|
||||
print(f"dire : {', '.join(loc(r['hero']) for r in result['dire'])}", flush=True)
|
||||
slot = gsi_slot(self.self_info)
|
||||
team = self.self_info.get("team") or found.get("self_team")
|
||||
if slot:
|
||||
role = found["roles"].get(slot)
|
||||
pos = f"position {role['position']} ({role['label']})" if role else "position unknown"
|
||||
print(f"you : slot {slot} {team} - {pos}", flush=True)
|
||||
elif not slot:
|
||||
print("you : slot unknown (waiting for GSI team_slot)", flush=True)
|
||||
result["roles"] = found
|
||||
return result
|
||||
|
||||
|
||||
class SingleBindServer(HTTPServer):
|
||||
"""Fail loudly when the port is taken.
|
||||
|
||||
Windows honours SO_REUSEADDR literally, so the stdlib default would let a
|
||||
second watcher bind 3223 silently and steal half the GSI payloads.
|
||||
"""
|
||||
|
||||
allow_reuse_address = False
|
||||
|
||||
|
||||
def make_handler(watcher: Watcher):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
|
||||
def do_POST(self): # noqa: N802 - required by BaseHTTPRequestHandler
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length) if length else b"{}"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
try:
|
||||
watcher.on_payload(json.loads(body.decode("utf-8")))
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
print(f"[gsi] bad payload: {e}", flush=True)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
if watcher.verbose:
|
||||
super().log_message(fmt, *args)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Keep progress visible when stdout is a pipe or file, not just a console,
|
||||
# and force UTF-8 so the Chinese hero names survive the default Windows
|
||||
# console code page (which mangles them into mojibake).
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace", line_buffering=True)
|
||||
|
||||
args = sys.argv[1:]
|
||||
cfg = load_config()
|
||||
calibrated = bool(cfg.get("slots"))
|
||||
verbose = "--verbose" in args
|
||||
|
||||
if not calibrated:
|
||||
print("WARNING: config.json has no calibrated slots - running in capture-only mode.")
|
||||
print(" Play one draft, then: python calibrate.py samples/raw/<matchid>/<frame>.png")
|
||||
print()
|
||||
|
||||
watcher = Watcher(cfg, calibrated, verbose)
|
||||
if "--dump-gsi" in args:
|
||||
watcher.dump_payloads = True
|
||||
if "--no-dump-gsi" in args:
|
||||
watcher.dump_payloads = False
|
||||
|
||||
if "--states" in args:
|
||||
names = args[args.index("--states") + 1].split(",")
|
||||
watcher.trigger_states = {
|
||||
n if n.startswith("DOTA_GAMERULES_STATE_") else f"DOTA_GAMERULES_STATE_{n}"
|
||||
for n in (s.strip().upper() for s in names)
|
||||
if n
|
||||
}
|
||||
|
||||
if "--once" in args:
|
||||
watcher.run_once()
|
||||
return
|
||||
|
||||
port = int(args[args.index("--port") + 1]) if "--port" in args else cfg.get("gsi", {}).get("port", 3223)
|
||||
try:
|
||||
server = SingleBindServer(("127.0.0.1", port), make_handler(watcher))
|
||||
except OSError as e:
|
||||
sys.exit(
|
||||
f"cannot bind 127.0.0.1:{port} ({e}).\n"
|
||||
"Another gsi_watch.py is probably still running - stop it first:\n"
|
||||
" Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" |\n"
|
||||
" Where-Object { $_.CommandLine -like '*gsi_watch*' } |\n"
|
||||
" ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
|
||||
)
|
||||
print(f"listening on http://127.0.0.1:{port}/ (Ctrl+C to stop)")
|
||||
print(f"trigger states: {', '.join(sorted(watcher.trigger_states))}")
|
||||
print(f"gsi dump : {'on -> samples/raw/<matchid>/gsi.jsonl' if watcher.dump_payloads else 'off'}")
|
||||
if calibrated:
|
||||
print(f"template library: {len(watcher.library)} entries")
|
||||
print("waiting for Dota 2 ... (needs -gamestateintegration launch option)")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped.")
|
||||
finally:
|
||||
if watcher.overlay is not None:
|
||||
watcher.overlay.close()
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Read the game-mode label under the top-center timer (e.g. 全英雄选择)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT
|
||||
|
||||
TEMPLATES = ROOT / "templates" / "modes"
|
||||
|
||||
# key -> Chinese label as drawn under the timer during hero selection
|
||||
MODES = {
|
||||
"all_pick": "全英雄选择",
|
||||
"captains_mode": "队长模式",
|
||||
"random_draft": "随机征召",
|
||||
"single_draft": "单一征召",
|
||||
"ability_draft": "技能征召",
|
||||
}
|
||||
|
||||
|
||||
def mode_roi(img: np.ndarray, cfg: dict) -> np.ndarray:
|
||||
"""Crop the strip under the draft timer where the mode name sits."""
|
||||
m = cfg.get("mode_label", {})
|
||||
ih, iw = img.shape[:2]
|
||||
y0 = int(ih * m.get("y0_rel", 0.045))
|
||||
y1 = int(ih * m.get("y1_rel", 0.072))
|
||||
x0 = int(iw * m.get("x0_rel", 0.40))
|
||||
x1 = int(iw * m.get("x1_rel", 0.60))
|
||||
return img[y0:y1, x0:x1]
|
||||
|
||||
|
||||
def _ink(roi: np.ndarray) -> np.ndarray:
|
||||
"""Binary mask of the bright mode glyphs on the dark header."""
|
||||
if roi.size == 0:
|
||||
return np.zeros((1, 1), np.uint8)
|
||||
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
|
||||
return (gray > 160).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def _tight(mask: np.ndarray, height: int = 28) -> np.ndarray | None:
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) < 8:
|
||||
return None
|
||||
crop = mask[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
||||
h, w = crop.shape
|
||||
nh = height
|
||||
nw = max(8, int(round(w * (nh / h))))
|
||||
return cv2.resize(crop, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def load_mode_templates() -> dict[str, np.ndarray]:
|
||||
out = {}
|
||||
if not TEMPLATES.is_dir():
|
||||
return out
|
||||
for p in TEMPLATES.glob("*.png"):
|
||||
img = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
|
||||
if img is not None:
|
||||
out[p.stem] = img
|
||||
return out
|
||||
|
||||
|
||||
def detect_mode(img: np.ndarray, cfg: dict, templates: dict | None = None) -> dict | None:
|
||||
"""Return {key, label, score} or None."""
|
||||
templates = templates if templates is not None else load_mode_templates()
|
||||
if not templates:
|
||||
return None
|
||||
ink = _tight(_ink(mode_roi(img, cfg)))
|
||||
if ink is None:
|
||||
return None
|
||||
best_key, best = None, -1.0
|
||||
for key, tmpl in templates.items():
|
||||
h = min(ink.shape[0], tmpl.shape[0])
|
||||
a = cv2.resize(ink, (max(8, int(ink.shape[1] * h / ink.shape[0])), h))
|
||||
b = cv2.resize(tmpl, (max(8, int(tmpl.shape[1] * h / tmpl.shape[0])), h))
|
||||
big, small = (a, b) if a.shape[1] >= b.shape[1] else (b, a)
|
||||
if big.shape[0] < small.shape[0] or big.shape[1] < small.shape[1]:
|
||||
continue
|
||||
score = float(cv2.matchTemplate(big, small, cv2.TM_CCOEFF_NORMED).max())
|
||||
if score > best:
|
||||
best, best_key = score, key
|
||||
min_score = cfg.get("mode_label", {}).get("min_score", 0.55)
|
||||
if best_key is None or best < min_score:
|
||||
return None
|
||||
return {"key": best_key, "label": MODES.get(best_key, best_key), "score": round(best, 3)}
|
||||
|
||||
|
||||
def build_template(img: np.ndarray, cfg: dict, key: str) -> Path:
|
||||
"""Save a mode template from a live selection frame."""
|
||||
TEMPLATES.mkdir(parents=True, exist_ok=True)
|
||||
ink = _tight(_ink(mode_roi(img, cfg)))
|
||||
if ink is None:
|
||||
raise SystemExit("no mode glyphs found in ROI - check mode_label coords")
|
||||
out = TEMPLATES / f"{key}.png"
|
||||
cv2.imwrite(str(out), ink)
|
||||
return out
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
import sys
|
||||
|
||||
from common import load_config
|
||||
|
||||
cfg = load_config()
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit("usage: python modes.py <frame.png> [--build KEY]")
|
||||
img = cv2.imread(sys.argv[1])
|
||||
if img is None:
|
||||
raise SystemExit(f"cannot read {sys.argv[1]}")
|
||||
if "--build" in sys.argv:
|
||||
key = sys.argv[sys.argv.index("--build") + 1]
|
||||
print(build_template(img, cfg, key))
|
||||
return
|
||||
found = detect_mode(img, cfg)
|
||||
print(found or "no mode matched")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar.
|
||||
|
||||
Runs a Tk root on a background thread. DraftSession calls set_roster(),
|
||||
set_grid_marks(), and set_analysis(); geometry uses relative coords.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
import threading
|
||||
import tkinter as tk
|
||||
|
||||
import mss
|
||||
|
||||
from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px
|
||||
|
||||
ROLE_ICONS_DIR = ROOT / "assets" / "role_icons"
|
||||
|
||||
ROLE_ORDER = [
|
||||
"Carry",
|
||||
"Support",
|
||||
"Nuker",
|
||||
"Disabler",
|
||||
"Durable",
|
||||
"Escape",
|
||||
"Initiator",
|
||||
"Pusher",
|
||||
]
|
||||
|
||||
CHROMA = "#ff00ff"
|
||||
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
||||
DEFAULT_SYNERGY_COLOR = "#e9a825"
|
||||
DEFAULT_FILL_COLOR = "#9b7ebd"
|
||||
DEFAULT_MARK_TEXT = "#0b1220"
|
||||
DEFAULT_ANALYSIS_BG = "#1a2332"
|
||||
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
||||
LABEL_ORDER = ("克", "搭", "补")
|
||||
LABEL_COLORS = {
|
||||
"克": "counter",
|
||||
"搭": "synergy",
|
||||
"补": "fill",
|
||||
}
|
||||
|
||||
|
||||
def _load_roles_by_key() -> dict[str, list[str]]:
|
||||
if not HEROES_PATH.exists():
|
||||
return {}
|
||||
table = json.loads(HEROES_PATH.read_text(encoding="utf-8"))
|
||||
return {h["key"]: list(h.get("roles") or []) for h in table}
|
||||
|
||||
|
||||
def _primary_monitor_size() -> tuple[int, int]:
|
||||
with mss.MSS() as sct:
|
||||
mon = sct.monitors[1]
|
||||
return int(mon["width"]), int(mon["height"])
|
||||
|
||||
|
||||
def _enable_click_through(hwnd: int) -> None:
|
||||
"""Make the window ignore mouse input (Windows)."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import ctypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
GWL_EXSTYLE = -20
|
||||
WS_EX_LAYERED = 0x00080000
|
||||
WS_EX_TRANSPARENT = 0x00000020
|
||||
WS_EX_TOOLWINDOW = 0x00000080
|
||||
get_long = user32.GetWindowLongW
|
||||
set_long = user32.SetWindowLongW
|
||||
style = get_long(hwnd, GWL_EXSTYLE)
|
||||
set_long(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW)
|
||||
|
||||
|
||||
class DraftOverlay:
|
||||
"""Fullscreen transparent overlay drawn above Dota during draft tracking."""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.cfg = cfg
|
||||
o = cfg.get("overlay") or {}
|
||||
self.y_gap_rel = float(o.get("y_gap_rel", 0.008))
|
||||
self.icon_h_rel = float(o.get("icon_h_rel", 0.016))
|
||||
self.icon_gap_rel = float(o.get("icon_gap_rel", 0.002))
|
||||
self.mark_size_rel = float(o.get("mark_size_rel", 0.018))
|
||||
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
||||
self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
|
||||
self.counter_color = str(o.get("counter_color", o.get("rec_color", DEFAULT_COUNTER_COLOR)))
|
||||
self.synergy_color = str(o.get("synergy_color", DEFAULT_SYNERGY_COLOR))
|
||||
self.fill_color = str(o.get("fill_color", DEFAULT_FILL_COLOR))
|
||||
self.mark_text = str(o.get("mark_text_color", o.get("rec_text_color", DEFAULT_MARK_TEXT)))
|
||||
self.analysis_y_rel = float(o.get("analysis_y_rel", 0.12))
|
||||
self.analysis_h_rel = float(o.get("analysis_h_rel", 0.028))
|
||||
self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014))
|
||||
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
||||
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
||||
self.roles_by_key = _load_roles_by_key()
|
||||
self._roster: dict[int, str] = {}
|
||||
self._cells: dict[str, dict] = {}
|
||||
self._marks: dict[str, list[str]] = {}
|
||||
self._analysis = ""
|
||||
self._ready = threading.Event()
|
||||
self._closed = False
|
||||
self._root: tk.Tk | None = None
|
||||
self._canvas: tk.Canvas | None = None
|
||||
self._photos: list[tk.PhotoImage] = []
|
||||
self._icon_src: dict[str, tk.PhotoImage] = {}
|
||||
self._thread = threading.Thread(target=self._run, name="draft-overlay", daemon=True)
|
||||
self._thread.start()
|
||||
self._ready.wait(timeout=5.0)
|
||||
|
||||
def _label_fill(self, label: str) -> str:
|
||||
kind = LABEL_COLORS.get(label)
|
||||
if kind == "synergy":
|
||||
return self.synergy_color
|
||||
if kind == "fill":
|
||||
return self.fill_color
|
||||
return self.counter_color
|
||||
|
||||
def _run(self) -> None:
|
||||
sw, sh = _primary_monitor_size()
|
||||
root = tk.Tk()
|
||||
self._root = root
|
||||
root.overrideredirect(True)
|
||||
root.attributes("-topmost", True)
|
||||
root.geometry(f"{sw}x{sh}+0+0")
|
||||
root.configure(bg=CHROMA)
|
||||
try:
|
||||
root.attributes("-transparentcolor", CHROMA)
|
||||
except tk.TclError:
|
||||
pass
|
||||
canvas = tk.Canvas(root, width=sw, height=sh, bg=CHROMA, highlightthickness=0, bd=0)
|
||||
canvas.pack(fill="both", expand=True)
|
||||
self._canvas = canvas
|
||||
self._screen = (sw, sh)
|
||||
self._load_icon_sources()
|
||||
root.update_idletasks()
|
||||
try:
|
||||
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) or root.winfo_id()
|
||||
_enable_click_through(int(hwnd))
|
||||
except Exception:
|
||||
pass
|
||||
root.withdraw()
|
||||
self._ready.set()
|
||||
root.mainloop()
|
||||
try:
|
||||
root.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self._closed = True
|
||||
|
||||
def _load_icon_sources(self) -> None:
|
||||
assert self._root is not None
|
||||
for name in ROLE_ORDER:
|
||||
path = ROLE_ICONS_DIR / f"{name}.png"
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
self._icon_src[name] = tk.PhotoImage(master=self._root, file=str(path))
|
||||
except tk.TclError:
|
||||
continue
|
||||
|
||||
def _scaled_icon(self, name: str, target_h: int) -> tk.PhotoImage | None:
|
||||
src = self._icon_src.get(name)
|
||||
if src is None or target_h <= 0:
|
||||
return None
|
||||
h = max(src.height(), 1)
|
||||
if target_h >= h:
|
||||
factor = max(1, round(target_h / h))
|
||||
img = src.zoom(factor, factor)
|
||||
else:
|
||||
factor = max(1, round(h / target_h))
|
||||
img = src.subsample(factor, factor)
|
||||
self._photos.append(img)
|
||||
return img
|
||||
|
||||
def set_roster(self, confirmed: dict[int, str]) -> None:
|
||||
"""Update tags for confirmed slots (hero_key by slot index)."""
|
||||
roster = {int(k): v for k, v in confirmed.items() if v}
|
||||
if roster == self._roster:
|
||||
return
|
||||
self._roster = dict(roster)
|
||||
self._schedule_redraw()
|
||||
|
||||
def set_analysis(self, text: str | None) -> None:
|
||||
"""Short lineup analysis banner (empty clears)."""
|
||||
value = (text or "").strip()
|
||||
if value == self._analysis:
|
||||
return
|
||||
self._analysis = value
|
||||
self._schedule_redraw()
|
||||
|
||||
def set_grid_marks(
|
||||
self,
|
||||
cells: dict[str, dict] | None,
|
||||
marks: dict[str, list[str] | int] | list[dict] | None,
|
||||
) -> None:
|
||||
"""克/搭/补 badges on hero-grid cells.
|
||||
|
||||
marks: key->labels list, key->legacy rank int, or suggest_marks list.
|
||||
"""
|
||||
cells = {str(k): dict(v) for k, v in (cells or {}).items()}
|
||||
parsed: dict[str, list[str]] = {}
|
||||
if isinstance(marks, list):
|
||||
for item in marks:
|
||||
key = item.get("key")
|
||||
if not key:
|
||||
continue
|
||||
labels = item.get("labels")
|
||||
if labels:
|
||||
parsed[str(key)] = [str(x) for x in labels if x in LABEL_ORDER]
|
||||
elif item.get("rank"):
|
||||
parsed[str(key)] = ["克"]
|
||||
elif marks:
|
||||
for key, val in marks.items():
|
||||
if isinstance(val, (list, tuple)):
|
||||
labs = [str(x) for x in val if x in LABEL_ORDER]
|
||||
elif isinstance(val, int) and val > 0:
|
||||
labs = ["克"]
|
||||
elif isinstance(val, str) and val in LABEL_ORDER:
|
||||
labs = [val]
|
||||
else:
|
||||
labs = []
|
||||
if labs:
|
||||
parsed[str(key)] = labs
|
||||
if cells == self._cells and parsed == self._marks:
|
||||
return
|
||||
self._cells = cells
|
||||
self._marks = parsed
|
||||
self._schedule_redraw()
|
||||
|
||||
def _schedule_redraw(self) -> None:
|
||||
root = self._root
|
||||
if root is None or self._closed:
|
||||
return
|
||||
try:
|
||||
root.after(0, self._redraw)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def show(self) -> None:
|
||||
root = self._root
|
||||
if root is None or self._closed:
|
||||
return
|
||||
try:
|
||||
root.after(0, root.deiconify)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def hide(self) -> None:
|
||||
root = self._root
|
||||
if root is None or self._closed:
|
||||
return
|
||||
|
||||
def _hide() -> None:
|
||||
if self._canvas is not None:
|
||||
self._canvas.delete("all")
|
||||
self._photos.clear()
|
||||
self._analysis = ""
|
||||
root.withdraw()
|
||||
|
||||
try:
|
||||
root.after(0, _hide)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
root = self._root
|
||||
if root is None or self._closed:
|
||||
return
|
||||
done = threading.Event()
|
||||
|
||||
def _shutdown() -> None:
|
||||
try:
|
||||
if self._canvas is not None:
|
||||
self._canvas.delete("all")
|
||||
self._photos.clear()
|
||||
self._icon_src.clear()
|
||||
root.quit()
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
try:
|
||||
root.after(0, _shutdown)
|
||||
except RuntimeError:
|
||||
done.set()
|
||||
done.wait(timeout=2.0)
|
||||
self._thread.join(timeout=2.0)
|
||||
self._closed = True
|
||||
|
||||
def _redraw(self) -> None:
|
||||
canvas = self._canvas
|
||||
if canvas is None:
|
||||
return
|
||||
canvas.delete("all")
|
||||
self._photos.clear()
|
||||
sw, sh = self._screen
|
||||
icon_h = max(8, int(round(self.icon_h_rel * sh)))
|
||||
gap = max(0, int(round(self.icon_gap_rel * sh)))
|
||||
y_gap = int(round(self.y_gap_rel * sh))
|
||||
|
||||
if self._analysis:
|
||||
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
||||
bar_y = int(round(self.analysis_y_rel * sh))
|
||||
font_size = max(10, int(round(self.analysis_font_rel * sh)))
|
||||
pad_x = max(12, int(round(0.01 * sw)))
|
||||
# Estimate text width roughly; keep banner centered and readable.
|
||||
approx_w = min(sw - 2 * pad_x, max(200, int(len(self._analysis) * font_size * 0.95) + 2 * pad_x))
|
||||
x0 = (sw - approx_w) // 2
|
||||
y0 = bar_y
|
||||
x1 = x0 + approx_w
|
||||
y1 = y0 + bar_h
|
||||
canvas.create_rectangle(x0, y0, x1, y1, fill=self.analysis_bg, outline=self.analysis_bg)
|
||||
canvas.create_text(
|
||||
(x0 + x1) / 2,
|
||||
(y0 + y1) / 2,
|
||||
text=self._analysis,
|
||||
fill=self.analysis_fg,
|
||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||
)
|
||||
|
||||
for slot in self.cfg.get("slots") or []:
|
||||
idx = int(slot["index"])
|
||||
hero = self._roster.get(idx)
|
||||
if not hero:
|
||||
continue
|
||||
roles = [r for r in ROLE_ORDER if r in set(self.roles_by_key.get(hero, []))]
|
||||
if not roles:
|
||||
continue
|
||||
x, y, w, h = slot_rect_px(slot, self.cfg, sw, sh)
|
||||
icons = [img for r in roles if (img := self._scaled_icon(r, icon_h)) is not None]
|
||||
if not icons:
|
||||
continue
|
||||
total_w = sum(img.width() for img in icons) + gap * (len(icons) - 1)
|
||||
cx = x + w / 2
|
||||
left = int(round(cx - total_w / 2))
|
||||
top = y + h + y_gap
|
||||
cursor = left
|
||||
for img in icons:
|
||||
canvas.create_image(cursor, top, image=img, anchor="nw")
|
||||
cursor += img.width() + gap
|
||||
|
||||
mark = max(12, int(round(self.mark_size_rel * sh)))
|
||||
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
||||
mark_gap = max(1, int(round(self.mark_gap_rel * sh)))
|
||||
font_size = max(8, int(round(mark * 0.55)))
|
||||
for key, labels in self._marks.items():
|
||||
cell = self._cells.get(key)
|
||||
if not cell:
|
||||
continue
|
||||
ordered = [lab for lab in LABEL_ORDER if lab in labels]
|
||||
if not ordered:
|
||||
continue
|
||||
x0 = int(cell["x0"])
|
||||
y0 = int(cell["y0"])
|
||||
x1 = x0 + pad
|
||||
y1 = y0 + pad
|
||||
for i, lab in enumerate(ordered):
|
||||
bx1 = x1 + i * (mark + mark_gap)
|
||||
by1 = y1
|
||||
bx2 = bx1 + mark
|
||||
by2 = by1 + mark
|
||||
fill = self._label_fill(lab)
|
||||
canvas.create_rectangle(bx1, by1, bx2, by2, fill=fill, outline=fill)
|
||||
canvas.create_text(
|
||||
(bx1 + bx2) / 2,
|
||||
(by1 + by2) / 2,
|
||||
text=lab,
|
||||
fill=self.mark_text,
|
||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Recognize the 10 drafted heroes from a strategy-time screenshot.
|
||||
|
||||
Usage:
|
||||
python recognize.py samples/raw/draft.png
|
||||
python recognize.py samples/raw/draft.png --truth tinker,earthshaker,...,drow_ranger
|
||||
python recognize.py samples/raw/draft.png --sheet
|
||||
|
||||
Outputs per-slot JSON with top-1 hero, score and margin; slots failing the
|
||||
confidence gate are reported as null. With --truth, prints accuracy and saves
|
||||
misrecognized crops to failures/ (debug only, gitignored).
|
||||
|
||||
recognize_image() is the reusable entry point used by gsi_watch.py.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import (
|
||||
ROOT,
|
||||
crop_slot,
|
||||
has_ranked_overlay,
|
||||
load_config,
|
||||
load_template_library,
|
||||
match_score,
|
||||
ranked_match_mask,
|
||||
)
|
||||
|
||||
FAILURES_DIR = ROOT / "failures"
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
|
||||
|
||||
def write_sheet(img: np.ndarray, results: list[dict], cfg: dict) -> str:
|
||||
"""Contact sheet of every slot with its predicted hero, for eyeballing."""
|
||||
scale = 2
|
||||
tiles = []
|
||||
for r in results:
|
||||
crop = crop_slot(img, cfg["slots"][r["slot"] - 1], cfg)
|
||||
if crop is None:
|
||||
continue
|
||||
tile = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_LANCZOS4)
|
||||
label = np.zeros((54, tile.shape[1], 3), np.uint8)
|
||||
name = r["hero"] or f"?{r['raw_best']}"
|
||||
colour = (120, 255, 120) if r["hero"] else (120, 200, 255)
|
||||
cv2.putText(label, f"{r['slot']} {name[:16]}", (4, 20),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.42, colour, 1, cv2.LINE_AA)
|
||||
cv2.putText(label, f"s{r['score']:.2f} m{r['margin']:.2f}", (4, 42),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.42, (170, 170, 170), 1, cv2.LINE_AA)
|
||||
stack = np.vstack([tile, label])
|
||||
tiles.append(cv2.copyMakeBorder(stack, 2, 2, 2, 2, cv2.BORDER_CONSTANT, value=(60, 60, 60)))
|
||||
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
out = PREVIEW_DIR / "recognize_sheet.png"
|
||||
cv2.imwrite(str(out), np.hstack(tiles))
|
||||
return str(out)
|
||||
|
||||
|
||||
def recognize_slot(crop, library, cfg, mask=None):
|
||||
"""Return (best_hero, best_score, margin, scored list).
|
||||
|
||||
cfg is accepted for call-site compatibility; score gates are applied by the caller.
|
||||
"""
|
||||
_ = cfg
|
||||
best_per_hero: dict[str, float] = {}
|
||||
for hero, tmpl in library:
|
||||
s = match_score(crop, tmpl, mask)
|
||||
if s > best_per_hero.get(hero, -2.0):
|
||||
best_per_hero[hero] = s
|
||||
ranked = sorted(best_per_hero.items(), key=lambda kv: kv[1], reverse=True)
|
||||
if not ranked:
|
||||
return None, 0.0, 0.0, []
|
||||
top1 = ranked[0]
|
||||
margin = top1[1] - ranked[1][1] if len(ranked) > 1 else 1.0
|
||||
return top1[0], top1[1], margin, ranked[:3]
|
||||
|
||||
|
||||
def recognize_image(img: np.ndarray, cfg: dict | None = None, library=None) -> dict:
|
||||
"""Recognize all slots in a full-screen frame.
|
||||
|
||||
cfg and library are accepted so a long-running caller can load the
|
||||
template library once instead of on every frame.
|
||||
|
||||
Ranked matchmaking draws a title bar + medal over every portrait; when
|
||||
that overlay is detected we match only the unoccluded face region.
|
||||
"""
|
||||
cfg = cfg if cfg is not None else load_config()
|
||||
library = library if library is not None else load_template_library()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
min_score = cfg["match"]["min_score"]
|
||||
min_margin = cfg["match"]["min_margin"]
|
||||
ranked_ui = has_ranked_overlay(img, cfg)
|
||||
mask = ranked_match_mask(cfg["canonical_size"], cfg) if ranked_ui else None
|
||||
|
||||
results = []
|
||||
for slot in cfg["slots"]:
|
||||
crop = crop_slot(img, slot, cfg)
|
||||
if crop is None:
|
||||
results.append({"slot": slot["index"], "hero": None, "score": 0, "margin": 0, "top3": []})
|
||||
continue
|
||||
hero, score, margin, top3 = recognize_slot(crop, library, cfg, mask)
|
||||
passed = score >= min_score and margin >= min_margin
|
||||
results.append(
|
||||
{
|
||||
"slot": slot["index"],
|
||||
"hero": hero if passed else None,
|
||||
"raw_best": hero,
|
||||
"score": round(score, 3),
|
||||
"margin": round(margin, 3),
|
||||
"top3": [[h, round(s, 3)] for h, s in top3],
|
||||
}
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
return {
|
||||
"radiant": results[:5],
|
||||
"dire": results[5:],
|
||||
"slots": results,
|
||||
"recognized": sum(1 for r in results if r["hero"]),
|
||||
"ranked_overlay": ranked_ui,
|
||||
"library_size": len(library),
|
||||
"elapsed_ms": round(elapsed * 1000),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
image_path = sys.argv[1]
|
||||
truth = None
|
||||
if "--truth" in sys.argv:
|
||||
truth = sys.argv[sys.argv.index("--truth") + 1].split(",")
|
||||
if len(truth) != 10:
|
||||
sys.exit(f"--truth expects 10 comma-separated keys, got {len(truth)}")
|
||||
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run calibrate.py first")
|
||||
library = load_template_library()
|
||||
if not library:
|
||||
sys.exit("template library is empty - run fetch_cdn_templates.py")
|
||||
|
||||
out = recognize_image(img, cfg, library)
|
||||
results = out.pop("slots")
|
||||
print(json.dumps(out, ensure_ascii=False, indent=1))
|
||||
|
||||
if "--sheet" in sys.argv:
|
||||
print(f"sheet: {write_sheet(img, results, cfg)}")
|
||||
|
||||
if truth:
|
||||
FAILURES_DIR.mkdir(exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
correct = 0
|
||||
for r, expected in zip(results, truth):
|
||||
expected = expected.strip()
|
||||
got = r["hero"]
|
||||
ok = got == expected
|
||||
correct += ok
|
||||
mark = "OK " if ok else "ERR"
|
||||
print(f"{mark} slot {r['slot']}: expected={expected} got={got} (raw={r.get('raw_best')} score={r['score']} margin={r['margin']})")
|
||||
if not ok:
|
||||
slot_cfg = cfg["slots"][r["slot"] - 1]
|
||||
crop = crop_slot(img, slot_cfg, cfg)
|
||||
if crop is not None:
|
||||
cv2.imwrite(str(FAILURES_DIR / f"{stamp}_s{r['slot']}_{expected}.png"), crop)
|
||||
print(f"accuracy: {correct}/10, misses saved to failures/ (filename contains the correct key)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Draft suggestions from qualitative hero relations + lineup archetypes.
|
||||
|
||||
Mark available heroes with 克 / 搭 / 补:
|
||||
克 — relation counters, push/global answers, punish enemy gaps
|
||||
搭 — synergy with locked allies
|
||||
补 — fill ally tag gaps
|
||||
|
||||
Role-queue filters by position tags; otherwise all heroes are candidates.
|
||||
Also returns a short analysis string and per-mark reasons (no AI).
|
||||
|
||||
Data: shared/data/relations.json + draft_archetypes rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from draft_archetypes import (
|
||||
answer_for_candidate,
|
||||
collect_reasons,
|
||||
detect_archetypes,
|
||||
detect_gaps,
|
||||
format_analysis,
|
||||
tag_profile,
|
||||
)
|
||||
from shared.grid import hero_table
|
||||
from shared.hero_tags import tags_for_hero
|
||||
from shared.relations import DEFAULT_RELATIONS, indexes, load_relations
|
||||
|
||||
DEFAULT_ROLE_TAGS = {
|
||||
"1": ["Carry"],
|
||||
"2": ["Carry", "Nuker", "Escape"],
|
||||
"3": ["Initiator", "Durable", "Carry"],
|
||||
"4": ["Support"],
|
||||
"5": ["Support"],
|
||||
}
|
||||
|
||||
# Soft boosts when candidate tags address a prominent enemy profile face.
|
||||
# Never creates marks alone.
|
||||
_PROFILE_BOOSTS: dict[str, tuple[str, ...]] = {
|
||||
"爆发": ("耐久", "逃生"),
|
||||
"推进": ("控制", "先手"),
|
||||
"先手": ("逃生", "控制"),
|
||||
"控制": ("逃生", "耐久"),
|
||||
"核心": ("控制", "先手"),
|
||||
"辅助": ("核心", "先手"),
|
||||
}
|
||||
|
||||
_SOFT_BOOST = 0.25
|
||||
_ARCH_BOOST = 0.5
|
||||
_GAP_BOOST = 0.35
|
||||
|
||||
|
||||
def _maps() -> tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]]:
|
||||
table = hero_table()
|
||||
names = {h["key"]: h["name_loc"] for h in table}
|
||||
roles = {h["key"]: list(h.get("roles") or []) for h in table}
|
||||
tags = {
|
||||
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
|
||||
for h in table
|
||||
}
|
||||
return names, roles, tags
|
||||
|
||||
|
||||
def enemy_keys(confirmed: dict[int, str], self_team: str | None) -> list[str]:
|
||||
if self_team == "radiant":
|
||||
slots = range(6, 11)
|
||||
elif self_team == "dire":
|
||||
slots = range(1, 6)
|
||||
else:
|
||||
return []
|
||||
return [confirmed[s] for s in slots if confirmed.get(s)]
|
||||
|
||||
|
||||
def ally_keys(confirmed: dict[int, str], self_team: str | None, self_slot: int | None = None) -> list[str]:
|
||||
"""Teammates already locked (excludes your own slot)."""
|
||||
if self_team == "radiant":
|
||||
slots = range(1, 6)
|
||||
elif self_team == "dire":
|
||||
slots = range(6, 11)
|
||||
else:
|
||||
return []
|
||||
out = []
|
||||
for s in slots:
|
||||
if self_slot is not None and s == self_slot:
|
||||
continue
|
||||
if confirmed.get(s):
|
||||
out.append(confirmed[s])
|
||||
return out
|
||||
|
||||
|
||||
def candidates_for_position(
|
||||
position: int | None,
|
||||
*,
|
||||
roles_by_key: dict[str, list[str]],
|
||||
role_tags: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""Role-queue filter. position=None means all heroes (non-role queue)."""
|
||||
if position is None:
|
||||
return list(roles_by_key.keys())
|
||||
tags_map = role_tags or DEFAULT_ROLE_TAGS
|
||||
wanted = set(tags_map.get(str(position)) or tags_map.get(position) or [])
|
||||
if not wanted:
|
||||
return []
|
||||
out = []
|
||||
for key, tags in roles_by_key.items():
|
||||
if wanted.intersection(tags):
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def enemy_profile(enemies: list[str], tags_by_key: dict[str, list[str]] | None = None) -> dict[str, int]:
|
||||
"""Count Chinese draft tags across locked enemies."""
|
||||
if tags_by_key is None:
|
||||
_, _, tags_by_key = _maps()
|
||||
return tag_profile(enemies, tags_by_key)
|
||||
|
||||
|
||||
def _profile_soft_boost(cand_tags: list[str], profile: dict[str, int]) -> float:
|
||||
if not profile or not cand_tags:
|
||||
return 0.0
|
||||
cand = set(cand_tags)
|
||||
boost = 0.0
|
||||
for face, n in sorted(profile.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||
if n <= 0:
|
||||
continue
|
||||
wanted = _PROFILE_BOOSTS.get(face)
|
||||
if not wanted:
|
||||
continue
|
||||
if cand.intersection(wanted):
|
||||
boost += _SOFT_BOOST * n
|
||||
return boost
|
||||
|
||||
|
||||
def _empty_result() -> dict:
|
||||
return {
|
||||
"enemy_profile": {},
|
||||
"ally_profile": {},
|
||||
"enemy_archetypes": [],
|
||||
"enemy_gaps": [],
|
||||
"ally_gaps": [],
|
||||
"analysis": "",
|
||||
"marks": [],
|
||||
}
|
||||
|
||||
|
||||
def suggest_marks(
|
||||
*,
|
||||
position: int | None,
|
||||
enemies: list[str],
|
||||
allies: list[str] | None = None,
|
||||
exclude: set[str] | list[str],
|
||||
relations: dict | None = None,
|
||||
top_n: int | None = 0,
|
||||
role_tags: dict | None = None,
|
||||
min_enemies: int = 1,
|
||||
min_heroes_for_gaps: int = 2,
|
||||
archetypes_enabled: bool = True,
|
||||
**_ignored,
|
||||
) -> dict:
|
||||
"""Return 克/搭/补 marks plus lineup analysis.
|
||||
|
||||
Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means
|
||||
no truncation. ``position`` None = non-role queue (all heroes).
|
||||
"""
|
||||
allies = list(allies or [])
|
||||
enemies = list(enemies or [])
|
||||
if len(enemies) < max(1, int(min_enemies)):
|
||||
return _empty_result()
|
||||
|
||||
rel = relations if relations is not None else load_relations()
|
||||
has_rel = bool(rel.get("counters") or rel.get("synergies"))
|
||||
if not has_rel and not archetypes_enabled:
|
||||
return _empty_result()
|
||||
|
||||
names, roles_by_key, tags_by_key = _maps()
|
||||
counters_of, countered_by, synergies_of = indexes(rel) if has_rel else ({}, {}, {})
|
||||
exclude_set = {e for e in exclude if e}
|
||||
|
||||
e_profile = tag_profile(enemies, tags_by_key)
|
||||
a_profile = tag_profile(allies, tags_by_key)
|
||||
|
||||
archetypes: list[str] = []
|
||||
enemy_gaps: list[str] = []
|
||||
ally_gaps: list[str] = []
|
||||
if archetypes_enabled:
|
||||
archetypes = detect_archetypes(enemies, tags_by_key)
|
||||
enemy_gaps = detect_gaps(
|
||||
e_profile, hero_count=len(enemies), min_heroes=min_heroes_for_gaps
|
||||
)
|
||||
ally_gaps = detect_gaps(
|
||||
a_profile, hero_count=len(allies), min_heroes=min_heroes_for_gaps
|
||||
)
|
||||
|
||||
analysis = format_analysis(
|
||||
archetypes=archetypes,
|
||||
enemy_gaps=enemy_gaps,
|
||||
ally_gaps=ally_gaps,
|
||||
ally_count=len(allies),
|
||||
)
|
||||
|
||||
scored: list[tuple[float, str, dict]] = []
|
||||
for cand in candidates_for_position(position, roles_by_key=roles_by_key, role_tags=role_tags):
|
||||
if cand in exclude_set:
|
||||
continue
|
||||
cand_tags = tags_by_key.get(cand) or []
|
||||
beats = []
|
||||
beaten_by = []
|
||||
with_allies = []
|
||||
for ek in enemies:
|
||||
for edge in counters_of.get(cand) or []:
|
||||
if edge["key"] == ek:
|
||||
beats.append({"enemy": ek, "reason": edge.get("reason") or ""})
|
||||
for edge in countered_by.get(cand) or []:
|
||||
if edge["key"] == ek:
|
||||
beaten_by.append({"enemy": ek, "reason": edge.get("reason") or ""})
|
||||
for ak in allies:
|
||||
for edge in synergies_of.get(cand) or []:
|
||||
if edge["key"] == ak:
|
||||
with_allies.append({"ally": ak, "reason": edge.get("reason") or ""})
|
||||
|
||||
arch_hits, punish_gaps, fill_gaps = answer_for_candidate(
|
||||
cand,
|
||||
cand_tags,
|
||||
archetypes=archetypes,
|
||||
enemy_gaps=enemy_gaps,
|
||||
ally_gaps=ally_gaps,
|
||||
)
|
||||
|
||||
labels: list[str] = []
|
||||
if beats or arch_hits or punish_gaps:
|
||||
labels.append("克")
|
||||
if with_allies:
|
||||
labels.append("搭")
|
||||
if fill_gaps:
|
||||
labels.append("补")
|
||||
if not labels:
|
||||
continue
|
||||
|
||||
reasons = collect_reasons(
|
||||
names=names,
|
||||
beats=beats,
|
||||
with_allies=with_allies,
|
||||
archetype_hits=arch_hits,
|
||||
punish_gaps=punish_gaps,
|
||||
fill_gaps=fill_gaps,
|
||||
)
|
||||
soft = _profile_soft_boost(cand_tags, e_profile)
|
||||
score = (
|
||||
float(len(beats) - len(beaten_by) + len(with_allies))
|
||||
+ soft
|
||||
+ _ARCH_BOOST * len(arch_hits)
|
||||
+ _GAP_BOOST * (len(punish_gaps) + len(fill_gaps))
|
||||
)
|
||||
scored.append((score, cand, {
|
||||
"labels": labels,
|
||||
"beats": beats,
|
||||
"beaten_by": beaten_by,
|
||||
"with": with_allies,
|
||||
"reasons": reasons,
|
||||
"score": round(score, 3),
|
||||
}))
|
||||
|
||||
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
||||
limit = None if top_n is None or int(top_n) <= 0 else int(top_n)
|
||||
sliced = scored if limit is None else scored[:limit]
|
||||
marks = []
|
||||
for rank, (_score, key, detail) in enumerate(sliced, start=1):
|
||||
marks.append({
|
||||
"key": key,
|
||||
"name_loc": names.get(key, key),
|
||||
"rank": rank,
|
||||
**detail,
|
||||
})
|
||||
return {
|
||||
"enemy_profile": e_profile,
|
||||
"ally_profile": a_profile,
|
||||
"enemy_archetypes": archetypes,
|
||||
"enemy_gaps": enemy_gaps,
|
||||
"ally_gaps": ally_gaps,
|
||||
"analysis": analysis,
|
||||
"marks": marks,
|
||||
}
|
||||
|
||||
|
||||
def suggest_top(
|
||||
*,
|
||||
position: int | None,
|
||||
enemies: list[str],
|
||||
allies: list[str] | None = None,
|
||||
exclude: set[str] | list[str],
|
||||
relations: dict | None = None,
|
||||
top_n: int | None = 0,
|
||||
role_tags: dict | None = None,
|
||||
min_enemies: int = 1,
|
||||
min_heroes_for_gaps: int = 2,
|
||||
archetypes_enabled: bool = True,
|
||||
**_ignored,
|
||||
) -> list[dict]:
|
||||
"""Compatibility wrapper: return mark list from ``suggest_marks``."""
|
||||
return suggest_marks(
|
||||
position=position,
|
||||
enemies=enemies,
|
||||
allies=allies,
|
||||
exclude=exclude,
|
||||
relations=relations,
|
||||
top_n=top_n,
|
||||
role_tags=role_tags,
|
||||
min_enemies=min_enemies,
|
||||
min_heroes_for_gaps=min_heroes_for_gaps,
|
||||
archetypes_enabled=archetypes_enabled,
|
||||
**_ignored,
|
||||
)["marks"]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_RELATIONS",
|
||||
"DEFAULT_ROLE_TAGS",
|
||||
"ally_keys",
|
||||
"candidates_for_position",
|
||||
"enemy_keys",
|
||||
"enemy_profile",
|
||||
"load_relations",
|
||||
"suggest_marks",
|
||||
"suggest_top",
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
opencv-python>=4.10
|
||||
numpy>=2.0
|
||||
mss>=9.0
|
||||
openpyxl>=3.1
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Read role-queue lane labels under top-bar portraits.
|
||||
|
||||
Row under each portrait (role-queue only, own team): 优势路 / 中路 / 劣势路 /
|
||||
辅助 / 纯辅助. Your own slot index comes from GSI team_slot, not from name tint.
|
||||
|
||||
The role text is flat grey with zero saturation, so a threshold on
|
||||
value+saturation isolates it cleanly. Matching is done on the binary mask
|
||||
(icon included) rather than OCR: there are only five possible strings and
|
||||
they differ in width, so mask IoU separates them by a wide margin.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT, slot_rect_px
|
||||
|
||||
TEMPLATES_ROLES = ROOT / "templates" / "roles"
|
||||
|
||||
# key -> (in-game text, lane position number)
|
||||
ROLES = {
|
||||
"safe": ("优势路", 1),
|
||||
"mid": ("中路", 2),
|
||||
"off": ("劣势路", 3),
|
||||
"soft_support": ("辅助", 4),
|
||||
"hard_support": ("纯辅助", 5),
|
||||
}
|
||||
|
||||
# canonical mask geometry, chosen so 1440p text (~17px tall) upsamples slightly
|
||||
STRIP_H = 24
|
||||
STRIP_W = 160
|
||||
|
||||
|
||||
def _row_rect(slot: dict, cfg: dict, img_w: int, img_h: int, row: str) -> tuple[int, int, int, int]:
|
||||
r = cfg["text_rows"][row]
|
||||
x, _, w, _ = slot_rect_px(slot, cfg, img_w, img_h)
|
||||
pad = int(round(w * 0.35)) # names/roles overflow the portrait width
|
||||
y0 = int(round(r["y0_rel"] * img_h))
|
||||
y1 = int(round(r["y1_rel"] * img_h))
|
||||
return x - pad, y0, w + 2 * pad, y1 - y0
|
||||
|
||||
|
||||
def _text_mask(patch: np.ndarray, min_value: int, max_sat: float) -> np.ndarray:
|
||||
"""Isolate the flat light-grey glyphs from the dark blurred background."""
|
||||
p = patch.astype(np.float32)
|
||||
mx = p.max(axis=2)
|
||||
mn = p.min(axis=2)
|
||||
sat = (mx - mn) / np.maximum(mx, 1.0)
|
||||
return ((mx > min_value) & (sat < max_sat)).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def _tight(mask: np.ndarray) -> np.ndarray | None:
|
||||
"""Crop to the ink, then normalize height so resolution stops mattering."""
|
||||
ys, xs = np.nonzero(mask)
|
||||
if ys.size < 40:
|
||||
return None
|
||||
m = mask[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
h, w = m.shape
|
||||
scale = STRIP_H / h
|
||||
m = cv2.resize(m, (max(1, int(round(w * scale))), STRIP_H), interpolation=cv2.INTER_AREA)
|
||||
canvas = np.zeros((STRIP_H, STRIP_W), np.uint8)
|
||||
m = m[:, :STRIP_W]
|
||||
canvas[:, : m.shape[1]] = m
|
||||
return (canvas > 127).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def role_mask(img: np.ndarray, slot: dict, cfg: dict) -> np.ndarray | None:
|
||||
"""Binary mask of one slot's role label, or None when there is no label."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = _row_rect(slot, cfg, iw, ih, "role")
|
||||
patch = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
t = cfg["text_rows"]["role"]
|
||||
return _tight(_text_mask(patch, t.get("min_value", 110), t.get("max_sat", 0.08)))
|
||||
|
||||
|
||||
def name_tint(img: np.ndarray, slot: dict, cfg: dict) -> tuple[float, float] | None:
|
||||
"""Mean value and saturation of the name glyphs: (value, saturation)."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = _row_rect(slot, cfg, iw, ih, "name")
|
||||
patch = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
p = patch.astype(np.float32)
|
||||
mx = p.max(axis=2)
|
||||
thr = max(90.0, float(mx.max()) * 0.7)
|
||||
sel = mx > thr
|
||||
if int(sel.sum()) < 30:
|
||||
return None
|
||||
px = p[sel]
|
||||
hi = px.max(axis=1)
|
||||
lo = px.min(axis=1)
|
||||
return float(hi.mean()), float(((hi - lo) / np.maximum(hi, 1.0)).mean())
|
||||
|
||||
|
||||
def iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
ab = a > 0
|
||||
bb = b > 0
|
||||
union = int((ab | bb).sum())
|
||||
return float((ab & bb).sum()) / union if union else 0.0
|
||||
|
||||
|
||||
def load_role_templates() -> dict[str, np.ndarray]:
|
||||
if not TEMPLATES_ROLES.is_dir():
|
||||
return {}
|
||||
out = {}
|
||||
for key in ROLES:
|
||||
f = TEMPLATES_ROLES / f"{key}.png"
|
||||
if f.is_file():
|
||||
img = cv2.imread(str(f), cv2.IMREAD_GRAYSCALE)
|
||||
if img is not None:
|
||||
out[key] = img
|
||||
return out
|
||||
|
||||
|
||||
def detect_roles(img: np.ndarray, cfg: dict, templates: dict[str, np.ndarray] | None = None) -> dict:
|
||||
"""Per-slot role labels from the role-queue text under top-bar portraits.
|
||||
|
||||
Returns {"self_team": str|None, "roles": {slot: {...}}}.
|
||||
Your own top-bar slot comes from GSI team_slot elsewhere - this helper
|
||||
does not guess it from name brightness.
|
||||
Slots without a role label (the enemy team, or any non-role-queue mode)
|
||||
are simply absent from "roles".
|
||||
"""
|
||||
if templates is None:
|
||||
templates = load_role_templates()
|
||||
cutoff = cfg.get("roles", {}).get("min_iou", 0.55)
|
||||
|
||||
roles: dict[int, dict] = {}
|
||||
for slot in cfg.get("slots", []):
|
||||
mask = role_mask(img, slot, cfg)
|
||||
if mask is None:
|
||||
continue
|
||||
ranked = sorted(((iou(mask, t), k) for k, t in templates.items()), reverse=True)
|
||||
if not ranked or ranked[0][0] < cutoff:
|
||||
continue
|
||||
score, key = ranked[0]
|
||||
roles[slot["index"]] = {
|
||||
"role": key,
|
||||
"label": ROLES[key][0],
|
||||
"position": ROLES[key][1],
|
||||
"score": round(score, 3),
|
||||
}
|
||||
|
||||
self_team = None
|
||||
if roles:
|
||||
self_team = "radiant" if min(roles) <= 5 else "dire"
|
||||
|
||||
return {"self_team": self_team, "roles": roles}
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
"""python roles.py <frame.png> - report roles found
|
||||
python roles.py <frame.png> --build off,safe,mid,soft_support,hard_support
|
||||
- save templates from slots 1..N
|
||||
"""
|
||||
import sys
|
||||
|
||||
from common import load_config
|
||||
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(_main.__doc__)
|
||||
return
|
||||
frame = cv2.imread(args[0])
|
||||
if frame is None:
|
||||
raise SystemExit(f"cannot read {args[0]}")
|
||||
cfg = load_config()
|
||||
|
||||
if "--build" in args:
|
||||
labels = args[args.index("--build") + 1].split(",")
|
||||
TEMPLATES_ROLES.mkdir(parents=True, exist_ok=True)
|
||||
for slot, key in zip(cfg["slots"], labels):
|
||||
key = key.strip()
|
||||
if key not in ROLES:
|
||||
raise SystemExit(f"unknown role {key!r}, expected one of {list(ROLES)}")
|
||||
mask = role_mask(frame, slot, cfg)
|
||||
if mask is None:
|
||||
raise SystemExit(f"slot {slot['index']} has no role text")
|
||||
out = TEMPLATES_ROLES / f"{key}.png"
|
||||
cv2.imwrite(str(out), mask)
|
||||
print(f"slot {slot['index']} -> {key} ({ROLES[key][0]}) {out}")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
print(json.dumps(detect_roles(frame, cfg), ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"comment": "Ground truth for captured frames: 10 hero keys left to right, '?' for unknown. Used by evaluate.py. Paths are filenames under samples/raw/ (or samples/raw/<matchid>/ when using per-match folders).",
|
||||
"frames": {}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |