"""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 [--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()