diff --git a/assets/templates/skill_binds/f1.png b/assets/templates/skill_binds/f1.png new file mode 100644 index 0000000..d4ec24b Binary files /dev/null and b/assets/templates/skill_binds/f1.png differ diff --git a/assets/templates/skill_binds/f2.png b/assets/templates/skill_binds/f2.png new file mode 100644 index 0000000..fad5228 Binary files /dev/null and b/assets/templates/skill_binds/f2.png differ diff --git a/assets/templates/skill_binds/f3.png b/assets/templates/skill_binds/f3.png new file mode 100644 index 0000000..d9c970d Binary files /dev/null and b/assets/templates/skill_binds/f3.png differ diff --git a/assets/templates/skill_binds/f4.png b/assets/templates/skill_binds/f4.png new file mode 100644 index 0000000..b47970d Binary files /dev/null and b/assets/templates/skill_binds/f4.png differ diff --git a/assets/templates/skill_binds/f5.png b/assets/templates/skill_binds/f5.png new file mode 100644 index 0000000..d828d65 Binary files /dev/null and b/assets/templates/skill_binds/f5.png differ diff --git a/assets/templates/skill_binds/f6.png b/assets/templates/skill_binds/f6.png new file mode 100644 index 0000000..3929e70 Binary files /dev/null and b/assets/templates/skill_binds/f6.png differ diff --git a/assets/templates/skill_binds/f7.png b/assets/templates/skill_binds/f7.png new file mode 100644 index 0000000..cbaf47f Binary files /dev/null and b/assets/templates/skill_binds/f7.png differ diff --git a/assets/templates/skill_binds/f8.png b/assets/templates/skill_binds/f8.png new file mode 100644 index 0000000..4599480 Binary files /dev/null and b/assets/templates/skill_binds/f8.png differ diff --git a/tools/map_spellbook.py b/tools/map_spellbook.py index b856080..d1d84ab 100644 --- a/tools/map_spellbook.py +++ b/tools/map_spellbook.py @@ -34,6 +34,7 @@ answers the question directly instead of inferring it. import argparse import difflib +import glob import os import re import sys @@ -58,10 +59,24 @@ from utils.misc import focus_d2r_window CELL0 = (683, 410) # centre of the top-left cell CELL_W = 46.5 CELL_H = 45.0 -CELL_HALF = 22 # half-width of the icon box around a centre +CELL_HALF = 24 # half-width of the icon box around a centre -# The hotkey label sits in this box, relative to a cell's top-left corner. -CORNER = (26, 2, 22, 16) # x, y, w, h +# The hotkey label box, relative to a cell's top-left corner (cell is +# 2*CELL_HALF square). This is both the region blanked from saved templates and +# the region searched for the binding. +CORNER = (24, 2, 24, 16) # x, y, w, h + +# The label is read by TEMPLATE MATCH, not OCR. It is ~20x12px of white glyph +# over whatever icon happens to be behind it; brightness thresholding cannot +# separate the two when the icon is also bright (F4's swirl, the row-4 +# weapons), and OCR managed only 4/8. A top-hat isolates small bright features +# regardless of background, and the glyphs are a fixed UI font. +BIND_TEMPLATE_DIR = os.path.join("assets", "templates", "skill_binds") +BIND_MIN = 0.70 +# Search slack. Matching a same-sized crop is 1px-brittle: int() rounding in the +# grid geometry lands a pixel off the measured centre and the score collapses +# from ~1.0 to ~0.4. With slack, 8/8 matched at every threshold 0.60-0.78. +BIND_PAD = 4 # The tooltip renders directly ABOVE the hovered cell and grows upward, so its # position moves with the row. A fixed ROI reads the game world instead — the @@ -80,6 +95,16 @@ KNOWN_SKILLS = [ ] MATCH_MIN = 0.72 +# The bind screen shows this hint along the bottom. It is how we tell whether +# the grid is ALREADY open — pressing the key blindly toggles an open grid +# CLOSED, which is exactly how the first live run came back with 0 skills. +# Matched fuzzily: Tesseract reliably renders this as +# "PRESS FI-F8 TO BIRD A SKIN" (BIND->BIRD, SKILL->SKIN), so an exact compare +# never fires. Closed, the same ROI yields "" or "7," — the gap is wide. +HINT_ROI = (450, 620, 400, 25) # x, y, w, h +HINT_TEXT = "PRESS F F TO BIND A SKILL" +HINT_MIN = 0.55 + ASSET_DIR = os.path.join("assets", "templates", "skills_spellbook") # config key -> the name D2R shows in the tooltip @@ -97,6 +122,18 @@ CONFIG_SKILL_NAMES = { } +def grid_is_open(img=None) -> bool: + """True when the skill-bind grid is showing.""" + img = screen.grab() if img is None else img + x, y, w, h = HINT_ROI + crop = cv2.resize(img[y:y + h, x:x + w], None, fx=2.5, fy=2.5) + text = re.sub(r"[^A-Z ]", " ", _ocr(crop, psm=7).upper()) + text = re.sub(r"\s+", " ", text).strip() + if not text: + return False + return difflib.SequenceMatcher(None, HINT_TEXT, text).ratio() >= HINT_MIN + + def _cell_centre(col: int, row: int) -> tuple[int, int]: return int(CELL0[0] + CELL_W * col), int(CELL0[1] + CELL_H * row) @@ -111,19 +148,45 @@ def _ocr(img, psm: int = 6) -> str: return "" -def read_bound_key(cell_img) -> str | None: - """OCR the upper-right corner -> 'f5', or None when unbound.""" +def _isolate_glyph(bgr): + """Top-hat: keeps small bright features, drops whatever is behind them.""" + g = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype("float32") + k = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) + th = cv2.morphologyEx(g, cv2.MORPH_TOPHAT, k) + return cv2.normalize(th, None, 0, 255, cv2.NORM_MINMAX).astype("uint8") + + +_BIND_TEMPLATES: dict | None = None + + +def _bind_templates() -> dict: + global _BIND_TEMPLATES + if _BIND_TEMPLATES is None: + _BIND_TEMPLATES = {} + for path in glob.glob(os.path.join(BIND_TEMPLATE_DIR, "*.png")): + im = cv2.imread(path) + if im is not None: + _BIND_TEMPLATES[os.path.basename(path)[:-4].lower()] = _isolate_glyph(im) + return _BIND_TEMPLATES + + +def read_bound_key(img, cx: int, cy: int) -> str | None: + """Template-match the hotkey label -> 'f5', or None when unbound.""" x, y, w, h = CORNER - corner = cell_img[y:y + h, x:x + w] - if corner.size == 0: + x0 = cx - CELL_HALF + x + y0 = cy - CELL_HALF + y + win = img[y0 - BIND_PAD:y0 + h + BIND_PAD, x0 - BIND_PAD:x0 + w + BIND_PAD] + if win.size == 0 or win.shape[0] < 10: return None - corner = cv2.resize(corner, None, fx=4.0, fy=4.0, interpolation=cv2.INTER_CUBIC) - text = _ocr(corner, psm=7).upper().replace(" ", "") - m = re.search(r"F\s*(\d{1,2})", text) - if not m: - return None - n = int(m.group(1)) - return f"f{n}" if 1 <= n <= 12 else None + g = _isolate_glyph(win) + best, score = None, 0.0 + for name, tpl in _bind_templates().items(): + if g.shape[0] < tpl.shape[0] or g.shape[1] < tpl.shape[1]: + continue + s = cv2.matchTemplate(g, tpl, cv2.TM_CCOEFF_NORMED).max() + if s > score: + best, score = name, s + return best if score >= BIND_MIN else None def icon_without_corner(cell_img): @@ -173,7 +236,7 @@ def map_grid(cols: int, rows: int, save_assets: bool) -> list[dict]: if not name: continue # empty cell — no tooltip appeared cell = img[cy - CELL_HALF:cy + CELL_HALF, cx - CELL_HALF:cx + CELL_HALF] - key = read_bound_key(cell) + key = read_bound_key(img, cx, cy) found.append({"name": name, "key": key, "col": col, "row": row}) print(f" ({col},{row}) {name:<24} {key or '-'}", flush=True) @@ -247,9 +310,22 @@ def main() -> int: return 2 time.sleep(0.5) - if not args.no_open: + # Detect, do not assume. Toggling an already-open grid closes it. + opened_by_us = False + if not grid_is_open(): + if args.no_open: + print("map_spellbook: --no-open was given but the bind grid is not " + "open. Open it and re-run, or drop --no-open.") + return 2 keyboard.send(args.key) time.sleep(1.0) + opened_by_us = True + if not grid_is_open(): + print(f"map_spellbook: pressed {args.key!r} but the bind grid did not " + f"open. Use --key if your bind screen is on another key.") + return 2 + else: + print("Bind grid already open — leaving it as found.") print(f"Scanning {args.cols}x{args.rows} bind grid...", flush=True) found = map_grid(args.cols, args.rows, args.assets) @@ -270,8 +346,8 @@ def main() -> int: print(f"\nFound {len(found)} skills, {bound} of them bound.") problems = compare_with_config(found) - if not args.no_open: - keyboard.send(args.key) # close the grid we opened + if opened_by_us: + keyboard.send(args.key) # close it only if we opened it if problems: print(f"\n{problems} mismatch(es). Fix the binds in game or the keys in "