"""Independent pixel-only inverse for the half-turn subtraction mosaic. This module deliberately duplicates the public visual specification and glyph templates. It receives one raster and does not import any candidate module. """ from __future__ import annotations import numpy as np from PIL import Image _N = 11 _TEMPLATES = { "fork": {(0, 0), (0, 4), (1, 1), (1, 3), (2, 2), (3, 2), (4, 1), (4, 2), (4, 3)}, "hook": {(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (4, 1), (4, 2), (4, 3), (4, 4)}, "stair": {(0, 0), (1, 0), (1, 1), (2, 1), (2, 2), (3, 2), (3, 3), (4, 3), (4, 4)}, "rake": {(0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (1, 0), (1, 2), (1, 4), (2, 2)}, } def _locate_board(array: np.ndarray) -> tuple[int, int, int]: # Cyan frame: robust channel relation, not an exact renderer RGB constant. cyan = ( (array[:, :, 0] < 45) & (array[:, :, 1] > 115) & (array[:, :, 2] > 135) & (array[:, :, 2] > array[:, :, 1]) ) ys, xs = np.nonzero(cyan) if len(xs) < 500: raise ValueError("cyan registration frame is missing or damaged") # The frame is the large cyan component by span; the small legend outline is # above y=100 and cannot reach the mosaic's lower half. row_counts = cyan.sum(axis=1) # The board's horizontal rails span at least 324 pixels; the compact cyan # legend outline is deliberately shorter than 300 pixels. candidate_rows = np.flatnonzero(row_counts > 300) if len(candidate_rows) < 2: raise ValueError("cannot isolate horizontal frame rails") top_outer = int(candidate_rows.min()) bottom_outer = int(candidate_rows.max()) region = cyan[top_outer : bottom_outer + 1] col_counts = region.sum(axis=0) candidate_cols = np.flatnonzero(col_counts > 250) if len(candidate_cols) < 2: raise ValueError("cannot isolate vertical frame rails") left_outer = int(candidate_cols.min()) right_outer = int(candidate_cols.max()) inner_width = right_outer - left_outer + 1 - 16 inner_height = bottom_outer - top_outer + 1 - 16 if abs(inner_width - inner_height) > 2: raise ValueError("registration frame is not square") cell_size = int(round(inner_width / _N)) if cell_size < 24 or cell_size > 36 or abs(inner_width - _N * cell_size) > 1: raise ValueError("registration frame does not contain an 11-by-11 lattice") return left_outer + 8, top_outer + 8, cell_size def _read_cells(array: np.ndarray, left: int, top: int, cell_size: int) -> set[tuple[int, int]]: active: set[tuple[int, int]] = set() for row in range(_N): for col in range(_N): if row == _N // 2 and col == _N // 2: continue pad = max(6, cell_size // 4) patch = array[ top + row * cell_size + pad : top + (row + 1) * cell_size - pad, left + col * cell_size + pad : left + (col + 1) * cell_size - pad, ] if patch.size == 0: raise ValueError("cell sampling patch is empty") luminance = 0.2126 * patch[:, :, 0] + 0.7152 * patch[:, :, 1] + 0.0722 * patch[:, :, 2] dark_fraction = float((luminance < 145.0).mean()) if dark_fraction > 0.78: active.add((row, col)) elif dark_fraction > 0.12: raise ValueError("a lattice cell is visually ambiguous") if not 30 <= len(active) <= 52: raise ValueError(f"implausible dark-cell count: {len(active)}") return active def _classify(result: set[tuple[int, int]]) -> str: ranked: list[tuple[int, str]] = [] for label, template in _TEMPLATES.items(): best = 10_000 for row0 in range(_N - 4): for col0 in range(_N - 4): placed = {(row0 + row, col0 + col) for row, col in template} best = min(best, len(result.symmetric_difference(placed))) ranked.append((best, label)) ranked.sort() if ranked[0][0] > 4: raise ValueError("subtracted evidence is too corrupted for any glyph") if ranked[1][0] - ranked[0][0] < 4: raise ValueError("subtracted glyph has insufficient runner-up margin") return ranked[0][1] def decision_from_image(image: Image.Image) -> str: """Recover the declared answer from raster pixels alone.""" array = np.asarray(image.convert("RGB"), dtype=np.int16) if array.ndim != 3 or array.shape[0] < 400 or array.shape[1] < 400: raise ValueError("image is too small") left, top, cell_size = _locate_board(array) active = _read_cells(array, left, top, cell_size) rotated = {(_N - 1 - row, _N - 1 - col) for row, col in active} result = active - rotated if not 7 <= len(result) <= 11: raise ValueError("directed subtraction has an implausible evidence count") return _classify(result)