从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。 Co-authored-by: Cursor <cursoragent@cursor.com>
85 lines
3.0 KiB
Python
85 lines
3.0 KiB
Python
"""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()
|