Files

235 lines
8.4 KiB
Python

"""Read the hero-selection grid: which heroes are unavailable.
No template matching is involved, because the grid's layout is fully
determined. Heroes are split into four attribute blocks laid out left to
right (strength, agility, intelligence, universal); inside a block they are
sorted by the in-client localized name and filled row-major, and any leftover
cells sit at the tail of the block.
That was verified against a live ranked draft: the four blocks held exactly
36 / 35 / 34 / 22 cells, matching the roster's attribute counts, every empty
cell was in the bottom row at the end of its block, and all nine bans that
the in-game chat log named landed on cells drawn with the ban slash.
A card that cannot be picked - banned, or already taken - is drawn dimmed
under a diagonal slash, which flattens it. Greyscale contrast is the clean
separator: in that same draft the seventeen unavailable cards measured 8-21
while every live card measured 33 or more.
cv2/numpy are imported lazily inside the vision functions so that web-side
consumers of hero_table()/ATTR_ORDER (serve/export/fetch scripts, and CI)
do not need opencv installed.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
from shared.paths import HEROES_JSON, PC_CONFIG
ATTR_ORDER = ("str", "agi", "int", "all")
def hero_table() -> list[dict]:
table = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
if table and "attr" not in table[0]:
raise SystemExit(f"{HEROES_JSON.name} predates grid support - rerun fetch_cdn_templates.py")
return table
def _runs(flags, min_len: int) -> list[tuple[int, int]]:
out, start = [], None
for i, v in enumerate(flags):
if v and start is None:
start = i
elif not v and start is not None:
if i - start >= min_len:
out.append((start, i))
start = None
if start is not None and len(flags) - start >= min_len:
out.append((start, len(flags)))
return out
def detect_grid(img, cfg: dict | None = None) -> dict | None:
"""Locate the card lattice. Returns column and row spans, or None.
Cards are busy and the gaps between them are flat, so a per-column and
per-row standard deviation profile separates them without any thresholds
that depend on resolution.
"""
import cv2
import numpy as np
g = cfg.get("grid", {}) if cfg else {}
floor = g.get("min_std", 18.0)
ih = img.shape[0]
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
band = gray[int(ih * 0.20):int(ih * 0.60), :]
cols = _plausible(_runs(band.std(axis=0) > _ink(band.std(axis=0), floor), int(ih * 0.025)))
if len(cols) < 8:
return None
strip = gray[:, cols[0][0]:cols[-1][1]]
prof = strip.std(axis=1)
rows = [r for r in _plausible(_runs(prof > _ink(prof, floor), int(ih * 0.03))) if r[0] > ih * 0.10]
if len(rows) < 2:
return None
return {"cols": cols, "rows": rows}
def _ink(profile, floor: float) -> float:
"""Threshold that follows the frame's own contrast.
Banners and tooltips dim the whole grid for a moment; a fixed cut loses
rows and columns on those frames, which would silently truncate the layout.
"""
import numpy as np
return max(floor * 0.5, 0.35 * float(np.percentile(profile, 75)))
def _plausible(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Drop side panels and stray runs by keeping spans near the median width."""
import numpy as np
if not spans:
return []
med = float(np.median([b - a for a, b in spans]))
return [s for s in spans if 0.7 * med <= (s[1] - s[0]) <= 1.4 * med]
def block_of_column(cols: list[tuple[int, int]]) -> list[int]:
"""Tag every column with its attribute block index.
Blocks are separated by a visibly wider gutter than the gap between two
cards in the same block.
"""
import numpy as np
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
if not gaps:
return [0] * len(cols)
cut = float(np.median(gaps)) * 1.8
block, out = 0, [0]
for gap in gaps:
if gap > cut:
block += 1
out.append(block)
return out
def build_layout(grid: dict, table: list[dict]) -> dict[tuple[int, int], str] | None:
"""Map every cell to a hero from the roster alone. None if the shape is off."""
cols, rows = grid["cols"], grid["rows"]
blocks = block_of_column(cols)
if len(set(blocks)) != len(ATTR_ORDER):
return None
layout: dict[tuple[int, int], str] = {}
for bi, attr in enumerate(ATTR_ORDER):
cells = [(r, c) for r in range(len(rows)) for c in range(len(cols)) if blocks[c] == bi]
cells.sort()
heroes = sorted((h for h in table if h["attr"] == attr), key=lambda h: h["name_loc"])
if len(heroes) > len(cells):
return None
for cell, hero in zip(cells, heroes):
layout[cell] = hero["key"]
return layout
def cell_contrast(img, grid: dict, r: int, c: int) -> float:
import cv2
x0, x1 = grid["cols"][c]
y0, y1 = grid["rows"][r]
patch = img[y0:y1, x0:x1]
if patch.size == 0:
return 0.0
# trim the level badge and attribute gem the client paints over the art
h, w = patch.shape[:2]
inner = patch[int(h * 0.04):int(h * 0.86), int(w * 0.05):int(w * 0.95)]
return float(cv2.cvtColor(inner, cv2.COLOR_BGR2GRAY).std())
def read_grid(img, cfg: dict | None = None) -> dict:
"""Heroes that cannot be picked right now, read off the selection grid.
"unavailable" covers bans and heroes already taken by either team; the
caller separates them using the picks it already recognized from the top
bar. Returns ok=False when the lattice does not look like a full roster,
so a mis-detected grid never turns into a bogus ban list.
"""
cfg = cfg or {}
table = hero_table()
grid = detect_grid(img, cfg)
if grid is None:
return {"ok": False, "reason": "no grid detected", "unavailable": [], "cells": {}}
layout = build_layout(grid, table)
if layout is None:
return {"ok": False, "reason": "grid shape does not fit the roster", "unavailable": [], "cells": {}}
if len(layout) != len(table):
return {"ok": False,
"reason": f"placed {len(layout)} of {len(table)} heroes",
"unavailable": [], "cells": {}}
cut = cfg.get("grid", {}).get("unavailable_std", 26.0)
scored = {key: cell_contrast(img, grid, r, c) for (r, c), key in layout.items()}
unavailable = sorted((k for k, s in scored.items() if s < cut), key=lambda k: scored[k])
live = [s for s in scored.values() if s >= cut]
cols, rows = grid["cols"], grid["rows"]
cells = {
key: {"x0": cols[c][0], "y0": rows[r][0], "x1": cols[c][1], "y1": rows[r][1]}
for (r, c), key in layout.items()
}
return {
"ok": True,
"unavailable": unavailable,
"cells": cells,
"grid": {"cols": len(cols), "rows": len(rows)},
"margin": round(min(live) - max((scored[k] for k in unavailable), default=0.0), 1) if live and unavailable else None,
}
def bans(grid_result: dict, picked: list[str]) -> list[str]:
"""Unavailable minus whatever the top bar already showed as picked."""
taken = {p for p in picked if p}
return [k for k in grid_result.get("unavailable", []) if k not in taken]
def _main() -> None:
import cv2
if len(sys.argv) < 2:
raise SystemExit("usage: python shared/grid.py <frame.png> [--picked key,key,...]")
img = cv2.imread(sys.argv[1])
if img is None:
raise SystemExit(f"cannot read {sys.argv[1]}")
picked = []
if "--picked" in sys.argv:
picked = [s.strip() for s in sys.argv[sys.argv.index("--picked") + 1].split(",")]
cfg = json.loads(PC_CONFIG.read_text(encoding="utf-8")) if PC_CONFIG.is_file() else {}
res = read_grid(img, cfg)
names = {h["key"]: h["name_loc"] for h in hero_table()}
if not res["ok"]:
print(f"grid not readable: {res['reason']}")
return
print(f"grid {res['grid']['rows']}x{res['grid']['cols']}, "
f"{len(res['unavailable'])} unavailable, contrast margin {res['margin']}")
print("unavailable:", ", ".join(names.get(k, k) for k in res["unavailable"]))
if picked:
b = bans(res, picked)
print(f"bans ({len(b)}):", ", ".join(names.get(k, k) for k in b))
if __name__ == "__main__":
_main()