196 lines
9.0 KiB
Python
196 lines
9.0 KiB
Python
"""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())
|