"""Recover the game grid from a screenshot of the map table, and read the unit markers off it. This is a second, separate pipeline from `ocr.py`'s typewriter-text OCR. See `docs/map_vision_plan.md` for the design record, the measurements behind it, and the approaches that were tried and rejected. The scene is a *flat* table viewed by a perspective camera, so a single homography describes grid-to-screen exactly. The grid, its per-cell labels and the markers are drawn on the table surface, so they are visible whether or not the aerial photo covers that part of the map. Shape of the solution: 1. Line evidence -> two projective line families -> candidate lattices. Abundant and cheap, but it can only ever give a lattice up to unknown scale (1 km cells and the 100 m subgrid look identical), axis assignment, direction and phase. 2. Those discrete unknowns, plus the absolute anchor, are resolved by READING CELL LABELS -- and the labels are not detected. Once a candidate lattice exists we know exactly where a label must be (9% of a cell in from the left, 6% down from the top), so we crop there and correlate the glyphs rendered in the game's own font. Detection was tried five different ways and always returned aerial-photo texture instead of glyphs; correlating a known template at a known place does not have that failure mode. 3. The correlation score also *ranks the lattice candidates*: a wrong lattice puts the crop where no label is, so it scores low. One number therefore selects scale, axis assignment, direction, phase and anchor together. Measured on the 10 fixtures in tests/fixtures/map_shots: solves 7 of them, with 100% of each solved shot's annotated points landing in the correct cell (85 of 112 overall) and a residual spread of 0.005-0.033 cells. The other three are rejected rather than guessed at, and no fixture has ever produced a plausible-but-wrong grid. Rejection is a supported outcome -- a silently misplaced target is far worse than a refusal. Over the 122 typewriter screenshots this was checked against, solve() accepted none, which is what makes it safe to route clipboard images through it (see looks_like_map for the cheap pre-filter). """ from __future__ import annotations import numpy as np from pathlib import Path try: import cv2 except ImportError as exc: # pragma: no cover raise ImportError( "map_vision needs opencv (pip install opencv-python-headless)") from exc from PIL import Image, ImageDraw, ImageFont LARGE_X = "ABCDEFGHIJKLMNOPQRST" COLS, ROWS = 20, 10 FONT_PATH = Path(__file__).resolve().parents[2] / "assets" / "fonts" / "CourierPrime-Regular.ttf" WORK_W = 1500 # working resolution; screenshots vary 700..6880 px wide CELL_PX = 240 # canonical size a rectified cell is warped to CELL_MARGIN = 0.18 # rectify beyond the cell bounds, see rectify_cell() PAD_L, PAD_T = 0.09, 0.06 # label padding inside its cell (game constant) LABEL_ACCEPT = 0.62 # per-read confidence; measured: correct reads 0.73-0.87, # wrong reads 0.40-0.56, so this sits inside the gap MIN_LABEL_VOTES = 2 # one label has no error detection: a misread shifts the # whole board with nothing to contradict it FLIPS = ((False, False), (True, False), (False, True), (True, True)) SWAP = np.array([[0, 1, 0], [1, 0, 0], [0, 0, 1]], np.float64) LABELS = [f"{c}{n}" for c in LARGE_X for n in range(1, 11)] # --------------------------------------------------------------- evidence def load(path, work_w=None) -> np.ndarray: img = cv2.imread(str(path), cv2.IMREAD_COLOR) if img is None: raise ValueError(f"cannot read image: {path}") return downscale(img, work_w) def downscale(img, work_w=None) -> np.ndarray: h, w = img.shape[:2] s = min(1.0, (work_w or WORK_W) / w) if s < 1.0: img = cv2.resize(img, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA) return img def ridge(img: np.ndarray) -> np.ndarray: """Bright, neutral, thin line structures; coloured overlays suppressed. Two things this must get right: * the colour mask is relative to the scene's OWN illuminant. One fixture is lit bright red, and an absolute saturation cut masks the entire table, grid included. * several top-hat kernel sizes, not one. A top-hat kernel must be LARGER than the structure it keeps or it hollows it out, and grid lines run from ~2px when the whole table is in frame to ~15px when a single cell fills it. """ lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) lum = lab[:, :, 0] a = lab[:, :, 1].astype(np.float32) - float(np.median(lab[:, :, 1])) b = lab[:, :, 2].astype(np.float32) - float(np.median(lab[:, :, 2])) chroma = np.sqrt(a * a + b * b) acc = np.zeros(lum.shape, np.float32) for k in (7, 15, 31, 51): el = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) acc = np.maximum(acc, cv2.morphologyEx(lum, cv2.MORPH_TOPHAT, el).astype(np.float32)) acc[chroma > 16.0] = 0 return (np.clip(acc, 0, 55) / 55.0 * 255).astype(np.uint8) def segments(ev: np.ndarray, min_len: float) -> np.ndarray: lines = cv2.createLineSegmentDetector().detect(ev)[0] if lines is None: return np.zeros((0, 4), np.float32) L = lines.reshape(-1, 4) return L[np.hypot(L[:, 2] - L[:, 0], L[:, 3] - L[:, 1]) >= min_len] def _homog_lines(L): p1 = np.c_[L[:, 0], L[:, 1], np.ones(len(L))] p2 = np.c_[L[:, 2], L[:, 3], np.ones(len(L))] ln = np.cross(p1, p2) return ln / (np.linalg.norm(ln[:, :2], axis=1, keepdims=True) + 1e-9) def _vp_ransac(L, allowed, iters=4000, tol_deg=1.5, seed=0): idx = np.flatnonzero(allowed) if len(idx) < 3: return np.zeros(len(L), bool) rng = np.random.default_rng(seed) ln = _homog_lines(L) mid = np.c_[(L[:, 0] + L[:, 2]) / 2, (L[:, 1] + L[:, 3]) / 2] ang = np.arctan2(L[:, 3] - L[:, 1], L[:, 2] - L[:, 0]) tol = np.deg2rad(tol_deg) best = np.zeros(len(L), bool) for _ in range(iters): i, j = rng.choice(idx, 2, replace=False) v = np.cross(ln[i], ln[j]) if abs(v[2]) < 1e-9: continue vx, vy = v[0] / v[2], v[1] / v[2] want = np.arctan2(vy - mid[:, 1], vx - mid[:, 0]) diff = np.abs((want - ang + np.pi / 2) % np.pi - np.pi / 2) inl = (diff < tol) & allowed if inl.sum() > best.sum(): best = inl return best def two_families(L, min_sep_deg=20.0): """The two pencils of grid lines. The second family is required to be angularly DISTINCT from the first. Simply re-running RANSAC on the leftovers lets both passes lock onto the same family and report two "families" a degree apart. Orientation is also undirected, so angles are compared as doubled angles -- otherwise +89 and -89 degrees look like opposites instead of neighbours. """ th2 = 2.0 * np.arctan2(L[:, 3] - L[:, 1], L[:, 2] - L[:, 0]) inA = _vp_ransac(L, np.ones(len(L), bool), seed=1) if inA.sum() == 0: return inA, np.zeros(len(L), bool) mA = np.arctan2(np.median(np.sin(th2[inA])), np.median(np.cos(th2[inA]))) d = np.abs(np.angle(np.exp(1j * (th2 - mA)))) / 2.0 inB = _vp_ransac(L, (~inA) & (d > np.deg2rad(min_sep_deg)), seed=2) return inA, inB def _fit_vp(L, inl): _, _, Vt = np.linalg.svd(_homog_lines(L[inl])) v = Vt[-1] return v / (v[2] if abs(v[2]) > 1e-12 else 1e-12) # ---------------------------------------------------------- lattice fitting def _dir_of(Hp, sub): ds = [] for (x1, y1, x2, y2) in sub: p = Hp @ np.array([[x1, x2], [y1, y2], [1.0, 1.0]]) if np.any(np.abs(p[2]) < 1e-9): continue p = p[:2] / p[2] d = p[:, 1] - p[:, 0] n = np.linalg.norm(d) if n > 1e-9: d = d / n ds.append(d if d[0] >= 0 else -d) if not ds: return None d = np.median(np.array(ds), axis=0) return d / (np.linalg.norm(d) + 1e-12) def rectify_candidates(vpA, vpB, L, inA, inB, shape): """Maps that turn the perspective lattice into an axis-aligned regular one, so spacing becomes a 2-parameter fit instead of a projective one. Both a projective and an affine variant are offered. Insisting on the projective one is wrong: with a near-overhead camera the vanishing points are far away and ill-conditioned, so the horizon estimate is noise and the "horizon crosses the frame" guard fires on the *easiest* inputs. Mild perspective must be the easy case. """ out = [] cand_hp = [] horizon = np.cross(vpA, vpB) if abs(horizon[2]) > 1e-9: hz = horizon / horizon[2] Hp = np.array([[1, 0, 0], [0, 1, 0], [hz[0], hz[1], 1.0]], np.float64) h, w = shape[:2] corners = np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1]], np.float64) ws = (Hp @ corners)[2] if np.all(np.abs(ws) > 1e-6) and not (ws.min() < 0 < ws.max()): cand_hp.append(Hp) cand_hp.append(np.eye(3)) for Hp in cand_hp: dA, dB = _dir_of(Hp, L[inA]), _dir_of(Hp, L[inB]) if dA is None or dB is None or abs(float(np.cross(dA, dB))) < 0.05: continue Ha = np.eye(3) Ha[:2, :2] = np.linalg.inv(np.column_stack([dA, dB])) out.append(Ha @ Hp) return out def fit_lattice_1d(pos, min_occupancy=0.5, top=6): """Fit pos ~ phase + spacing * k for unknown integers k. Two traps, both hit for real: * spacing -> 0 fits ANY set of positions: every value lands within tolerance of some multiple of a tiny spacing, so maximising inlier count collapses to a degenerate near-zero spacing. The guard is OCCUPANCY -- the fraction of integer slots between the extreme indices that are actually populated. A true grid fills nearly all of them. * fitting over every position lets ONE misdetected line (a film-strip edge, a dotted front line) drag spacing and phase, producing a grid visibly off by a line. So refit on inliers only. Returns up to `top` candidates as (n_inliers, spacing, phase, occupancy), for the caller to choose between jointly across both axes. """ pos = np.sort(np.asarray(pos, np.float64)) if len(pos) < 3: return [] diffs = np.diff(pos) diffs = diffs[diffs > 1e-9] if len(diffs) == 0: return [] cands = {float(np.median(diffs))} for d in diffs: for div in (1, 2, 3): cands.add(d / div) out = [] for s0 in sorted(cands): s, phase = s0, pos[0] if s <= 1e-9: continue for _ in range(3): k = np.round((pos - phase) / s) sol, *_ = np.linalg.lstsq(np.column_stack([np.ones(len(pos)), k]), pos, rcond=None) phase, s = float(sol[0]), float(sol[1]) if s <= 1e-9: break if s <= 1e-9: continue inl = np.abs(pos - (phase + np.round((pos - phase) / s) * s)) < 0.2 * s if inl.sum() < 3: continue for _ in range(3): # refit on inliers only kk = np.round((pos[inl] - phase) / s) if len(np.unique(kk)) < 2: break sol, *_ = np.linalg.lstsq(np.column_stack([np.ones(int(inl.sum())), kk]), pos[inl], rcond=None) p2, s2 = float(sol[0]), float(sol[1]) if s2 <= 1e-9: break phase, s = p2, s2 nxt = np.abs(pos - (phase + np.round((pos - phase) / s) * s)) < 0.2 * s if nxt.sum() < 3 or np.array_equal(nxt, inl): break inl = nxt k = np.round((pos - phase) / s) inl = np.abs(pos - (phase + k * s)) < 0.2 * s if inl.sum() < 3: continue ks = np.unique(k[inl]) slots = ks.max() - ks.min() + 1 occ = len(ks) / slots if slots > 0 else 0.0 if occ < min_occupancy: continue out.append((int(inl.sum()), float(s), float(phase), float(occ))) out.sort(key=lambda r: (-r[0], -r[1])) keep = [] for r in out: if all(abs(r[1] - k[1]) > 0.03 * max(r[1], k[1]) for k in keep): keep.append(r) if len(keep) >= top: break return keep def _cluster(vals, tol): vals = np.sort(np.asarray(vals, np.float64)) out, grp = [], [vals[0]] for v in vals[1:]: if v - grp[-1] <= tol: grp.append(v) else: out.append(float(np.mean(grp))) grp = [v] out.append(float(np.mean(grp))) return np.array(out) def cell_steps(H, shape): """Pixel length of a one-index step along each lattice axis, measured at the CENTRE of the frame -- not at index (0,0), which is usually far off-screen and, under perspective, a wildly different scale.""" h, w = shape[:2] c = np.linalg.inv(H) @ np.array([w / 2.0, h / 2.0, 1.0]) if abs(c[2]) < 1e-12: return None ci, cj = c[0] / c[2], c[1] / c[2] q = H @ np.array([[ci, ci + 1, ci], [cj, cj, cj + 1], [1, 1, 1.0]]) if np.any(np.abs(q[2]) < 1e-12): return None q = q[:2] / q[2] return (float(np.linalg.norm(q[:, 1] - q[:, 0])), float(np.linalg.norm(q[:, 2] - q[:, 0]))) def lattice_candidates(img, L, inA, inB, top=6): """Candidate homographies mapping lattice index -> image pixels. Includes BOTH axis assignments: the two line families are unordered, and getting this wrong yields the true cell transposed. The two axes are also chosen jointly, not independently: cells are square on the table and the camera is near overhead, so a reconstructed cell must come out roughly rectangular on screen. Choosing per-axis lets one axis lock to the 1 km grid while the other locks to the 100 m subgrid, giving a geometrically impossible 10:1 cell. """ vpA, vpB = _fit_vp(L, inA), _fit_vp(L, inB) out = [] for Hr in rectify_candidates(vpA, vpB, L, inA, inB, img.shape): posA, posB = [], [] for sub, axis, acc in ((L[inA], 1, posA), (L[inB], 0, posB)): for (x1, y1, x2, y2) in sub: p = Hr @ np.array([[x1, x2], [y1, y2], [1.0, 1.0]]) if np.any(np.abs(p[2]) < 1e-9): continue p = p[:2] / p[2] acc.append(float(np.mean(p[axis]))) if len(posA) < 3 or len(posB) < 3: continue cA = fit_lattice_1d(_cluster(posA, 0.01 * max(np.ptp(posA), 1e-9))) cB = fit_lattice_1d(_cluster(posB, 0.01 * max(np.ptp(posB), 1e-9))) for nA, sA, pA, _oa in cA: for nB, sB, pB, _ob in cB: K = np.array([[sB, 0, pB], [0, sA, pA], [0, 0, 1.0]]) H = np.linalg.inv(Hr) @ K st = cell_steps(H, img.shape) if st is None or min(st) < 2.0 or not (0.5 <= st[0] / st[1] <= 2.0): continue for swap in (False, True): HH = H @ SWAP if swap else H out.append((HH, nA + nB, cell_steps(HH, img.shape) or st)) out.sort(key=lambda r: -r[1]) return out[:top] # ------------------------------------------------------------- label reading _TEMPLATES: dict = {} def glyph_template(label: str, height: int): """The label as the game draws it: cream glyphs with a heavy dark outline. The outline is what makes correlation discriminative against aerial-photo texture, which has plenty of bright blobs but nothing ringed in near-black.""" key = (label, height) if key in _TEMPLATES: return _TEMPLATES[key] font = ImageFont.truetype(str(FONT_PATH), int(height)) pad = int(height * 0.6) im = Image.new("L", (int(height * 5) + pad, int(height * 2) + pad), 0) ImageDraw.Draw(im).text((pad // 2, pad // 4), label, font=font, fill=255, stroke_width=max(1, int(height * 0.10)), stroke_fill=0) a = np.array(im) ys, xs = np.nonzero(a > 40) if len(xs) == 0: _TEMPLATES[key] = None return None a = a[max(0, ys.min() - 2):ys.max() + 3, max(0, xs.min() - 2):xs.max() + 3] _TEMPLATES[key] = a return a def rectify_cell(img, H, i, j): """Warp one lattice cell to a canonical square, with a margin. The margin matters: the lattice phase can be off by ~10% of a cell, and an exact-bounds warp would clip a label near the cell edge -- a clipped glyph correlates with nothing. Reading a slightly larger region tolerates that instead of requiring the phase to be perfect. """ m = CELL_MARGIN src = np.array([[i - m, j - m], [i + 1 + m, j - m], [i + 1 + m, j + 1 + m], [i - m, j + 1 + m]], np.float64) q = H @ np.vstack([src.T, np.ones(4)]) if np.any(np.abs(q[2]) < 1e-9): return None n = int(CELL_PX * (1 + 2 * m)) canon = np.array([[0, 0], [n, 0], [n, n], [0, n]], np.float32) M = cv2.getPerspectiveTransform((q[:2] / q[2]).T.astype(np.float32), canon) return cv2.warpPerspective(img, M, (n, n), flags=cv2.INTER_LINEAR) def read_cell_label(cell_gray, glyph_fracs=(0.10, 0.13, 0.17)): """Which label best explains the pixels where a label must be? No detection: the grid fixes the label's position and size, so this correlates every candidate label there and takes the best. Sliding the template over a slightly larger crop absorbs the residual phase error. All four cell orientations are tried, because the lattice axes have arbitrary direction and the label may land in any corner, mirrored. -> (label, score) with score in [-1, 1]; measured, correct reads score 0.73-0.87 and wrong ones 0.40-0.56. """ n = cell_gray.shape[0] best = (None, -1.0) x0 = int(max(0, (CELL_MARGIN - 0.03) * CELL_PX)) x1 = int(min(n, (CELL_MARGIN + 0.50) * CELL_PX)) y0 = int(max(0, (CELL_MARGIN - 0.05) * CELL_PX)) y1 = int(min(n, (CELL_MARGIN + 0.30) * CELL_PX)) for fx, fy in FLIPS: v = cell_gray if fx: v = v[:, ::-1] if fy: v = v[::-1, :] patch = np.ascontiguousarray(v[y0:y1, x0:x1]) if patch.shape[0] < 12 or patch.shape[1] < 12: continue for gf in glyph_fracs: h = max(8, int(gf * CELL_PX)) for lab in LABELS: t = glyph_template(lab, h) 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 = (lab, sc) return best 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).""" h, w = shape[:2] q = np.linalg.inv(H) @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1]], np.float64) ok = np.abs(q[2]) > 1e-9 if ok.sum() < 3: return [] ij = q[:2, ok] / q[2, ok] cells = [] for i in range(int(np.floor(ij[0].min())), int(np.ceil(ij[0].max()))): for j in range(int(np.floor(ij[1].min())), int(np.ceil(ij[1].max()))): c = H @ np.array([i + 0.5, j + 0.5, 1.0]) if abs(c[2]) < 1e-9: continue x, y = c[0] / c[2], c[1] / c[2] if 0 <= x < w and 0 <= y < h: cells.append((float(np.hypot(x - w / 2, y - h / 2)), i, j)) cells.sort() return [(i, j) for _d, i, j in cells[:limit]] # ------------------------------------------------------------------- solving class GridSolution: """A solved grid: lattice homography plus the discrete mapping from lattice index to game cell.""" def __init__(self, H, si, sj, du, dv, votes, reads, steps): self.H, self.si, self.sj = H, si, sj self.du, self.dv = du, dv self.votes, self.reads, self.steps = votes, reads, steps def lattice_to_grid(self): """Affine 3x3 taking lattice coords (u, v) to continuous game grid coords (col, row), col in [0, 20] and row in [1, 11]. The +1 for a negative sign is not cosmetic. With si = -1, increasing u means decreasing col, so the cell that lattice index i opens at u = i is entered from its RIGHT edge, and si*u + du alone runs from col+1 down to col across it -- floor() would name the neighbour for the whole cell. Offsetting by 1 makes the fraction always grow in the direction col/row grow, which is also what the app's Coord means by its sub-cell x/y (see models.Coord.as_fraction), so both signs agree with it. """ return np.array([[self.si, 0.0, self.du + (0 if self.si > 0 else 1)], [0.0, self.sj, self.dv + (0 if self.sj > 0 else 1)], [0.0, 0.0, 1.0]]) def grid_of(self, x, y): """-> continuous (col, row) for a pixel in working-resolution coords.""" q = self.lattice_to_grid() @ np.linalg.inv(self.H) @ np.array([x, y, 1.0]) if abs(q[2]) < 1e-12: return None return float(q[0] / q[2]), float(q[1] / q[2]) def cell_of(self, x, y): """-> ("J8", sub_x, sub_y) for a pixel in working-resolution coords.""" g = self.grid_of(x, y) if g is None: return None colf, rowf = g col, row = int(np.floor(colf)), int(np.floor(rowf)) if not (0 <= col < COLS and 1 <= row <= ROWS): return None return (f"{LARGE_X[col]}{row}", int(np.clip((colf - col) * 10, 0, 9)), int(np.clip((rowf - row) * 10, 0, 9))) def format_coord(cell) -> str: """("K8", 0, 3) -> "K8 0:3", matching how the game writes coordinates.""" if cell is None: return "?" return f"{cell[0]} {cell[1]}:{cell[2]}" def solve(img): """-> (GridSolution, None) or (None, reason).""" L = segments(ridge(img), min_len=0.04 * img.shape[1]) inA, inB = two_families(L) if inA.sum() < 3 or inB.sum() < 3: return None, "too few grid line families" cands = lattice_candidates(img, L, inA, inB) if not cands: return None, "no plausible lattice" best = None for H, _ninl, steps in cands: cells = visible_cells(H, img.shape) reads = [] for (i, j) in cells: cell = rectify_cell(img, H, i, j) if cell is None: continue lab, sc = read_cell_label(cv2.cvtColor(cell, cv2.COLOR_BGR2GRAY)) if lab and sc >= LABEL_ACCEPT: reads.append((i, j, lab, sc)) if len(reads) < MIN_LABEL_VOTES: continue for si in (1, -1): for sj in (1, -1): votes = {} for i, j, lab, sc in reads: key = (LARGE_X.index(lab[0]) - si * i, int(lab[1:]) - sj * j) v = votes.setdefault(key, [0, 0.0]) v[0] += 1 v[1] += sc for (du, dv), (cnt, tot) in votes.items(): if cnt < MIN_LABEL_VOTES: continue # extent prior: every visible cell must be a real map cell inrange = all(0 <= si * i + du < COLS and 1 <= sj * j + dv <= ROWS for (i, j) in cells) score = tot + cnt + (1.5 if inrange else -1.5) if best is None or score > best[0]: best = (score, H, si, sj, du, dv, cnt, len(reads), steps) if best is None: return None, (f"no confident label read " f"(need {MIN_LABEL_VOTES} at >={LABEL_ACCEPT})") _s, H, si, sj, du, dv, cnt, nreads, steps = best return GridSolution(H, si, sj, du, dv, cnt, nreads, steps), None def solution_from_correspondences(pairs): """Build a solution from explicit grid<->pixel correspondences. `pairs` is [((col, row), (x, y)), ...] with at least 4 entries, where (col, row) are CONTINUOUS grid coordinates: col 0..20 increasing with the letters, row 1..11 as the game numbers them. This is the manual override path -- four dragged cell corners plus that cell's label fully determine the homography, so it works even when line detection or label reading fail completely. Four correspondences is the minimum: a homography has 8 degrees of freedom and each point contributes 2 equations. Three points would only fix an affine map, and under real perspective a square's image is a general quadrilateral, so the fourth corner is genuinely not implied by the other three. """ if len(pairs) < 4: raise ValueError("a homography needs at least 4 correspondences") src = np.array([[p[0][0], p[0][1]] for p in pairs], np.float64) dst = np.array([[p[1][0], p[1][1]] for p in pairs], np.float64) H, _ = cv2.findHomography(src.reshape(-1, 1, 2), dst.reshape(-1, 1, 2), 0) if H is None: raise ValueError("degenerate correspondences") # identity discrete mapping: the grid coords were given directly return GridSolution(H, 1, 1, 0, 0, votes=len(pairs), reads=len(pairs), steps=cell_steps(H, (1, 1)) or (1.0, 1.0)) def centre_cell_quad(sol, shape): """The centre-most fully-visible cell, as handles for manual correction. -> (label, [(x, y) x4], [(col, row) x4]) with the two lists in matching order, so a UI can seed four draggable handles from the automatic solution and refit through solution_from_correspondences() as they move. """ h, w = shape[:2] cells = visible_cells(sol.H, shape, limit=1) if not cells: return None i, j = cells[0] corners_ij = [(i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)] q = sol.H @ np.array([[c[0] for c in corners_ij], [c[1] for c in corners_ij], [1, 1, 1, 1.0]]) if np.any(np.abs(q[2]) < 1e-9): return None px = [(float(x), float(y)) for x, y in (q[:2] / q[2]).T] g = sol.lattice_to_grid() @ np.array([[c[0] for c in corners_ij], [c[1] for c in corners_ij], [1, 1, 1, 1.0]]) grid = [(float(a), float(b)) for a, b in (g[:2] / g[2]).T] centre = sol.cell_of(*((np.array(px[0]) + np.array(px[2])) / 2)) return (centre[0] if centre else None), px, grid MAP_KM_W, MAP_KM_H = 20.0, 10.0 def warp_to_map(img, sol, px_per_km=100): """Rectify a screenshot into map space, ready to composite under the app's own grid. Returns (BGRA array, px_per_km). Only the region the screenshot actually covers is opaque; everything else is transparent, so a partial view of the table does not blank out the rest of the map. Warping once into map space -- rather than transforming while drawing -- keeps the renderer simple: Cairo has no projective transform, but once the image is in map space a plain scale and translate places it. """ out_w, out_h = int(MAP_KM_W * px_per_km), int(MAP_KM_H * px_per_km) # Map space is pixels over the whole board: x = col * px_per_km rightward, # y measured DOWN while row counts UP, so row 1 (the game's bottom row) # lands at the bottom edge. Row is 1-based here and 0-based in the app's # Coord.as_fraction, hence the extra +px_per_km. grid_to_map = np.array([[px_per_km, 0.0, 0.0], [0.0, -px_per_km, out_h + px_per_km], [0.0, 0.0, 1.0]]) # sol.H alone only reaches LATTICE coords; the discrete mapping (si, sj, # du, dv) is what pins those to named cells, and leaving it out put the # screenshot in the wrong place for every automatically solved grid. M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H) bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA) bgra[:, :, 3] = 255 return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0)), px_per_km RETRY_WORK_W = 2400 GATE_MIN_FAMILY = 8 def looks_like_map(img) -> bool: """Cheap routing test: is this a map-table screenshot or writer text? Purely a latency optimisation, NOT a correctness gate -- `solve()` is the real decision, and measured over 122 writer screenshots it accepts zero of them. This just avoids paying solve()'s ~10-20s on text pastes. The discriminating feature is the number of lines in the SMALLER line family: measured, map screenshots have >=9 (median 15) while writer screenshots that reach this point have a median of 5 (lower quartile 3). Angular separation and implied cell size do not separate the two at all. Deliberately looser than solve(): a false positive here only costs time, while a false negative would silently route a map to the text pipeline. """ L = segments(ridge(img), min_len=0.04 * img.shape[1]) inA, inB = two_families(L) if min(int(inA.sum()), int(inB.sum())) < GATE_MIN_FAMILY: return False return bool(lattice_candidates(img, L, inA, inB)) def solve_path(path): """Solve a screenshot on disk, retrying at higher working resolution. A very wide screenshot downscaled to WORK_W can leave cells so small that the label is only a handful of pixels across, which no filtering recovers -- measured, one fixture's best label score goes 0.54 -> 0.65 (past the accept threshold) purely from working at 2400px instead of 1500px. The retry only pays that cost when the first pass actually fails. -> (GridSolution, image_used, None) or (None, image_used, reason) """ img = load(path) sol, err = solve(img) if sol is not None: return sol, img, None raw = cv2.imread(str(path), cv2.IMREAD_COLOR) if raw is not None and raw.shape[1] > 1.4 * WORK_W: big = downscale(raw, RETRY_WORK_W) sol2, err2 = solve(big) if sol2 is not None: return sol2, big, None err = err2 or err return None, img, err # ------------------------------------------------------------------- markers # Grey-world illuminant normalisation was tried here and removed. It did cut # one fixture's false positives (33 -> 5) by neutralising red stage lighting, # but it made the worst case worse: cancelling the red cast also restores the # cyan front-line ribbon to full saturation, so the ribbon then fires the # friendly-marker mask (43 -> 50 spurious markers). The false positives are # overlay geometry, not lighting, so they need a shape test, not a colour fix. # ---- unit type classification against the game's own marker icons -------- _ICON_BANK: dict = {} ICON_SIZE = 64 DIAMOND_IOU = 0.64 # blob-vs-ideal-diamond overlap needed to be a marker. # Swept against verified counts: 0.64 keeps every shot # confirmed correct by hand (5/2/2/3 markers) while cutting # ribbon+hatching false positives from 43 to 2 on the worst # fixture. Loosening to 0.50 regains one real marker on one # shot but quadruples the false positives. SYMBOL_KEEP = 0.52 # central fraction of the marker that carries the symbol def _icon_dir(side): base = Path(__file__).resolve().parents[2] / "assets" / "icons" / "targets" return base / ("friendly" if side == "friendly" else "enemy") def icon_bank(side): """Every marker icon as a normalised grayscale patch. The shipped icons are the complete marker -- coloured diamond plus the black inner symbol -- which is exactly what is drawn on the table, so a detected marker can be matched against them directly. They differ ONLY in the inner symbol, so the comparison is effectively on that symbol. """ if side in _ICON_BANK: return _ICON_BANK[side] entries = [] d = _icon_dir(side) for f in sorted(d.glob("*.png")): raw = cv2.imread(str(f), cv2.IMREAD_UNCHANGED) if raw is None: continue if raw.shape[2] == 4: a = raw[:, :, 3:4].astype(np.float32) / 255.0 rgb = raw[:, :, :3].astype(np.float32) raw = (rgb * a + 128.0 * (1 - a)).astype(np.uint8) g = cv2.cvtColor(raw, cv2.COLOR_BGR2GRAY) g = cv2.resize(g, (ICON_SIZE, ICON_SIZE), interpolation=cv2.INTER_AREA) name = f.stem.split("_", 1)[1] if "_" in f.stem else f.stem entries.append((name, _zscore(_inner(g)))) _ICON_BANK[side] = entries return entries def _inner(patch): """The central part of a marker, where the only discriminative content is. Every icon is the SAME diamond and differs only in the small black symbol inside it, so correlating whole markers lets the identical diamond edges dominate the score and swamp the signal -- which is why classification returned near-tied scores (margins under 0.05) and therefore almost always None. """ n = patch.shape[0] k = int(n * (1 - SYMBOL_KEEP) / 2) return patch[k:n - k, k:n - k] def _zscore(patch): p = patch.astype(np.float32) p -= p.mean() sd = float(p.std()) return p / sd if sd > 1e-6 else p def classify_marker(img, box, side, pad=0.22, min_margin=0.08, min_score=0.55): """Which unit icon is this marker? -> (name, score, margin). `name` is None when the best match does not beat the runner-up by `min_margin`; the markers are small on screen and several icons differ only in fine detail, so an unconfident answer must stay unknown rather than become a wrong unit type. """ x, y, w, h = box m = int(pad * max(w, h)) x0, y0 = max(0, x - m), max(0, y - m) x1, y1 = min(img.shape[1], x + w + m), min(img.shape[0], y + h + m) crop = img[y0:y1, x0:x1] if crop.size == 0 or min(crop.shape[:2]) < 8: return None, 0.0, 0.0 g = cv2.resize(cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY), (ICON_SIZE, ICON_SIZE), interpolation=cv2.INTER_AREA) q = _zscore(_inner(g)) scores = [(float((q * t).mean()), name) for name, t in icon_bank(side)] if not scores: return None, 0.0, 0.0 scores.sort(reverse=True) best, second = scores[0], (scores[1] if len(scores) > 1 else (0.0, None)) margin = best[0] - second[0] # An absolute floor as well as a margin. With only the margin, matching # collapsed onto one class ("Ship") for nearly every marker at scores of # 0.29-0.46 -- confidently wrong, which is worse than admitting ignorance, # because a wrong unit type looks like real intel. Markers are only ~30px # across at the working resolution; cropping the symbol from the NATIVE # resolution screenshot is the fix, not a lower threshold. ok = margin >= min_margin and best[0] >= min_score return (best[1] if ok else None), best[0], margin def marker_masks(img): """Hostile (pink/red) and friendly (cyan) marker colours.""" hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) h, s, v = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2] hostile = ((h <= 10) | (h >= 168)) & (s > 55) & (v > 95) friendly = (h >= 82) & (h <= 105) & (s > 55) & (v > 110) k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) return [cv2.morphologyEx(m.astype(np.uint8) * 255, cv2.MORPH_CLOSE, k) for m in (hostile, friendly)] def diamonds(mask, cell_px): """Marker-sized, marker-shaped blobs. The markers scale with the map, so a solved grid tells us how big one must be (~0.14 of a cell). A diamond also fills about half its bounding box, which rejects the long thin territory hatching and front-line ribbons that share the markers' colours. """ want = 0.14 * cell_px lo, hi = 0.55 * want, 1.9 * want n, lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8) out = [] for i in range(1, n): x, y, w, h, a = stats[i] if not (lo <= max(w, h) <= hi) or min(w, h) < 0.4 * lo: continue if not (0.55 <= w / h <= 1.8): continue if not (0.30 <= a / float(w * h) <= 0.85): continue # Actually test for a DIAMOND. A bounding-box fill ratio near 0.5 is # not enough: a chunk of the territory hatching or of a front-line # ribbon hits the same ratio and the same colour, which is where the # tens of spurious markers came from. Compare the blob against an # ideal diamond inscribed in its own bounding box. blob = (lab[y:y + h, x:x + w] == i) ideal = np.zeros((h, w), np.uint8) cv2.fillConvexPoly(ideal, np.array( [[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1) ideal = ideal.astype(bool) union = int(np.logical_or(blob, ideal).sum()) if union == 0: continue if int(np.logical_and(blob, ideal).sum()) / union < DIAMOND_IOU: continue out.append((float(cent[i][0]), float(cent[i][1]), (int(x), int(y), int(w), int(h)))) return out def find_markers(img, sol): """-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box.""" cell = max(sol.steps) found = [] for side, mask in zip(("hostile", "friendly"), marker_masks(img)): for (cx, cy, box) in diamonds(mask, cell): c = sol.cell_of(cx, cy) if c is None: continue unit, score, margin = classify_marker(img, box, side) 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)) return found