"""Download all hero portraits from Steam CDN as fallback templates. Usage: python fetch_cdn_templates.py Writes: heroes.json - hero id / key / English name table (from OpenDota constants) templates/cdn/{key}.png - face-centered square crop resized to canonical size """ import json from pathlib import Path import cv2 import numpy as np import requests from common import ROOT, TEMPLATES_CDN, load_config # Valve's own feed: ids, localized names, primary attribute. The hero-selection # grid groups by that attribute and sorts by that localized name, so taking both # from the same source is what lets grid.py place every cell without matching. HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}" IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png" ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"} # 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 fetch_heroes(lang: str = "schinese") -> list[dict]: data = requests.get(HEROES_URL.format(lang=lang), timeout=30).json() heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes") if not heroes: raise SystemExit("hero list came back empty") return heroes 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_heroes() table = [] ok, fail = 0, 0 for h in heroes: key = h["name"].removeprefix("npc_dota_hero_") table.append({ "id": h["id"], "key": key, "name": h["name_english_loc"], "attr": ATTRS.get(h["primary_attr"], "all"), "name_loc": h["name_loc"], }) out = TEMPLATES_CDN / f"{key}.png" if out.exists(): ok += 1 continue try: raw = requests.get(IMG_URL.format(key=key), timeout=30).content 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 with open(ROOT / "heroes.json", "w", encoding="utf-8") as f: json.dump(sorted(table, key=lambda t: t["id"]), f, ensure_ascii=False, indent=1) print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in heroes.json") if __name__ == "__main__": main()