Add marker id detection; log unit_score/margin; use full-res captures

read_marker_id (map_vision.py) reads each marker's own small "#<N>" id
label via template correlation, same approach as read_cell_label and
for the same documented reason (this text sits over the same aerial-
photo backdrop that defeated detection-based approaches for grid
labels). Wired end-to-end: find_markers -> Proposal.detected_id ->
save_marker_ground_truth's JSON. Reads against ScreenshotImport's
full_image when available, since the id text is tiny. Crop region and
threshold are a single-screenshot calibration, not yet validated
against real ground truth (documented as such).

Also switches save_marker_ground_truth/save_grid_correction to use
full_image over the WORK_W-downscaled image, so a human reviewing a
capture can actually read the small id text well enough to judge it.

Logs unit_score/unit_margin on every Proposal too (previously only
pass/fail `unit` was recorded), and measured current type-detection
reliability against the 6 existing ground-truth captures: 0/72 (0%)
accepted proposals had any confident detected_unit at all, not just
wrong guesses -- classify_marker never clears its own confidence floor
against real screenshots. Findings and next steps in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 18:54:53 +02:00
co-authored by Claude Sonnet 5
parent 896c7dc36a
commit 086b871e3a
6 changed files with 226 additions and 16 deletions
+11 -2
View File
@@ -640,7 +640,11 @@ class MainWindow(Adw.ApplicationWindow):
both solutions as ground truth, useful later for improving the
grid solver against exactly the case it got wrong."""
if solution is not imp.solution:
debug_capture.save_grid_correction(imp.image, imp.solution, solution)
# full_image over image: sharper source for a human reviewing
# the capture later, same reasoning as save_marker_ground_truth's.
debug_capture.save_grid_correction(
imp.full_image if imp.full_image is not None else imp.image,
imp.solution, solution)
imp.solution = solution
# A screenshot already on the board (never explicitly dropped, the
# user just pasted a new one straight over it) still deserves its
@@ -775,7 +779,12 @@ class MainWindow(Adw.ApplicationWindow):
moment it can still be tied to this specific image."""
added_targets = [t for t in self.board.targets if t not in imp.baseline_targets]
added_allies = [a for a in self.board.allies if a not in imp.baseline_allies]
debug_capture.save_marker_ground_truth(imp.image, imp.proposals, added_targets, added_allies)
# full_image over image: a human checking a detected_id against
# this capture later needs to actually read that tiny text, see
# save_marker_ground_truth's own docstring.
debug_capture.save_marker_ground_truth(
imp.full_image if imp.full_image is not None else imp.image,
imp.proposals, added_targets, added_allies)
def _remove_screenshot(self) -> None:
"""Dropping the screenshot also drops every proposal never accepted:
+13 -1
View File
@@ -101,7 +101,17 @@ def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()
matter: a rejected proposal is a false positive to fix, a manually-
added unit that had no matching proposal at all is a miss to fix.
Skipped entirely if there's nothing to say (no proposals AND no
manually-added units), a screenshot nobody ever looked at units on."""
manually-added units), a screenshot nobody ever looked at units on.
Each proposal also carries `detected_id` (map_vision.read_marker_id's
best-effort read of the marker's own "#<N>" id label, see its own
docstring -- not yet validated against a real batch of this exact
ground truth, which is precisely what these captures are for).
`image` should be the sharpest one the caller has (full_image over
the WORK_W-downscaled one, see ScreenshotImport.full_image) so a
human reviewing a capture later can actually read that id text well
enough to judge whether detected_id was right -- not just take the
detector's word for it."""
if not proposals and not added_targets and not added_allies:
return None
png = _to_png_bytes(image)
@@ -120,6 +130,8 @@ def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()
{
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y,
"detected_unit": p.unit, "verdict": verdict(p), "confirmed_type": p.confirmed_type,
"detected_id": p.detected_id,
"unit_score": p.unit_score, "unit_margin": p.unit_margin,
}
for p in proposals
],
+28 -2
View File
@@ -41,6 +41,13 @@ class Proposal:
box: tuple
accepted: bool = False
rejected: bool = False
# classify_marker's own raw numbers behind `unit` (best-match score,
# and its margin over the runner-up) -- unit alone only says whether
# it beat min_score/min_margin, not by how much or how close a call
# it was. Ground truth needs these to tell "confidently wrong" apart
# from "just barely missed the bar", which `unit=None` alone can't.
unit_score: float = 0.0
unit_margin: float = 0.0
# The TargetType.name actually applied when accepted -- usually just
# `unit` translated through icons.target_type_from_icon, but can
# differ if the user corrected it via "Accept as...". Set by
@@ -48,6 +55,15 @@ class Proposal:
# debug_capture.save_marker_ground_truth: `unit` is what the
# classifier guessed, this is what the user actually confirmed.
confirmed_type: str | None = None
# The marker's own "#<N>" id label, as read off the screenshot by
# map_vision.read_marker_id -- distinct from `label`/sub_x/sub_y
# (the grid CELL this marker is in), this is the small per-unit id
# the game itself draws. None when unread/unconfident (see
# read_marker_id's own docstring: best-effort, not yet validated
# against a real ground-truth batch). Meant for future dedup work
# (see TODO.md) once there's confidence in the read; not otherwise
# consumed yet.
detected_id: str | None = None
@property
def coord(self) -> str:
@@ -95,7 +111,10 @@ class ScreenshotImport:
self.proposals = [
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
sub_y=m["sub_y"], unit=m.get("unit"),
centre=m["centre"], box=m["box"]) for m in markers]
centre=m["centre"], box=m["box"],
detected_id=m.get("detected_id"),
unit_score=m.get("unit_score", 0.0),
unit_margin=m.get("unit_margin", 0.0)) for m in markers]
return self.proposals
def build_overlay(self, px_per_km=150):
@@ -200,9 +219,16 @@ class ImportJob:
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
because the user's grid correction sits between the two phases.
Marker detection itself always runs against imp.image (WORK_W,
same as solving used); imp.full_image is passed through only for
reading each marker's own tiny id label off a sharper source, see
map_vision.find_markers' own id_img param.
"""
def work():
imp.set_proposals(map_vision.find_markers(imp.image, imp.solution))
id_img = imp.full_image # None is fine, find_markers falls back to imp.image
imp.set_proposals(map_vision.find_markers(
imp.image, imp.solution, id_img=id_img, id_scale=imp.full_image_scale))
return imp, None
return self._run(work, on_done, "map-markers")
+74 -3
View File
@@ -486,6 +486,62 @@ def read_cell_label(cell_gray, glyph_fracs=(0.10, 0.13, 0.17)):
return best
# Every marker the game draws also carries a small "#<N>" id label just
# above-left of its icon (distinct from the big per-cell grid label
# read_cell_label reads) -- calibrated by eye against a real screenshot
# saved under debug_captures/marker_ground_truth: it sits roughly one
# marker-width to the left and level with the marker's own top edge.
# Observed ids in practice are small (single or double digit); 1-99
# covers that generously without the search space growing large.
MARKER_ID_CANDIDATES = [f"#{n}" for n in range(1, 100)]
MIN_MARKER_ID_SCORE = 0.55 # unmeasured starting point, see read_marker_id's own docstring
def read_marker_id(gray, box, glyph_fracs=(0.30, 0.40, 0.50, 0.60)):
"""Which '#<N>' id best explains the pixels just above-left of this
marker? Same template-correlation approach as read_cell_label, and
for the same reason (see this module's own docstring): this text
sits over the same aerial-photo backdrop that defeated every
detection-based approach tried for grid labels, so glyph correlation
against a known-position crop is used here too rather than OCR.
`box` is the marker's own detected (x, y, w, h), in `gray`'s pixel
space -- the caller is responsible for scaling it if `gray` isn't
the same image the marker was detected in (see find_markers' own
id_img/id_scale params, for reading against a sharper source than
detection ran on).
Best-effort and NOT validated against a real ground-truth batch yet
(unlike read_cell_label's measured 0.73-0.87 vs 0.40-0.56 -- there's
no equivalent number here): both the crop region and
MIN_MARKER_ID_SCORE are a single-screenshot calibration, expect this
to need retuning once there's a real batch of debug_capture ground
truth with confirmed ids to check against (see TODO.md). Returns
None below the threshold rather than guessing.
"""
x, y, w, h = box
left = max(0, int(x - 1.0 * w))
top = max(0, int(y - 0.45 * h))
right = min(gray.shape[1], int(x + 0.65 * w))
bottom = min(gray.shape[0], int(y + 0.55 * h))
if right - left < 6 or bottom - top < 6:
return None
patch = np.ascontiguousarray(gray[top:bottom, left:right])
best = (None, -1.0)
for gf in glyph_fracs:
th = max(6, int(gf * h))
for cand in MARKER_ID_CANDIDATES:
t = glyph_template(cand, th)
if t is None or t.shape[0] >= patch.shape[0] or t.shape[1] >= patch.shape[1]:
continue
sc = float(cv2.matchTemplate(patch, t, cv2.TM_CCOEFF_NORMED).max())
if sc > best[1]:
best = (cand, sc)
if best[1] < MIN_MARKER_ID_SCORE:
return None
return best[0].lstrip("#")
def visible_cells(H, shape, limit=6):
"""Lattice cells whose centre is on screen, nearest the frame centre
first (least perspective distortion, so the easiest to read)."""
@@ -961,18 +1017,33 @@ def diamonds(mask, cell_px, shape="diamond"):
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
def find_markers(img, sol):
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box."""
def find_markers(img, sol, id_img=None, id_scale=1.0):
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box,
detected_id.
`id_img`/`id_scale`: read each marker's small "#<N>" id label (see
read_marker_id) against a sharper source than detection ran on --
ScreenshotImport.full_image over the WORK_W-downscaled `img`, same
reasoning as build_overlay's own img_scale (id text is tiny; reading
it off the downscaled image loses too much detail). `id_scale` is
id_img's width / img's width. Detection itself (marker
position/shape/color, unit classification) always runs against `img`
-- only the id read benefits from more resolution. Falls back to
reading against `img` itself when id_img is None (still better than
nothing, just at WORK_W's lower detail)."""
cell = max(sol.steps)
found = []
id_gray = cv2.cvtColor(id_img if id_img is not None else img, cv2.COLOR_BGR2GRAY)
for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
c = sol.cell_of(cx, cy)
if c is None:
continue
unit, score, margin = classify_marker(img, box, side)
id_box = box if id_scale == 1.0 else tuple(v * id_scale for v in box)
detected_id = read_marker_id(id_gray, id_box)
found.append(dict(side=side, unit=unit, unit_score=score,
unit_margin=margin, label=c[0], sub_x=c[1],
sub_y=c[2], coord=format_coord(c),
centre=(cx, cy), box=box))
centre=(cx, cy), box=box, detected_id=detected_id))
return found