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>
This commit is contained in:
voson
2026-07-29 14:29:08 +08:00
co-authored by Cursor
parent 96a9312194
commit 9c5aa5b610
280 changed files with 1451 additions and 450 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+226
View File
@@ -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()
+83
View File
@@ -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])
+145
View File
@@ -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()
+146
View File
@@ -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
+160
View File
@@ -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"
}
+265
View File
@@ -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",
]
+686
View File
@@ -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"]
+76
View File
@@ -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()
+214
View File
@@ -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()
+145
View File
@@ -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()
+333
View File
@@ -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()
+122
View File
@@ -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()
+379
View File
@@ -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"),
)
+180
View File
@@ -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()
+329
View File
@@ -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",
]
+4
View File
@@ -0,0 +1,4 @@
opencv-python>=4.10
numpy>=2.0
mss>=9.0
openpyxl>=3.1
+196
View File
@@ -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()
+4
View File
@@ -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": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More