Files
climperor/grid.py
T
voson a91789b72f v0.2.0: relations preview, item shop, abilities, overlay recommend, GSI enhancements
- 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)
2026-07-27 11:56:51 +08:00

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