Files
climperor/roles.py
T
vosonandCursor f32d24b8f8 Initial commit: 上分帝(Climperor)
从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:47:39 +08:00

221 lines
7.5 KiB
Python

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