"""Beschriftungen der Vorlage finden, auf Vollständigkeit prüfen und begutachten. cd tools/extract && uv run python find_annotations.py --propose # Listenentwurf + Kontaktbogen cd tools/extract && uv run python find_annotations.py --check # Status 1, wenn eine Fundstelle fehlt cd tools/extract && uv run python find_annotations.py --review # Prüfbilder je Eintrag Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md, Abschnitt „Werkzeug find_annotations.py“. Die Suche nutzt die Farbregeln der Pipeline, für Marker zusätzlich einen Kontrast von mindestens MARKER_CONTRAST: der hellblaue Küstenverlauf und die grünen Uferstreifen erfüllen die reine Farbregel, sind aber weich verlaufen; Markerschrift hat harte Kanten. """ from __future__ import annotations import argparse import sys from dataclasses import dataclass from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont from scipy import ndimage as ndi from shapely.geometry import Point, box from skimage.color import rgb2lab from leonida.annotations import annotation_mask, candidates, load_annotations from leonida.coords import SLICE, chunk_for_world, chunk_name from leonida.tiles import load_mosaic ANNOTATIONS = Path("data/annotations.toml") MAP = Path("../../public/data/vice-a/map.png") DEBUG = Path("debug/annotations") SCAN_KINDS = ("text_white", "text_red", "marker") SCAN_MIN_PX = {"text_white": 40, "text_red": 40, "marker": 25} SCAN_GROW = 8 # Pixel: benachbarte Treffer einer Art gehören zu einer Fundstelle SCAN_TILE = 500 # Pixel: Kachelgröße der Suche, damit der Speicher klein bleibt MARKER_CONTRAST = 12.0 # Spannweite der CIELAB-Helligkeit im 3x3-Umfeld PROPOSE_PAD = 10 # m Luft um vorgeschlagene Rahmen REVIEW_PAD = 20 # m Rand um die Prüfbilder REVIEW_SCALE = 2 @dataclass(frozen=True) class Spot: kind: str rows: tuple[int, int] # halboffen, Pixel der Vorlage cols: tuple[int, int] @property def centre(self) -> tuple[float, float]: """Weltkoordinate der Rahmenmitte; Pixelmitte (r, c) = Welt (x0 + c, y1 − r).""" return (SLICE["x0"] + (self.cols[0] + self.cols[1] - 1) / 2, SLICE["y1"] - (self.rows[0] + self.rows[1] - 1) / 2) @property def chunk(self) -> str: return chunk_name(*chunk_for_world(*self.centre)) or "?" def scan_mask(rgb: np.ndarray, kind: str) -> np.ndarray: """Suchpixel einer Art auf der ganzen Vorlage, kachelweise.""" h, w = rgb.shape[:2] out = np.zeros((h, w), dtype=bool) for r in range(0, h, SCAN_TILE): for c in range(0, w, SCAN_TILE): tile = rgb[r:r + SCAN_TILE, c:c + SCAN_TILE] hit = candidates(tile, kind) if kind == "marker": light = rgb2lab(tile.astype(np.float32) / 255.0)[..., 0] hit &= (ndi.maximum_filter(light, 3) - ndi.minimum_filter(light, 3)) >= MARKER_CONTRAST out[r:r + SCAN_TILE, c:c + SCAN_TILE] = hit return out def scan(rgb: np.ndarray) -> list[Spot]: spots = [] for kind in SCAN_KINDS: hit = scan_mask(rgb, kind) groups, _ = ndi.label(ndi.binary_dilation(hit, iterations=SCAN_GROW)) for index, sl in enumerate(ndi.find_objects(groups), start=1): inside = hit[sl] & (groups[sl] == index) if inside.sum() < SCAN_MIN_PX[kind]: continue rows, cols = np.nonzero(inside) spots.append(Spot(kind, (sl[0].start + int(rows.min()), sl[0].start + int(rows.max()) + 1), (sl[1].start + int(cols.min()), sl[1].start + int(cols.max()) + 1))) return sorted(spots, key=lambda s: (s.rows[0] // 250, s.cols[0], s.kind)) def uncovered(spots: list[Spot], annotations) -> list[Spot]: """Fundstellen, deren Mitte in keinem annotation- und keinem keep-Rahmen liegt.""" areas = [a.outline for a in annotations.annotations] + [box(*k.box) for k in annotations.keep] return [s for s in spots if not any(area.covers(Point(*s.centre)) for area in areas)] def propose_toml(spots: list[Spot]) -> str: lines = ["# Vorschlag aus find_annotations.py --propose. Namen und Arten von Hand prüfen,", "# dann nach data/annotations.toml übernehmen.", ""] for n, s in enumerate(spots, start=1): x0 = max(SLICE["x0"], SLICE["x0"] + s.cols[0] - PROPOSE_PAD) x1 = min(SLICE["x1"], SLICE["x0"] + s.cols[1] - 1 + PROPOSE_PAD) y0 = max(SLICE["y0"], SLICE["y1"] - (s.rows[1] - 1) - PROPOSE_PAD) y1 = min(SLICE["y1"], SLICE["y1"] - s.rows[0] + PROPOSE_PAD) lines += ["[[annotation]]", f'name = "#{n} {s.chunk}"', f'kind = "{s.kind}"', f"box = [[{x0}, {y1}], [{x1}, {y0}]]", ""] return "\n".join(lines) def contact_sheet(rgb: np.ndarray, spots: list[Spot], labels: list[str]) -> Image.Image: tiles = [] font = ImageFont.load_default() for spot, label in zip(spots, labels): r0, r1 = max(0, spot.rows[0] - 14), min(rgb.shape[0], spot.rows[1] + 14) c0, c1 = max(0, spot.cols[0] - 14), min(rgb.shape[1], spot.cols[1] + 14) image = Image.fromarray(rgb[r0:r1, c0:c1]) scale = max(0.5, min(3.0, 360 / image.width, 200 / image.height)) image = image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale)))) tile = Image.new("RGB", (380, 240), (30, 30, 30)) tile.paste(image, ((380 - image.width) // 2, 34 + max(0, (206 - image.height) // 2))) ImageDraw.Draw(tile).text((6, 8), label, fill=(255, 220, 0), font=font) tiles.append(tile) columns = 5 rows = max(1, (len(tiles) + columns - 1) // columns) sheet = Image.new("RGB", (columns * 384, rows * 244)) for k, tile in enumerate(tiles): sheet.paste(tile, ((k % columns) * 384, (k // columns) * 244)) return sheet def review(rgb: np.ndarray, annotations, picture: Image.Image, out_dir: Path) -> list[Path]: """Je Eintrag links die Vorlage mit Maskenumriss, rechts das Ergebnis aus map.png.""" out_dir.mkdir(parents=True, exist_ok=True) written, thumbs = [], [] for a in annotations.annotations: found = annotation_mask(rgb, a) r0, r1 = max(0, found.rows.start - REVIEW_PAD), min(rgb.shape[0], found.rows.stop + REVIEW_PAD) c0, c1 = max(0, found.cols.start - REVIEW_PAD), min(rgb.shape[1], found.cols.stop + REVIEW_PAD) mask = np.zeros((r1 - r0, c1 - c0), dtype=bool) mask[found.rows.start - r0:found.rows.stop - r0, found.cols.start - c0:found.cols.stop - c0] = found.mask left = rgb[r0:r1, c0:c1].copy() left[mask & ~ndi.binary_erosion(mask)] = (255, 0, 255) size = ((c1 - c0) * REVIEW_SCALE, (r1 - r0) * REVIEW_SCALE) left_image = Image.fromarray(left).resize(size, Image.NEAREST) right_image = picture.crop((c0, r0, c1, r1)).resize(size, Image.NEAREST) page = Image.new("RGB", (size[0] * 2 + 10, size[1] + 24), (20, 20, 20)) page.paste(left_image, (0, 24)) page.paste(right_image, (size[0] + 10, 24)) status = "" if found.applied else " — übersprungen" ImageDraw.Draw(page).text((6, 6), f"#{a.index} {a.name} · {a.chunk} · {a.kind}{status}", fill=(255, 220, 0)) slug = "".join(ch if ch.isalnum() else "-" for ch in a.name)[:40] path = out_dir / f"{a.index:03d}-{slug}.png" page.save(path) written.append(path) thumbs.append(page) if thumbs: width = 900 scaled = [t.resize((width, max(1, int(t.height * width / t.width)))) for t in thumbs] sheet = Image.new("RGB", (width, sum(t.height + 6 for t in scaled)), (0, 0, 0)) y = 0 for t in scaled: sheet.paste(t, (0, y)) y += t.height + 6 sheet.save(out_dir / "uebersicht.png") return written def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--propose", action="store_true") mode.add_argument("--check", action="store_true") mode.add_argument("--review", action="store_true") args = parser.parse_args(argv) rgb = load_mosaic(Path("cache")) if args.propose: spots = scan(rgb) DEBUG.mkdir(parents=True, exist_ok=True) (DEBUG / "vorschlag.toml").write_text(propose_toml(spots)) contact_sheet(rgb, spots, [f"#{n} {s.chunk} {s.kind}" for n, s in enumerate(spots, 1)]).save(DEBUG / "vorschlag.png") print(f"{len(spots)} Fundstellen -> {DEBUG / 'vorschlag.toml'}, {DEBUG / 'vorschlag.png'}") return 0 annotations = load_annotations(ANNOTATIONS) if args.check: missing = uncovered(scan(rgb), annotations) for s in missing: x, y = s.centre print(f"fehlt: {s.kind} · {s.chunk} · x {x:.0f} / y {y:.0f}") print(f"{len(missing)} Fundstellen ohne Eintrag") return 1 if missing else 0 written = review(rgb, annotations, Image.open(MAP).convert("RGB"), DEBUG) print(f"{len(written)} Prüfbilder -> {DEBUG}") return 0 if __name__ == "__main__": sys.exit(main())