- Add relations/item/abilities preview (serve_relations.py + web/relations/) - Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities, ability_videos, patches, stratz, matchups, portraits - Add overlay.py (role tags + Top-3 cyan marks), recommend.py - Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py - GSI: full payload JSONL dump, foreground window detection - Drop real template library; CDN-only matching - Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table - .gitignore: exclude large regenerable assets (icons/portraits/videos)
217 lines
8.0 KiB
Python
217 lines
8.0 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.
|
|
"""
|
|
|
|
import json
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from common import HEROES_JSON
|
|
|
|
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: np.ndarray, 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: np.ndarray, 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.
|
|
"""
|
|
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: np.ndarray, 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.
|
|
"""
|
|
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."""
|
|
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.
|
|
"""
|
|
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: np.ndarray, grid: dict, r: int, c: int) -> float:
|
|
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: np.ndarray, 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 sys
|
|
|
|
from common import load_config
|
|
|
|
if len(sys.argv) < 2:
|
|
raise SystemExit("usage: python 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(",")]
|
|
|
|
res = read_grid(img, load_config())
|
|
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()
|