tools(spellbook): detect grid state, template-match the hotkey labels

Three fixes from running it against the live game:

1. It blind-pressed the open key. The grid was ALREADY open, so the press
   CLOSED it and the scan read grass. Now it detects the grid via its bottom
   hint and toggles only when needed, restoring the state it found. The hint
   match is fuzzy because Tesseract renders it "PRESS FI-F8 TO BIRD A SKIN"
   (BIND->BIRD, SKILL->SKIN).

2. The tooltip ROI was fixed, but the tooltip renders ABOVE the hovered cell
   and moves with the row, so it was reading the game world. Now taken relative
   to the cell. Skill identification is a fuzzy match against a known list
   rather than a demand for clean OCR: real reads included "XI BLESSED HAMMER"
   and one frame OCR'd Fist of the Heavens as "LNMEPULE".

3. Hotkey labels are read by TEMPLATE MATCH, not OCR. They are ~20x12px of
   white glyph over whatever icon is behind them; brightness thresholding
   cannot separate the two when the icon is also bright (F4's swirl, the row-4
   weapons) and OCR managed 4/8. A top-hat isolates small bright features
   regardless of background: 8/8 with zero false positives.

   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. Exactly the three cells where truncation differed failed. A
   4px search slack fixes it; 8/8 held at every threshold 0.60-0.78.

Adds assets/templates/skill_binds/f1..f8.png, cut from a frame with all eight
labels visible.

Verified live: correctly reported conviction=f5 as casting TELEPORT and
concentration=f8 as casting CONVICTION — both confirmed by hand beforehand —
and found Concentration sitting unbound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-28 10:42:36 +02:00
parent 2feb13f44a
commit fd889a4df7
9 changed files with 94 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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 <k> 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 "