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:
1 parent
b219013bbf
commit
f098fe14b7
2 files changed
+251
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user