feat(extract): Beschriftungen suchen, Liste auf Vollständigkeit prüfen, Prüfbilder

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-13 19:38:14 +02:00
1 parent b219013bbf
commit f098fe14b7
2 files changed
+251

No files matched your search

+195
View File
@@ -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())
@@ -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()