From f098fe14b79d74d06b4dfbd888c726c11eccae83 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 19:38:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Beschriftungen=20suchen,=20Lis?= =?UTF-8?q?te=20auf=20Vollst=C3=A4ndigkeit=20pr=C3=BCfen,=20Pr=C3=BCfbilde?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- tools/extract/find_annotations.py | 195 +++++++++++++++++++ tools/extract/tests/test_find_annotations.py | 56 ++++++ 2 files changed, 251 insertions(+) create mode 100644 tools/extract/find_annotations.py create mode 100644 tools/extract/tests/test_find_annotations.py diff --git a/tools/extract/find_annotations.py b/tools/extract/find_annotations.py new file mode 100644 index 0000000..b5a639e --- /dev/null +++ b/tools/extract/find_annotations.py @@ -0,0 +1,195 @@ +"""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()) diff --git a/tools/extract/tests/test_find_annotations.py b/tools/extract/tests/test_find_annotations.py new file mode 100644 index 0000000..1dac504 --- /dev/null +++ b/tools/extract/tests/test_find_annotations.py @@ -0,0 +1,56 @@ +import numpy as np +from PIL import Image +from shapely.geometry import box + +import find_annotations as fa +from leonida.annotations import Annotation, AnnotationList, Keep, parse_annotations + +URBAN, WHITE, MAGENTA = (216, 216, 216), (248, 248, 248), (200, 0, 200) +WATER, COAST = np.array([48, 144, 192]), np.array([0, 200, 240]) + + +def picture(): + img = np.zeros((200, 300, 3), dtype=np.uint8) + img[:] = URBAN + img[40:70, 30:120] = WHITE # Schriftzug + for left in range(40, 100, 12): + img[120:140, left:left + 4] = MAGENTA # Markerschrift, harte Kanten + for offset in range(80): # weicher Küstenverlauf + weight = 1 - abs(offset - 40) / 40 + img[:, 200 + offset] = np.round(WATER * (1 - weight) + COAST * weight).astype(np.uint8) + return img + + +def test_scan_finds_text_and_markers_but_not_a_soft_coast_gradient(): + spots = fa.scan(picture()) + assert sorted(s.kind for s in spots) == ["marker", "text_white"] + assert all(s.cols[1] <= 200 for s in spots) + text = next(s for s in spots if s.kind == "text_white") + assert (text.rows, text.cols) == ((40, 70), (30, 120)) + assert text.chunk == "A1" + + +def test_uncovered_respects_annotation_and_keep_frames(): + spots = fa.scan(picture()) + text = next(s for s in spots if s.kind == "text_white") + marker = next(s for s in spots if s.kind == "marker") + covering = Annotation(1, "Text", "text_white", box(*text.centre, *text.centre).buffer(5).bounds, None) + keep = Keep(1, box(*marker.centre, *marker.centre).buffer(5).bounds, "echt") + assert fa.uncovered(spots, AnnotationList([covering], [keep])) == [] + assert fa.uncovered(spots, AnnotationList([covering])) == [marker] + + +def test_proposal_is_a_valid_list_with_padded_frames(): + spots = fa.scan(picture()) + parsed = parse_annotations(fa.propose_toml(spots)) + assert [a.kind for a in parsed.annotations] == [s.kind for s in spots] + text = next(a for a in parsed.annotations if a.kind == "text_white") + assert text.box == (-980.0, 2421.0, -871.0, 2470.0) # Pixel 30–119 / 40–69 plus 10 m Luft + + +def test_review_writes_one_page_per_entry_and_an_overview(tmp_path): + img = picture() + entry = Annotation(1, "Text", "text_white", (-980.0, 2420.0, -870.0, 2470.0), None) + written = fa.review(img, AnnotationList([entry]), Image.fromarray(img), tmp_path) + assert [p.name for p in written] == ["001-Text.png"] + assert (tmp_path / "uebersicht.png").exists()