"""Independent PNG-only inverse arm for marked articulation search.""" from __future__ import annotations import numpy as np from PIL import Image PALETTE = { "red": np.array((208, 61, 67), dtype=np.int16), "blue": np.array((48, 105, 190), dtype=np.int16), "green": np.array((42, 142, 86), dtype=np.int16), "purple": np.array((137, 72, 170), dtype=np.int16), } GOLD = np.array((242, 184, 54), dtype=np.int16) def _near(array: np.ndarray, color: np.ndarray, tolerance: float = 32.0) -> np.ndarray: delta = array.astype(np.int32) - color.astype(np.int32) return np.sum(delta * delta, axis=2) <= tolerance * tolerance def _neighbors(cell: tuple[int, int]) -> tuple[tuple[int, int], ...]: row, col = cell return ((row - 1, col), (row + 1, col), (row, col - 1), (row, col + 1)) def _component_count(cells: set[tuple[int, int]]) -> int: remaining = set(cells) count = 0 while remaining: count += 1 stack = [remaining.pop()] while stack: current = stack.pop() for neighbor in _neighbors(current): if neighbor in remaining: remaining.remove(neighbor) stack.append(neighbor) return count def _recover_candidate( array: np.ndarray, color_name: str, color_mask: np.ndarray, gold_mask: np.ndarray ) -> tuple[int, str] | None: ys, xs = np.nonzero(color_mask) if len(xs) < 500: return None min_x, max_x = int(xs.min()), int(xs.max()) min_y, max_y = int(ys.min()), int(ys.max()) width, height = max_x - min_x + 1, max_y - min_y + 1 if not (64 <= width <= 72 and 64 <= height <= 72): return None step_x = (width - 12.0) / 4.0 step_y = (height - 12.0) / 4.0 if not (12.5 <= step_x <= 15.0 and 12.5 <= step_y <= 15.0): return None local_gold = gold_mask[ max(0, min_y - 2) : min(array.shape[0], max_y + 3), max(0, min_x - 2) : min(array.shape[1], max_x + 3), ] gy, gx = np.nonzero(local_gold) if len(gx) < 30: return None marker_x = float(gx.mean() + max(0, min_x - 2)) marker_y = float(gy.mean() + max(0, min_y - 2)) combined = color_mask | gold_mask cells: set[tuple[int, int]] = set() for row in range(5): for col in range(5): cx = min_x + 5.5 + col * step_x cy = min_y + 5.5 + row * step_y x0, x1 = int(round(cx - 5)), int(round(cx + 5)) + 1 y0, y1 = int(round(cy - 5)), int(round(cy + 5)) + 1 patch = combined[max(0, y0) : min(array.shape[0], y1), max(0, x0) : min(array.shape[1], x1)] if patch.size and float(patch.mean()) >= 0.52: cells.add((row, col)) marker_col = int(round((marker_x - (min_x + 5.5)) / step_x)) marker_row = int(round((marker_y - (min_y + 5.5)) / step_y)) marker = (marker_row, marker_col) if len(cells) != 13 or marker not in cells or _component_count(cells) != 1: return None score = _component_count(cells - {marker}) return score, color_name def decision_from_image(image: Image.Image) -> str: array = np.asarray(image.convert("RGB"), dtype=np.uint8) if array.ndim != 3 or array.shape[0] < 128 or array.shape[1] < 128: raise ValueError("unexpected raster") gold_mask = _near(array, GOLD) recovered = [] for name, rgb in PALETTE.items(): item = _recover_candidate(array, name, _near(array, rgb), gold_mask) if item is not None: recovered.append(item) winners = [(score, name) for score, name in recovered if score >= 2] if not winners: raise ValueError("no intact marked articulation candidate") winners.sort(reverse=True) if len(winners) > 1 and winners[0][0] == winners[1][0]: raise ValueError("ambiguous marked articulation candidates") runner_score = max((score for score, _ in recovered if (score, _) != winners[0]), default=1) if winners[0][0] - runner_score < 1: raise ValueError("insufficient pixel runner-up margin") return winners[0][1]