"""Independent image-only inverse arm for the bounded level replay world.""" from __future__ import annotations from collections import deque import numpy as np from PIL import Image CAPACITY = 4 N_OPS = 8 def _components(mask: np.ndarray) -> list[np.ndarray]: """Return 4-connected saturated-pixel components as (x, y) arrays.""" height, width = mask.shape seen = np.zeros_like(mask, dtype=bool) components: list[np.ndarray] = [] for y, x in np.argwhere(mask): if seen[y, x]: continue queue = deque([(int(x), int(y))]) seen[y, x] = True points: list[tuple[int, int]] = [] while queue: px, py = queue.popleft() points.append((px, py)) for nx, ny in ((px - 1, py), (px + 1, py), (px, py - 1), (px, py + 1)): if 0 <= nx < width and 0 <= ny < height and mask[ny, nx] and not seen[ny, nx]: seen[ny, nx] = True queue.append((nx, ny)) if len(points) >= 300: components.append(np.asarray(points, dtype=np.float64)) return components def _measure(image: Image.Image) -> tuple[int, list[str]]: arr = np.asarray(image.convert("RGB"), dtype=np.int16) spread = arr.max(axis=2) - arr.min(axis=2) colorful = (spread >= 55) & (arr.max(axis=2) >= 90) components = _components(colorful) blocks: list[tuple[float, float]] = [] commands: list[tuple[float, str]] = [] for points in components: xs, ys = points[:, 0], points[:, 1] x0, x1 = float(xs.min()), float(xs.max()) y0, y1 = float(ys.min()), float(ys.max()) width, height = x1 - x0 + 1.0, y1 - y0 + 1.0 density = len(points) / (width * height) ratio = width / height if ratio >= 1.6 and density >= 0.75: blocks.append((float(xs.mean()), float(ys.mean()))) continue if not (0.65 <= ratio <= 0.95 and 0.42 <= density <= 0.62): raise ValueError("ambiguous saturated component geometry") center_y = (y0 + y1) / 2.0 offset = float(ys.mean()) - center_y if offset >= 4.0: direction = "up" elif offset <= -4.0: direction = "down" else: raise ValueError("triangle orientation is below the decision margin") commands.append((float(xs.mean()), direction)) if len(blocks) > CAPACITY: raise ValueError("too many filled gauge blocks") if len(commands) != N_OPS: raise ValueError(f"expected {N_OPS} command triangles, found {len(commands)}") commands.sort(key=lambda item: item[0]) if any(commands[i + 1][0] - commands[i][0] < 40 for i in range(N_OPS - 1)): raise ValueError("command order is unresolved") return len(blocks), [direction for _, direction in commands] def decision_from_image(image: Image.Image) -> str: start, ops = _measure(image) level = start for op in ops: level = min(CAPACITY, level + 1) if op == "up" else max(0, level - 1) return str(level)