Initial commit: 上分帝(Climperor)
从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""Read the role-queue labels and find which top-bar slot is you.
|
||||
|
||||
Two things live in the strip of text under each top-bar portrait:
|
||||
|
||||
row 1 (name) - your own name renders white and bold, everyone else's is
|
||||
tinted blue, which is enough to tell which slot is you.
|
||||
row 2 (role) - only drawn for your own team, and only in role-queue
|
||||
(定位匹配) matches: 优势路 / 中路 / 劣势路 / 辅助 / 纯辅助.
|
||||
|
||||
The role text is flat grey with zero saturation, so a threshold on
|
||||
value+saturation isolates it cleanly. Matching is done on the binary mask
|
||||
(icon included) rather than OCR: there are only five possible strings and
|
||||
they differ in width, so mask IoU separates them by a wide margin.
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT, slot_rect_px
|
||||
|
||||
TEMPLATES_ROLES = ROOT / "templates" / "roles"
|
||||
|
||||
# key -> (in-game text, lane position number)
|
||||
ROLES = {
|
||||
"safe": ("优势路", 1),
|
||||
"mid": ("中路", 2),
|
||||
"off": ("劣势路", 3),
|
||||
"soft_support": ("辅助", 4),
|
||||
"hard_support": ("纯辅助", 5),
|
||||
}
|
||||
|
||||
# canonical mask geometry, chosen so 1440p text (~17px tall) upsamples slightly
|
||||
STRIP_H = 24
|
||||
STRIP_W = 160
|
||||
|
||||
|
||||
def _row_rect(slot: dict, cfg: dict, img_w: int, img_h: int, row: str) -> tuple[int, int, int, int]:
|
||||
r = cfg["text_rows"][row]
|
||||
x, _, w, _ = slot_rect_px(slot, cfg, img_w, img_h)
|
||||
pad = int(round(w * 0.35)) # names/roles overflow the portrait width
|
||||
y0 = int(round(r["y0_rel"] * img_h))
|
||||
y1 = int(round(r["y1_rel"] * img_h))
|
||||
return x - pad, y0, w + 2 * pad, y1 - y0
|
||||
|
||||
|
||||
def _text_mask(patch: np.ndarray, min_value: int, max_sat: float) -> np.ndarray:
|
||||
"""Isolate the flat light-grey glyphs from the dark blurred background."""
|
||||
p = patch.astype(np.float32)
|
||||
mx = p.max(axis=2)
|
||||
mn = p.min(axis=2)
|
||||
sat = (mx - mn) / np.maximum(mx, 1.0)
|
||||
return ((mx > min_value) & (sat < max_sat)).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def _tight(mask: np.ndarray) -> np.ndarray | None:
|
||||
"""Crop to the ink, then normalize height so resolution stops mattering."""
|
||||
ys, xs = np.nonzero(mask)
|
||||
if ys.size < 40:
|
||||
return None
|
||||
m = mask[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
h, w = m.shape
|
||||
scale = STRIP_H / h
|
||||
m = cv2.resize(m, (max(1, int(round(w * scale))), STRIP_H), interpolation=cv2.INTER_AREA)
|
||||
canvas = np.zeros((STRIP_H, STRIP_W), np.uint8)
|
||||
m = m[:, :STRIP_W]
|
||||
canvas[:, : m.shape[1]] = m
|
||||
return (canvas > 127).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def role_mask(img: np.ndarray, slot: dict, cfg: dict) -> np.ndarray | None:
|
||||
"""Binary mask of one slot's role label, or None when there is no label."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = _row_rect(slot, cfg, iw, ih, "role")
|
||||
patch = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
t = cfg["text_rows"]["role"]
|
||||
return _tight(_text_mask(patch, t.get("min_value", 110), t.get("max_sat", 0.08)))
|
||||
|
||||
|
||||
def name_tint(img: np.ndarray, slot: dict, cfg: dict) -> tuple[float, float] | None:
|
||||
"""Mean value and saturation of the name glyphs: (value, saturation)."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = _row_rect(slot, cfg, iw, ih, "name")
|
||||
patch = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
p = patch.astype(np.float32)
|
||||
mx = p.max(axis=2)
|
||||
thr = max(90.0, float(mx.max()) * 0.7)
|
||||
sel = mx > thr
|
||||
if int(sel.sum()) < 30:
|
||||
return None
|
||||
px = p[sel]
|
||||
hi = px.max(axis=1)
|
||||
lo = px.min(axis=1)
|
||||
return float(hi.mean()), float(((hi - lo) / np.maximum(hi, 1.0)).mean())
|
||||
|
||||
|
||||
def iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
ab = a > 0
|
||||
bb = b > 0
|
||||
union = int((ab | bb).sum())
|
||||
return float((ab & bb).sum()) / union if union else 0.0
|
||||
|
||||
|
||||
def load_role_templates() -> dict[str, np.ndarray]:
|
||||
if not TEMPLATES_ROLES.is_dir():
|
||||
return {}
|
||||
out = {}
|
||||
for key in ROLES:
|
||||
f = TEMPLATES_ROLES / f"{key}.png"
|
||||
if f.is_file():
|
||||
img = cv2.imread(str(f), cv2.IMREAD_GRAYSCALE)
|
||||
if img is not None:
|
||||
out[key] = img
|
||||
return out
|
||||
|
||||
|
||||
def detect_roles(img: np.ndarray, cfg: dict, templates: dict[str, np.ndarray] | None = None) -> dict:
|
||||
"""Per-slot role plus which slot is you.
|
||||
|
||||
Returns {"self_slot": int|None, "self_team": str|None, "roles": {slot: {...}}}.
|
||||
Slots without a role label (the enemy team, or any non-role-queue mode)
|
||||
are simply absent from "roles".
|
||||
"""
|
||||
if templates is None:
|
||||
templates = load_role_templates()
|
||||
cutoff = cfg.get("roles", {}).get("min_iou", 0.55)
|
||||
|
||||
roles: dict[int, dict] = {}
|
||||
for slot in cfg.get("slots", []):
|
||||
mask = role_mask(img, slot, cfg)
|
||||
if mask is None:
|
||||
continue
|
||||
ranked = sorted(((iou(mask, t), k) for k, t in templates.items()), reverse=True)
|
||||
if not ranked or ranked[0][0] < cutoff:
|
||||
continue
|
||||
score, key = ranked[0]
|
||||
roles[slot["index"]] = {
|
||||
"role": key,
|
||||
"label": ROLES[key][0],
|
||||
"position": ROLES[key][1],
|
||||
"score": round(score, 3),
|
||||
}
|
||||
|
||||
self_slot = _detect_self(img, cfg)
|
||||
self_team = None
|
||||
if roles:
|
||||
self_team = "radiant" if min(roles) <= 5 else "dire"
|
||||
elif self_slot is not None:
|
||||
self_team = "radiant" if self_slot <= 5 else "dire"
|
||||
|
||||
return {"self_slot": self_slot, "self_team": self_team, "roles": roles}
|
||||
|
||||
|
||||
def _detect_self(img: np.ndarray, cfg: dict) -> int | None:
|
||||
"""Your own name is drawn bright white, the other nine a dimmer blue-grey.
|
||||
|
||||
The gap is ~55 units of brightness, so compare slots against each other
|
||||
instead of a fixed threshold - that survives HUD skins and any screen
|
||||
where every row happens to be bright (a menu, a loading overlay), because
|
||||
there the runner-up is just as bright and the match is rejected.
|
||||
"""
|
||||
r = cfg.get("roles", {})
|
||||
min_value = r.get("self_min_value", 195.0)
|
||||
min_gap = r.get("self_min_gap", 25.0)
|
||||
found = []
|
||||
for slot in cfg.get("slots", []):
|
||||
tint = name_tint(img, slot, cfg)
|
||||
if tint is not None:
|
||||
found.append((tint[0], slot["index"]))
|
||||
if len(found) < 2:
|
||||
return None
|
||||
found.sort(reverse=True)
|
||||
if found[0][0] < min_value or found[0][0] - found[1][0] < min_gap:
|
||||
return None
|
||||
return found[0][1]
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
"""python roles.py <frame.png> - report roles found
|
||||
python roles.py <frame.png> --build off,safe,mid,soft_support,hard_support
|
||||
- save templates from slots 1..N
|
||||
"""
|
||||
import sys
|
||||
|
||||
from common import load_config
|
||||
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(_main.__doc__)
|
||||
return
|
||||
frame = cv2.imread(args[0])
|
||||
if frame is None:
|
||||
raise SystemExit(f"cannot read {args[0]}")
|
||||
cfg = load_config()
|
||||
|
||||
if "--build" in args:
|
||||
labels = args[args.index("--build") + 1].split(",")
|
||||
TEMPLATES_ROLES.mkdir(parents=True, exist_ok=True)
|
||||
for slot, key in zip(cfg["slots"], labels):
|
||||
key = key.strip()
|
||||
if key not in ROLES:
|
||||
raise SystemExit(f"unknown role {key!r}, expected one of {list(ROLES)}")
|
||||
mask = role_mask(frame, slot, cfg)
|
||||
if mask is None:
|
||||
raise SystemExit(f"slot {slot['index']} has no role text")
|
||||
out = TEMPLATES_ROLES / f"{key}.png"
|
||||
cv2.imwrite(str(out), mask)
|
||||
print(f"slot {slot['index']} -> {key} ({ROLES[key][0]}) {out}")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
print(json.dumps(detect_roles(frame, cfg), ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
Reference in New Issue
Block a user