"""Independent pixel-only inverse arm for the guitar string count.""" from __future__ import annotations import numpy as np from PIL import Image def _runs(values: np.ndarray) -> list[tuple[int, int]]: padded = np.pad(values.astype(np.int8), (1, 1)) changes = np.diff(padded) starts = np.flatnonzero(changes == 1) stops = np.flatnonzero(changes == -1) return list(zip(starts.tolist(), stops.tolist())) def decision_from_image(image: Image.Image) -> str: rgb = np.asarray(image.convert("RGB"), dtype=np.int16) if rgb.shape[0] < 64 or rgb.shape[1] < 64: raise ValueError("image is too small") # Estimate the paper background only from corner patches, then locate a # broad, bright non-background horizontal band (the visibly drawn bridge). patches = np.concatenate((rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3))) bg = np.median(patches, axis=0) lum = rgb.mean(axis=2) distance = np.sqrt(((rgb - bg) ** 2).sum(axis=2)) chroma = rgb.max(axis=2) - rgb.min(axis=2) bridge_like = (lum > 178) & (distance > 30) & (chroma > 20) row_scores = bridge_like.sum(axis=1) candidate_rows = np.flatnonzero(row_scores >= 50) if candidate_rows.size < 8: raise ValueError("visible bridge band not found") # The bridge forms the lowest coherent run of qualifying rows; tuning pegs # are much narrower and cannot satisfy the row-width criterion. groups = _runs(np.isin(np.arange(rgb.shape[0]), candidate_rows)) groups = [(a, b) for a, b in groups if b - a >= 8] if not groups: raise ValueError("bridge band is not coherent") y0 = (groups[-1][0] + groups[-1][1] - 1) // 2 bright_x = np.flatnonzero(bridge_like[y0]) if bright_x.size < 60: raise ValueError("bridge row is too narrow") xlo, xhi = int(bright_x.min()), int(bright_x.max()) # Take a three-row vote. Each qualifying string is a narrow dark run wholly # inside the recovered bridge extent; body edges and sound hole lie outside. counts = [] for y in range(y0 - 1, y0 + 2): strip = rgb[y, xlo:xhi+1] dark = strip.mean(axis=1) < 105 runs = [(a, b) for a, b in _runs(dark) if 1 <= b-a <= 7 and a > 2 and b < len(dark)-2] counts.append(len(runs)) count = int(np.median(counts)) if count not in {4, 5, 6, 7, 8} or max(counts) != min(counts): raise ValueError(f"ambiguous string runs: {counts}") return str(count)