diff --git a/docs/superpowers/plans/2026-09-13-beschriftungen.md b/docs/superpowers/plans/2026-09-13-beschriftungen.md new file mode 100644 index 0000000..94742af --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-beschriftungen.md @@ -0,0 +1,3533 @@ +# Kartenbeschriftungen entfernen Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Extraktionspipeline entfernt alle Beschriftungen der Kartenvorlage vor der Vektorisierung und baut darunter Straßen, Flächen und Gebäude wieder auf; die ausgelieferte Welt, Kollision und Minimap werden daraus neu erzeugt. + +**Architecture:** `tools/extract/leonida/annotations.py` liest `data/annotations.toml`, bildet je Eintrag eine Pixelmaske aus der Vorlage und ruft für jede zusammenhängende Maske `tools/extract/leonida/inpaint.py` auf, das nur das Klassenbild ändert — Straßen durch Endenpaare, Strahlen und Ziele, Flächen durch nächste Quelle, Gebäude durch Schließen und Ergänzen. `run.py` hängt den Schritt zwischen `drop_map_labels` und die Vektorisierung, markiert ergänzte Gebäude und erzeugt am Ende die Minimap. `find_annotations.py` sucht Beschriftungen, prüft die Liste auf Vollständigkeit und rendert Prüfbilder. + +**Tech Stack:** Python 3.12 über uv mit numpy, scipy, scikit-image, shapely 2, networkx, Pillow, pytest; `tomllib` aus der Standardbibliothek · three 0.184.0, vitest (nur `src/main.js`, keine neuen Tests) + +**Spec:** `docs/superpowers/specs/2026-09-13-beschriftungen-design.md` (Stand ac2f414, am Prototyp geschärft) + +## Global Constraints + +- **Der Code dieses Plans ist gelaufen.** Er wurde als Wegwerf-Prototyp gegen die echte Vorlage entwickelt und in genau dieser Aufgabenreihenfolge in einer Kopie des Repositorys geprüft: jede Aufgabe scheitert zuerst mit den genannten Fehlern und besteht danach. Code, Tests und Liste 1:1 übernehmen. Weicht ein Ergebnis von den hier genannten Werten ab, anhalten und melden statt nachzujustieren. +- Python-Bibliotheken: numpy, scipy, scikit-image, shapely, networkx, pillow, pytest. TOML über `tomllib`. **Keine neue Abhängigkeit**, auch nicht in JavaScript. +- Koordinaten in `annotations.toml` sind **Welt-x und Welt-y in Metern**, wie die Minimap sie anzeigt. Pixelmitte (Zeile r, Spalte c) der Vorlage entspricht Welt `(x0 + c, y1 − r)` mit `SLICE = {"x0": -1000, "x1": 2000, "y0": -500, "y1": 2500}`. +- Chunk-Namen A1–L12: Spalte aus `i` (West → Ost), Zeile aus `j` (Nord → Süd). K7 = `chunk_10_6`. +- Formfehler brechen mit `AnnotationError` ab — Meldung beginnt mit `Beschriftung N:`, `Ausnahme N:` oder `Gitternetz:`. Inhaltliche Probleme sind Warnungen: `leer`, `randvoll`, `angeschnitten`. +- **Parallele Sitzungen** arbeiten auf demselben Zweig `leonida/vice-slice`. Vor jeder Änderung an einer geteilten Datei (`run.py`, `src/main.js`, Dokumente) die Datei neu lesen. **Niemals** `git add three-player-controller`, **niemals** `git add -A`. Commits nur mit ausdrücklichen Pfaden: `git add ` und `git commit -m "…" -- `. +- Alle Python-Befehle laufen in `tools/extract`: `cd tools/extract && uv run …`. Die Kacheln liegen in `tools/extract/cache` (nicht versioniert, vorhanden); `tools/extract/debug/` ist nicht versioniert. +- Testausgabe muss sauber sein. Python: `cd tools/extract && uv run pytest -q`. JavaScript: `npm test`. + +## Dateien + +| Datei | Aufgabe | Verantwortung | +|---|---|---| +| `tools/extract/leonida/coords.py` | 1 | `chunk_for_world(x, y)` | +| `tools/extract/leonida/annotations.py` | 1, 4 | Liste lesen und prüfen; Masken, Gitternetz, Entfernen, Bericht, Markierung | +| `tools/extract/leonida/inpaint.py` | 2, 3 | Wiederaufbau auf dem Klassenbild: Straßen und Wege, Flächen; Gebäude schließen und ergänzen | +| `tools/extract/run.py` | 5 | Schritt einhängen, Bericht, Markierung, Minimap am Ende | +| `tools/extract/find_annotations.py` | 6 | Suche, `--propose`, `--check`, `--review` | +| `tools/extract/data/annotations.toml` | 7 | Die Liste: 107 Einträge, 10 Ausnahmen, Gitternetz | +| `public/data/vice-a/*` | 7 | Neu erzeugte Chunks, Manifest, Minimap-Dateien | +| `src/main.js` | 7 | Start und Autos auf die wiederaufgebaute Straße am Park | +| `tools/extract/tests/test_coords.py`, `test_annotations_load.py`, `test_inpaint.py`, `test_annotations_remove.py`, `test_find_annotations.py` | 1–6 | Tests | +| `docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md`, `docs/superpowers/plans/2026-09-13-feinkalibrierung.md`, `docs/superpowers/specs/2026-09-13-minimap-design.md`, `docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md` | 8 | Folgen für andere Dokumente | + +--- + +### Task 1: Chunk-Zuordnung in Python und Beschriftungsliste lesen + +**Files:** +- Modify: `tools/extract/leonida/coords.py` (Funktion anfügen) +- Create: `tools/extract/leonida/annotations.py` +- Modify: `tools/extract/tests/test_coords.py` +- Create: `tools/extract/tests/test_annotations_load.py` + +**Interfaces:** +- Consumes: `SLICE`, `CHUNK_SIZE`, `chunk_name` aus `leonida/coords.py` +- Produces: + - `chunk_for_world(x: float, y: float) -> tuple[int, int]` in `leonida/coords.py` + - in `leonida/annotations.py`: `KINDS`, `MIN_BOX_SIDE`, `class AnnotationError(ValueError)`, `@dataclass(frozen=True) Annotation(index: int, name: str, kind: str, box: tuple[float, float, float, float] | None, polygon: tuple[tuple[float, float], ...] | None, reason: str = "")` mit Eigenschaften `outline -> Polygon` und `chunk -> str`, `Keep(index, box, reason)`, `Grid(spacing: float, width: float)`, `AnnotationList(annotations: list[Annotation], keep: list[Keep], grid: Grid | None)`, `load_annotations(path: Path) -> AnnotationList`, `parse_annotations(text: str) -> AnnotationList` + +- [ ] **Step 1: Die fehlschlagenden Tests schreiben** + +In `tools/extract/tests/test_coords.py` die Importzeile ersetzen durch: + +```python +from leonida.coords import SLICE, chunk_for_world, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world +``` + +und am Dateiende anfügen: + +```python +def test_chunk_for_world_matches_javascript(): + # Dieselben Beispiele wie tests/coords.test.js: Ecken des Ausschnitts, der Downtown-Bezugspunkt aus + # data/districts.json in B9 und der höchste Turm, das Southeast Financial Center, in C12. + assert chunk_for_world(-1000, 2500) == (0, 0) + assert chunk_for_world(1999, -499) == (11, 11) + assert chunk_for_world(-511, 254) == (1, 8) + assert chunk_name(*chunk_for_world(-511, 254)) == "B9" + assert chunk_name(*chunk_for_world(-461.31, -460.1)) == "C12" +``` + +`tools/extract/tests/test_annotations_load.py`: + +```python +import pytest + +from leonida.annotations import KINDS, AnnotationError, load_annotations, parse_annotations + + +def test_missing_file_and_comment_only_file_mean_no_entries_and_no_grid(tmp_path): + assert load_annotations(tmp_path / "fehlt.toml").annotations == [] + (tmp_path / "leer.toml").write_text("# nur Kommentare\n") + parsed = load_annotations(tmp_path / "leer.toml") + assert parsed.annotations == [] and parsed.keep == [] and parsed.grid is None + + +def test_all_spec_kinds_are_known(): + assert set(KINDS) == {"text_white", "text_red", "marker", "symbol", "street_name", "region"} + + +def test_entries_parse_with_normalised_boxes_and_chunk_names(): + parsed = parse_annotations(""" +[grid] +spacing = 500 +width = 6 + +[[annotation]] +name = "VICE BEACH (MIAMI BEACH)" +kind = "text_white" +box = [[1900, 705], [1350, 870]] + +[[annotation]] +name = "Rest am Marker S2/32" +kind = "region" +polygon = [[1905, 640], [1925, 640], [1925, 615], [1905, 615]] +reason = "vom Nutzer markiert" + +[[keep]] +box = [[-856, 194], [-794, -1]] +reason = "knallrotes Gebäude, echt" +""") + first, second = parsed.annotations + assert (first.index, first.kind, first.box) == (1, "text_white", (1350.0, 705.0, 1900.0, 870.0)) + assert first.chunk == "K7" + assert second.polygon[0] == (1905.0, 640.0) and second.box is None and second.reason == "vom Nutzer markiert" + assert second.chunk == "L8" + assert parsed.keep[0].box == (-856.0, -1.0, -794.0, 194.0) + assert (parsed.grid.spacing, parsed.grid.width) == (500.0, 6.0) + + +@pytest.mark.parametrize("body, message", [ + ('name = "X"\nkind = "banner"\nbox = [[0, 0], [10, 10]]', "unbekanntes kind 'banner'"), + ('kind = "marker"\nbox = [[0, 0], [10, 10]]', "Feld 'name' fehlt oder ist leer"), + ('name = "X"\nkind = "marker"\nbox = [[0, 0], [10, 10]]\nhight = 3', "Feld 'hight' gibt es nicht"), + ('name = "X"\nkind = "marker"', "Feld 'box' fehlt"), + ('name = "X"\nkind = "marker"\nbox = [1, 2]', "Feld 'box' muss zwei Ecken"), + ('name = "X"\nkind = "marker"\nbox = [[0, 0], [1, 5]]', "mindestens 2 m"), + ('name = "X"\nkind = "marker"\nbox = [[1900, 0], [2100, 50]]', "außerhalb des Ausschnitts"), + ('name = "X"\nkind = "marker"\npolygon = [[0, 0], [10, 0], [10, 10]]', "Feld 'polygon' gibt es nur bei region"), + ('name = "X"\nkind = "region"\nbox = [[0, 0], [10, 10]]\npolygon = [[0, 0], [10, 0], [10, 10]]', "genau eines der Felder"), + ('name = "X"\nkind = "region"\npolygon = [[0, 0], [10, 10], [10, 0], [0, 10]]', "ohne sich schneidende Kanten"), + ('name = "X"\nkind = "region"\npolygon = [[0, 0], [10, 10]]', "mindestens 3 Punkte"), + ('name = "X"\nkind = "marker"\nbox = [[0, 0], [10, 10]]\nreason = 7', "reason muss Text sein"), +]) +def test_form_errors_stop_with_entry_number_and_field(body, message): + text = ('[[annotation]]\nname = "OK"\nkind = "marker"\nbox = [[0, 0], [10, 10]]\n\n' + f'[[annotation]]\n{body}\n') + with pytest.raises(AnnotationError, match="Beschriftung 2") as err: + parse_annotations(text) + assert message in str(err.value) + + +def test_keep_needs_a_reason(): + with pytest.raises(AnnotationError, match="Ausnahme 1: Feld 'reason' fehlt oder ist leer"): + parse_annotations("[[keep]]\nbox = [[0, 0], [10, 10]]\n") + + +@pytest.mark.parametrize("body, message", [ + ("spacing = 0\nwidth = 6", "Gitternetz: Feld 'spacing' muss eine Zahl größer als 0 sein"), + ("spacing = 500", "Gitternetz: Feld 'width' fehlt"), + ("spacing = 500\nwidth = 6\ncolour = 1", "Gitternetz: Feld 'colour' gibt es nicht"), +]) +def test_grid_form_errors(body, message): + with pytest.raises(AnnotationError, match=message): + parse_annotations(f"[grid]\n{body}\n") + + +def test_broken_toml_and_unknown_sections_are_form_errors(): + with pytest.raises(AnnotationError, match="kein gültiges TOML"): + parse_annotations("[[annotation]\n") + with pytest.raises(AnnotationError, match="unbekannter Abschnitt 'label'"): + parse_annotations('[[label]]\nname = "X"\n') +``` + +- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_coords.py tests/test_annotations_load.py -q` +Expected: FAIL — zwei Sammelfehler: `ImportError: cannot import name 'chunk_for_world'` und `ModuleNotFoundError: No module named 'leonida.annotations'`. + +- [ ] **Step 3: `chunk_for_world` an `tools/extract/leonida/coords.py` anfügen** + +```python +def chunk_for_world(x: float, y: float) -> tuple[int, int]: + """Spiegel von chunkForWorld in src/world/coords.js: i nach Osten, j von der Nordkante nach Süden.""" + return int((x - SLICE["x0"]) // CHUNK_SIZE), int((SLICE["y1"] - y) // CHUNK_SIZE) +``` + +- [ ] **Step 4: `tools/extract/leonida/annotations.py` anlegen** + +```python +"""Kartenbeschriftungen der Vorlage entfernen, bevor die Pipeline Geometrie daraus macht. + +Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md +Koordinaten in annotations.toml sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt. + +Wie in der Feinkalibrierung: ein Formfehler in der Datei bricht mit AnnotationError ab, damit kein +Eintrag still verschwindet; ein formal richtiger Eintrag, der inhaltlich nichts trifft, wird +gemeldet und übersprungen. +""" +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +from shapely.geometry import Polygon, box + +from .coords import SLICE, chunk_for_world, chunk_name + +KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region") +MIN_BOX_SIDE = 2.0 # m + + +class AnnotationError(ValueError): + """Formfehler in annotations.toml: der Lauf bricht ab.""" + + +@dataclass(frozen=True) +class Annotation: + index: int # 1-basiert, wie in allen Meldungen + name: str + kind: str + box: tuple[float, float, float, float] | None # x_min, y_min, x_max, y_max + polygon: tuple[tuple[float, float], ...] | None + reason: str = "" + + @property + def outline(self) -> Polygon: + return Polygon(self.polygon) if self.polygon else box(*self.box) + + @property + def chunk(self) -> str: + centre = self.outline.centroid + return chunk_name(*chunk_for_world(centre.x, centre.y)) or "?" + + +@dataclass(frozen=True) +class Keep: + index: int + box: tuple[float, float, float, float] + reason: str + + +@dataclass(frozen=True) +class Grid: + spacing: float + width: float + + +@dataclass(frozen=True) +class AnnotationList: + annotations: list[Annotation] = field(default_factory=list) + keep: list[Keep] = field(default_factory=list) + grid: Grid | None = None + + +# --- Liste lesen und prüfen ------------------------------------------------------------------- + +def load_annotations(path: Path) -> AnnotationList: + """Liste aus der Datei; fehlt sie, gibt es keine Einträge und kein Gitternetz.""" + if not path.exists(): + return AnnotationList() + return parse_annotations(path.read_text()) + + +def parse_annotations(text: str) -> AnnotationList: + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as err: + raise AnnotationError(f"annotations.toml ist kein gültiges TOML: {err}") from err + unknown = sorted(set(data) - {"grid", "annotation", "keep"}) + if unknown: + raise AnnotationError(f"annotations.toml: unbekannter Abschnitt {unknown[0]!r}, " + "erwartet [grid], [[annotation]] oder [[keep]]") + for section in ("annotation", "keep"): + if not isinstance(data.get(section, []), list): + raise AnnotationError(f"annotations.toml: {section} muss als [[{section}]] geschrieben sein") + return AnnotationList( + [_parse_annotation(i, entry) for i, entry in enumerate(data.get("annotation", []), start=1)], + [_parse_keep(i, entry) for i, entry in enumerate(data.get("keep", []), start=1)], + _parse_grid(data.get("grid")), + ) + + +def _number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _point(value) -> bool: + return isinstance(value, list) and len(value) == 2 and all(_number(c) for c in value) + + +def _inside_slice(x_min, y_min, x_max, y_max) -> bool: + return x_min >= SLICE["x0"] and x_max <= SLICE["x1"] and y_min >= SLICE["y0"] and y_max <= SLICE["y1"] + + +def _parse_box(value, fail): + if not (isinstance(value, list) and len(value) == 2 and all(_point(p) for p in value)): + fail(f"Feld 'box' muss zwei Ecken [[x, y], [x, y]] enthalten, ist {value!r}") + (xa, ya), (xb, yb) = value + x_min, x_max = sorted((float(xa), float(xb))) + y_min, y_max = sorted((float(ya), float(yb))) + if x_max - x_min < MIN_BOX_SIDE or y_max - y_min < MIN_BOX_SIDE: + fail(f"Feld 'box' muss auf beiden Achsen mindestens {MIN_BOX_SIDE:g} m messen, ist {value!r}") + if not _inside_slice(x_min, y_min, x_max, y_max): + fail(f"Feld 'box' liegt außerhalb des Ausschnitts, ist {value!r}") + return x_min, y_min, x_max, y_max + + +def _parse_polygon(value, fail): + if not (isinstance(value, list) and len(value) >= 3 and all(_point(p) for p in value)): + fail(f"Feld 'polygon' muss mindestens 3 Punkte [x, y] enthalten, ist {value!r}") + shape = Polygon(value) + if not shape.is_valid or shape.area <= 0: + fail(f"Feld 'polygon' muss ein Vieleck ohne sich schneidende Kanten sein, ist {value!r}") + if not _inside_slice(*shape.bounds): + fail(f"Feld 'polygon' liegt außerhalb des Ausschnitts, ist {value!r}") + return tuple((float(x), float(y)) for x, y in value) + + +def _parse_annotation(index: int, entry) -> Annotation: + def fail(message: str): + raise AnnotationError(f"Beschriftung {index}: {message}") + + if not isinstance(entry, dict): + fail("ist keine Tabelle") + for key in entry: + if key not in ("name", "kind", "box", "polygon", "reason"): + fail(f"Feld {key!r} gibt es nicht") + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + fail("Feld 'name' fehlt oder ist leer") + kind = entry.get("kind") + if kind not in KINDS: + fail(f"unbekanntes kind {kind!r}, erlaubt sind {', '.join(KINDS)}") + reason = entry.get("reason", "") + if not isinstance(reason, str): + fail("reason muss Text sein") + if kind == "region": + if ("box" in entry) == ("polygon" in entry): + fail("region braucht genau eines der Felder 'box' und 'polygon'") + elif "polygon" in entry: + fail(f"Feld 'polygon' gibt es nur bei region, nicht bei {kind}") + elif "box" not in entry: + fail("Feld 'box' fehlt") + area = _parse_box(entry["box"], fail) if "box" in entry else None + outline = _parse_polygon(entry["polygon"], fail) if "polygon" in entry else None + return Annotation(index, name, kind, area, outline, reason) + + +def _parse_keep(index: int, entry) -> Keep: + def fail(message: str): + raise AnnotationError(f"Ausnahme {index}: {message}") + + if not isinstance(entry, dict): + fail("ist keine Tabelle") + for key in entry: + if key not in ("box", "reason"): + fail(f"Feld {key!r} gibt es nicht") + if "box" not in entry: + fail("Feld 'box' fehlt") + reason = entry.get("reason") + if not isinstance(reason, str) or not reason.strip(): + fail("Feld 'reason' fehlt oder ist leer") + return Keep(index, _parse_box(entry["box"], fail), reason) + + +def _parse_grid(value) -> Grid | None: + if value is None: + return None + + def fail(message: str): + raise AnnotationError(f"Gitternetz: {message}") + + if not isinstance(value, dict): + fail("muss als [grid] geschrieben sein") + for key in value: + if key not in ("spacing", "width"): + fail(f"Feld {key!r} gibt es nicht") + for key in ("spacing", "width"): + if key not in value: + fail(f"Feld {key!r} fehlt") + if not (_number(value[key]) and value[key] > 0): + fail(f"Feld {key!r} muss eine Zahl größer als 0 sein, ist {value[key]!r}") + return Grid(float(value["spacing"]), float(value["width"])) +``` + +- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen; dann die Suite** + +Run: `cd tools/extract && uv run pytest tests/test_coords.py tests/test_annotations_load.py -q && uv run pytest -q` +Expected: 29 passed, danach 77 passed, Ausgabe sauber. + +- [ ] **Step 6: Commit** + +```bash +git add tools/extract/leonida/coords.py tools/extract/leonida/annotations.py tools/extract/tests/test_coords.py tools/extract/tests/test_annotations_load.py +git commit -m "feat(extract): Chunk-Zuordnung in Python und Beschriftungsliste lesen + +Co-Authored-By: Claude Opus 5 " -- tools/extract/leonida/coords.py tools/extract/leonida/annotations.py tools/extract/tests/test_coords.py tools/extract/tests/test_annotations_load.py +``` + +--- + +### Task 2: Wiederaufbau — Straßen, Wege und Flächen + +**Files:** +- Create: `tools/extract/leonida/inpaint.py` +- Create: `tools/extract/tests/test_inpaint.py` + +**Interfaces:** +- Consumes: `CLASSES` aus `leonida/classify.py`, `_skeleton_graph(skeleton: np.ndarray) -> nx.Graph` aus `leonida/vectors.py` +- Produces in `leonida/inpaint.py`: + - `@dataclass RebuildStats(connected: int = 0, extended: int = 0, dead_ends: int = 0, closed: int = 0, added: int = 0, note: str = "")` + - `@dataclass RebuildResult(stats: RebuildStats, buildings: list[list[tuple[float, float]]] = [])` — Ecken ergänzter Gebäude als (Zeile, Spalte), ab Task 3 gefüllt + - `fill_nearest(labels: np.ndarray, target: np.ndarray, blocked: np.ndarray) -> None` — ändert `labels` an Ort und Stelle + - `is_thin(mask: np.ndarray) -> bool` + - `rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult` — ändert `labels` an Ort und Stelle; in dieser Aufgabe Straßen, Wege und Flächen + +- [ ] **Step 1: Die fehlschlagenden Tests schreiben** + +`tools/extract/tests/test_inpaint.py`: + +```python +import numpy as np +from scipy import ndimage as ndi + +from leonida.classify import CLASSES +from leonida.inpaint import fill_nearest, is_thin, rebuild + +STADT, PARK, WATER = CLASSES["stadt"], CLASSES["gruen"], CLASSES["wasser"] +ROAD, PATH, BUILDING = CLASSES["strasse"], CLASSES["weg"], CLASSES["gebaeude"] + + +def city(h=200, w=300): + return np.full((h, w), STADT, dtype=np.uint8) + + +def box_mask(shape, rows, cols): + mask = np.zeros(shape, dtype=bool) + mask[rows, cols] = True + return mask + + +def scribble(labels, mask): + """Schrift simulieren: unter der Maske steht Unsinn, der nichts mit der Karte zu tun hat.""" + labels[mask] = BUILDING + labels[mask & (np.indices(labels.shape).sum(axis=0) % 3 == 0)] = ROAD + + +def test_fill_nearest_keeps_a_road_running_along_a_grid_line(): + labels = city(60, 100) + labels[:, 40:60] = ROAD + band = box_mask(labels.shape, slice(None), slice(47, 53)) + labels[band] = BUILDING + fill_nearest(labels, band, band) + assert (labels[:, 40:60] == ROAD).all() + + +def test_fill_nearest_closes_a_grid_slit_in_a_park(): + labels = np.full((60, 100), PARK, dtype=np.uint8) + band = box_mask(labels.shape, slice(28, 34), slice(None)) + labels[band] = ROAD + fill_nearest(labels, band, band) + assert (labels == PARK).all() + + +def test_thin_means_at_most_four_metres_thick(): + assert is_thin(box_mask((40, 40), slice(10, 14), slice(5, 35))) + assert not is_thin(box_mask((40, 40), slice(10, 15), slice(5, 35))) + + +def test_straight_road_runs_through_a_text_band_in_the_same_width(): + labels = city() + labels[:, 140:160] = ROAD + mask = box_mask(labels.shape, slice(80, 120), slice(60, 240)) + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.connected == 1 + assert (labels[80:120, 141:159] == ROAD).all() + assert not (labels[80:120, 60:138] == ROAD).any() + assert not (labels[80:120, 162:240] == ROAD).any() + + +def test_a_slight_offset_is_bridged_smoothly(): + labels = city() + labels[:100, 130:150] = ROAD + labels[100:, 138:158] = ROAD + mask = box_mask(labels.shape, slice(80, 120), slice(60, 240)) + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.connected == 1 + roads, count = ndi.label(labels == ROAD) + assert count == 1 # oben und unten hängen jetzt zusammen + assert 16 <= (labels[100] == ROAD).sum() <= 24 # in der Mitte etwa straßenbreit + + +def test_roads_across_visible_ground_are_not_joined(): + labels = city() + labels[90:110, :60] = ROAD + labels[90:110, 240:] = ROAD + mask = box_mask(labels.shape, slice(70, 130), slice(60, 140)) | box_mask(labels.shape, slice(70, 130), slice(180, 240)) + mask |= box_mask(labels.shape, slice(70, 75), slice(140, 180)) # schmale Brücke: eine Maske + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.connected == 0 + assert result.stats.dead_ends == 2 + assert (labels[90:110, 141:179] == STADT).all() # sichtbarer Boden dazwischen bleibt + assert not (labels[mask] == ROAD).any() # Sackgassen werden nicht gemalt + + +def test_a_corner_under_the_mask_has_no_overhang(): + labels = city() + labels[:80, 100:120] = ROAD + labels[130:150, 181:] = ROAD + mask = box_mask(labels.shape, slice(80, 180), slice(60, 181)) + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.extended == 2 + assert (labels[80:130, 102:118] == ROAD).all() + assert (labels[132:148, 122:181] == ROAD).all() + assert not (labels[153:180, 100:120] == ROAD).any() + assert not (labels[130:150, 60:97] == ROAD).any() + + +def test_a_side_street_joins_a_hidden_through_road(): + labels = city() + labels[:80, 140:160] = ROAD + labels[130:150, :] = ROAD + mask = box_mask(labels.shape, slice(80, 180), slice(60, 240)) + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.connected == 1 and result.stats.extended == 1 + assert (labels[80:131, 142:158] == ROAD).all() + assert not (labels[153:180, 140:160] == ROAD).any() + + +def test_a_road_clipped_on_one_side_keeps_its_full_width(): + labels = city() + labels[:, 140:160] = ROAD + mask = box_mask(labels.shape, slice(80, 120), slice(150, 240)) + scribble(labels, mask) + rebuild(labels, mask) + assert (labels[80:120, 140:159] == ROAD).all() + assert not (labels[80:120, 162:240] == ROAD).any() + + +def test_a_path_stays_a_path_and_the_street_wins_the_crossing(): + labels = city() + labels[:, 145:150] = PATH + labels[95:115, :] = ROAD + mask = box_mask(labels.shape, slice(60, 140), slice(100, 200)) + scribble(labels, mask) + rebuild(labels, mask) + assert (labels[97:113, 145:150] == ROAD).all() + assert (labels[62:90, 146:149] == PATH).all() + assert (labels[120:138, 146:149] == PATH).all() + + +def test_park_and_water_under_text_stay_what_they_are(): + labels = city() + labels[:, :150] = PARK + labels[:, 250:] = WATER + mask = box_mask(labels.shape, slice(80, 120), slice(100, 280)) + scribble(labels, mask) + rebuild(labels, mask) + assert (labels[80:120, 100:149] == PARK).all() + assert (labels[80:120, 151:249] == STADT).all() + assert (labels[80:120, 251:280] == WATER).all() + + +def test_edge_crumbs_are_no_fill_source(): + labels = np.full((200, 300), PARK, dtype=np.uint8) + labels[74:78, 150:154] = CLASSES["sand"] # 16 m² Kantenglättung + mask = box_mask(labels.shape, slice(80, 120), slice(100, 200)) + scribble(labels, mask) + rebuild(labels, mask) + assert (labels[mask] == PARK).all() + + +def test_a_road_running_off_the_map_under_text_is_kept_to_the_edge(): + labels = city() + labels[:, 140:160] = ROAD + mask = box_mask(labels.shape, slice(0, 30), slice(60, 240)) + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.extended == 1 + assert (labels[0:30, 142:158] == ROAD).all() +``` + +- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_inpaint.py -q` +Expected: FAIL — Sammelfehler `ModuleNotFoundError: No module named 'leonida.inpaint'`. + +- [ ] **Step 3: `tools/extract/leonida/inpaint.py` anlegen** + +```python +"""Wiederaufbau des Klassenbilds unter entfernten Kartenbeschriftungen. + +Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md, Abschnitt „Wiederaufbau“. +Alle Funktionen arbeiten auf dem Klassenbild aus classify() (1 px = 1 m) und verändern es an Ort +und Stelle. Pixelkoordinaten sind (Zeile, Spalte), Zeilen wachsen nach Süden. Weltkoordinaten +kennt dieses Modul nicht; die Umrechnung macht annotations.py. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +import numpy as np +from scipy import ndimage as ndi +from skimage.draw import disk +from skimage.morphology import skeletonize + +from .classify import CLASSES +from .vectors import _skeleton_graph + +ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt +AREA_CLASSES = ("wasser", "land", "gruen", "sand", "stadt") +WINDOW_MARGIN = 60 # m um das Hüllrechteck der Maske +THIN_DEPTH = 2.0 # m: bis zu diesem Abstand vom Maskenrand gilt eine Maske als dünn +TRUST = 10.0 # m: Mindestabstand vertrauter Mittellinienpunkte zur Maske +DIRECTION_LENGTH = 15.0 # m Mittellinie für die Richtung eines Straßenendes +WIDTH_LENGTH = 5.0 # m Mittellinie für die Halbbreite — weiter hinten liegt oft schon die Kreuzung +MIN_TRUSTED = 5.0 # m: kürzere vertraute Stücke ergeben kein Straßenende +REACH = 20.0 # m: so weit darf die Maske vor einem Straßenende liegen +CROSS_THROUGH = 60.0 # m: größter Abstand zwischen zwei Anschlüssen, über die ein Strahl weiterläuft +EDGE_MARGIN = 8 # m: so nah am Rand des Ausschnitts ist das Skelett unzuverlässig +MAX_ANGLE = 20.0 # Grad zwischen Richtung eines Endes und der Verbindungslinie +WIDTH_RATIO = 1.6 # Halbbreiten eines Paares höchstens um diesen Faktor verschieden +CORRIDOR_SHARE = 0.9 # Anteil der Kurvenpunkte, die auf Maske oder sichtbarer Straße liegen müssen +TARGET_MIN_INSIDE = 3 # Kurvenpunkte in der Maske, ab denen ein Ziel auf der anderen Seite liegt +SOURCE_MIN_AREA = 50 # m²: kleinere Flächenstücke sind Kantenglättung und taugen nicht als Füllquelle + + +@dataclass +class RebuildStats: + connected: int = 0 # Paare von Straßenenden und Enden mit Ziel auf der anderen Seite + extended: int = 0 # geradeaus weitergeführte Enden, die anschließen + dead_ends: int = 0 # Enden ohne jeden Anschluss; sie werden nicht gemalt + closed: int = 0 # geschlossene Gebäude + added: int = 0 # ergänzte Gebäude + note: str = "" + + +@dataclass +class RebuildResult: + stats: RebuildStats + buildings: list[list[tuple[float, float]]] = field(default_factory=list) # Ecken (Zeile, Spalte) + + +@dataclass(frozen=True) +class RoadEnd: + row: float + col: float + dr: float # Einheitsvektor in Richtung Maske + dc: float + half: float # Halbbreite in m + + +def fill_nearest(labels: np.ndarray, target: np.ndarray, blocked: np.ndarray) -> None: + """Jedes Pixel in target übernimmt die Klasse des nächsten Pixels außerhalb von blocked.""" + if not target.any() or blocked.all(): + return + index = ndi.distance_transform_edt(blocked, return_distances=False, return_indices=True) + rows, cols = np.nonzero(target) + labels[rows, cols] = labels[index[0][rows, cols], index[1][rows, cols]] + + +def is_thin(mask: np.ndarray) -> bool: + """Dünn: kein Maskenpixel liegt weiter als THIN_DEPTH vom Maskenrand entfernt.""" + return float(ndi.distance_transform_edt(np.pad(mask, 1)).max()) <= THIN_DEPTH + + +def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult: + """Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf. + + blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne + Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, dann Flächen. + """ + blocked = mask if blocked is None else blocked + stats = RebuildStats() + win = _window(mask, WINDOW_MARGIN) + L, M, B = labels[win], mask[win], blocked[win] + edges = (win[0].start == 0, win[0].stop == labels.shape[0], win[1].start == 0, win[1].stop == labels.shape[1]) + painted = np.zeros(M.shape, dtype=bool) + for road, points, halves in _plan_roads(L, M, B, edges, stats): + _stamp(L, M, painted, road, points, halves) + _fill_areas(L, M & ~painted, B) + return RebuildResult(stats) + + +# --- Straßen und Wege ----------------------------------------------------------------------- + +def _plan_roads(L, M, B, edges, stats): + """Plant alle Striche, höherrangige Klassen zuerst, damit niedrigere an ihnen anschließen. + + Gemalt wird danach in ROAD_ORDER, damit an Kreuzungen die Straße gewinnt. + """ + dist_mask = ndi.distance_transform_edt(~M) + any_road = np.isin(L, [CLASSES[name] for name in ROAD_ORDER]) & ~B + centre = np.zeros(M.shape, dtype=bool) + strokes = {name: [] for name in ROAD_ORDER} + for name in reversed(ROAD_ORDER): + road = CLASSES[name] + visible = (L == road) & ~B + if not visible.any(): + continue + # Die Maske zählt beim Skelettieren als Straße: sonst schrumpft die Mittellinie einer + # abgeschnittenen Straße um ihre Halbbreite und erreicht die 10-m-Zone nie. + skeleton = skeletonize(visible | M) & ~M + graph = _skeleton_graph(skeleton) + width = ndi.distance_transform_edt(visible) + ends = [e for e in _road_ends(graph, dist_mask, width) if not _near_edge(e, M.shape, edges)] + pairs, single = _pair_ends(ends, M | any_road) + for a, b in pairs: + points = _hermite(a, b) + strokes[name].append((road, points, np.linspace(a.half, b.half, len(points)))) + _mark_centre(centre, points) + stats.connected += 1 + zone = np.argwhere(skeleton & visible & (dist_mask < TRUST)) + for end, length, joins in _cast_rays(L, B, dist_mask, road, single, centre, edges): + if joins: + count = max(2, int(math.ceil(length * 2)) + 1) + steps = np.linspace(0.0, length, count) + points = list(zip(end.row + end.dr * steps, end.col + end.dc * steps)) + stats.extended += 1 + else: + points = _to_target(end, zone, M, M | any_road) + if points is None: + stats.dead_ends += 1 # Sackgassen werden nicht gemalt + continue + stats.connected += 1 + strokes[name].append((road, points, [end.half] * len(points))) + _mark_centre(centre, points) + return [stroke for name in ROAD_ORDER for stroke in strokes[name]] + + +def _to_target(end: RoadEnd, zone, M, passable): + """Kurve zur sichtbaren Straße derselben Klasse auf der anderen Seite der Maske, sonst None.""" + best = None + for r, c in zone: + vr, vc = r - end.row, c - end.col + if math.hypot(vr, vc) == 0: + continue + angle = _angle((end.dr, end.dc), (vr, vc)) + if angle > MAX_ANGLE or (best is not None and angle >= best[0]): + continue + norm = math.hypot(vr, vc) + target = RoadEnd(float(r), float(c), -vr / norm, -vc / norm, end.half) + points = _hermite(end, target) + inside = sum(1 for pr, pc in points if M[int(round(pr)), int(round(pc))]) + if inside >= TARGET_MIN_INSIDE and _corridor(passable, points): + best = (angle, points) + return None if best is None else best[1] + + +def _road_ends(graph, dist_mask, width) -> list[RoadEnd]: + ends = [] + for node in sorted(graph.nodes): + if dist_mask[node] < TRUST: + continue + inward = [n for n in graph.neighbors(node) if dist_mask[n] < TRUST] + if not inward: + continue + trace = _trace(graph, node, inward, dist_mask) + if _length(trace) < MIN_TRUSTED: + continue + dr, dc = node[0] - trace[-1][0], node[1] - trace[-1][1] + norm = math.hypot(dr, dc) + near = [p for k, p in enumerate(trace) if _length(trace[:k + 1]) <= WIDTH_LENGTH] + half = float(np.median([width[p] for p in near])) + end = RoadEnd(float(node[0]), float(node[1]), dr / norm, dc / norm, half) + if not _reaches(dist_mask, end): + continue + if not any(math.hypot(e.row - end.row, e.col - end.col) <= 3 and e.dr * end.dr + e.dc * end.dc > 0.9 + for e in ends): + ends.append(end) + return ends + + +def _trace(graph, start, inward, dist_mask): + """Mittellinie ab start von der Maske weg, an Verzweigungen möglichst geradeaus.""" + path = [start] + seen = {start, *inward} + heading = (start[0] - sum(n[0] for n in inward) / len(inward), + start[1] - sum(n[1] for n in inward) / len(inward)) + while _length(path) < DIRECTION_LENGTH: + current = path[-1] + options = [n for n in graph.neighbors(current) if n not in seen and dist_mask[n] >= TRUST] + if not options: + break + if len(path) >= 3: + ref = path[max(0, len(path) - 6)] + heading = (current[0] - ref[0], current[1] - ref[1]) + nxt = max(options, key=lambda n: _cos((n[0] - current[0], n[1] - current[1]), heading)) + path.append(nxt) + seen.add(nxt) + return path + + +def _reaches(dist_mask, end: RoadEnd) -> bool: + """Liegt innerhalb von REACH vor dem Ende Maske höchstens eine Halbbreite neben dem Strahl?""" + h, w = dist_mask.shape + for s in np.arange(0.5, REACH + 0.25, 0.5): + r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s)) + if not (0 <= r < h and 0 <= c < w): + return False + if dist_mask[r, c] <= end.half: + return True + return False + + +def _pair_ends(ends, passable): + """Paare aufsteigend nach Winkelsumme; die Kurve muss über Maske oder sichtbarer Straße laufen.""" + candidates = [] + for i, a in enumerate(ends): + for j in range(i + 1, len(ends)): + b = ends[j] + vr, vc = b.row - a.row, b.col - a.col + if math.hypot(vr, vc) == 0: + continue + angle_a = _angle((a.dr, a.dc), (vr, vc)) + angle_b = _angle((b.dr, b.dc), (-vr, -vc)) + if angle_a > MAX_ANGLE or angle_b > MAX_ANGLE: + continue + if max(a.half, b.half) > WIDTH_RATIO * max(min(a.half, b.half), 0.5): + continue + candidates.append((angle_a + angle_b, i, j)) + used, pairs = set(), [] + for _, i, j in sorted(candidates): + if i in used or j in used or not _corridor(passable, _hermite(ends[i], ends[j])): + continue + used.update((i, j)) + pairs.append((ends[i], ends[j])) + return pairs, [e for k, e in enumerate(ends) if k not in used] + + +def _corridor(passable, points) -> bool: + h, w = passable.shape + hits = [0 <= int(round(r)) < h and 0 <= int(round(c)) < w and passable[int(round(r)), int(round(c))] + for r, c in points] + return sum(hits) >= CORRIDOR_SHARE * len(hits) + + +def _hermite(a: RoadEnd, b: RoadEnd): + dist = math.hypot(b.row - a.row, b.col - a.col) + t = np.linspace(0.0, 1.0, max(3, 2 * int(math.ceil(dist)) + 1)) + h00, h10 = 2 * t**3 - 3 * t**2 + 1, t**3 - 2 * t**2 + t + h01, h11 = -2 * t**3 + 3 * t**2, t**3 - t**2 + rows = h00 * a.row + h10 * a.dr * dist + h01 * b.row - h11 * b.dr * dist + cols = h00 * a.col + h10 * a.dc * dist + h01 * b.col - h11 * b.dc * dist + return list(zip(rows, cols)) + + +def _near_edge(end: RoadEnd, shape, edges) -> bool: + """Liegt das Ende näher als EDGE_MARGIN an einer Fensterkante, die zugleich Rand des Ausschnitts ist?""" + top, bottom, left, right = edges + h, w = shape + return ((top and end.row < EDGE_MARGIN) or (bottom and end.row > h - 1 - EDGE_MARGIN) + or (left and end.col < EDGE_MARGIN) or (right and end.col > w - 1 - EDGE_MARGIN)) + + +def _cast_rays(L, B, dist_mask, road, ends, centre, edges): + """Weiterführen: (Ende, Länge, schließt an) je Ende ohne Partner. + + „In der Maske“ heißt: Maske höchstens eine Halbbreite neben der Mittellinie, damit auch eine + seitlich angeschnittene Straße weiterläuft. Ein Anschluss ist eine gemalte Mittellinie in der + Maske (Kreuzung) oder sichtbare Straße der eigenen Klasse oder strasse höchstens eine Halbbreite + neben dem Strahl. Der Strahl läuft von Anschluss zu Anschluss weiter, solange der nächste + höchstens CROSS_THROUGH entfernt ist, und endet am letzten — im Straßenraster geht eine Straße + an einer Kreuzung meist weiter. Erreicht er in der Maske den Rand des Ausschnitts, schließt er + dort an: die Straße läuft über die Karte hinaus. Verlässt er die Maske auf anderem Boden, ist + Schluss; ohne jeden Anschluss ist das Ende eine Sackgasse. + """ + top, bottom, left, right = edges + h, w = dist_mask.shape + limit = math.hypot(h, w) + joinable = ((L == road) | (L == CLASSES["strasse"])) & ~B # Buchstabenkonturen zählen nicht + rays = [] + for end in ends: + reach = int(math.ceil(end.half)) + entered, joins, outside = None, [], 0.0 + s = 0.0 + while s <= limit: + r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s)) + if not (0 <= r < h and 0 <= c < w): + if entered is not None and ((r < 0 and top) or (r >= h and bottom) or (c < 0 and left) or (c >= w and right)): + joins.append(s - 0.5) # Rand des Ausschnitts: die Straße geht weiter + break + inside = dist_mask[r, c] <= end.half + if entered is None: + entered = s if inside else None + elif inside: + outside = 0.0 + if centre[r, c] and dist_mask[r, c] == 0: + joins.append(s) + elif joinable[max(0, r - reach):r + reach + 1, max(0, c - reach):c + reach + 1].any(): + joins.append(s) + outside += 0.5 + if outside > 2 * end.half + 2: + break # fest an sichtbarer Straße angekommen + else: + break # anderer Boden: hier ist Schluss + if joins and s - joins[-1] > CROSS_THROUGH: + break + s += 0.5 + if entered is not None: + rays.append([end, entered, joins[-1] if joins else s, bool(joins)]) + events = [] + for i in range(len(rays)): + for j in range(i + 1, len(rays)): + hit = _crossing(rays[i], rays[j]) + if hit is not None: + events.append((max(hit), i, j, *hit)) + for _, i, j, ti, tj in sorted(events): + if ti <= rays[i][2] and tj <= rays[j][2]: + rays[i][2:] = [ti, True] + rays[j][2:] = [tj, True] + return [(end, stop, joins) for end, _, stop, joins in rays] + + +def _crossing(ray_a, ray_b): + a, a_in, a_stop, _ = ray_a + b, b_in, b_stop, _ = ray_b + denom = a.dr * b.dc - a.dc * b.dr + if abs(denom) < 1e-9: + return None + qr, qc = b.row - a.row, b.col - a.col + ta = (qr * b.dc - qc * b.dr) / denom + tb = (qr * a.dc - qc * a.dr) / denom + if a_in <= ta <= a_stop and b_in <= tb <= b_stop: + return ta, tb + return None + + +def _stamp(L, M, painted, road, points, halves): + strip = np.zeros(M.shape, dtype=bool) + for (r, c), half in zip(points, halves): + rr, cc = disk((r, c), max(float(half), 0.5), shape=M.shape) + strip[rr, cc] = True + strip &= M + L[strip] = road + painted |= strip + + +def _mark_centre(centre, points): + h, w = centre.shape + for r, c in points: + r, c = int(round(r)), int(round(c)) + centre[max(0, r - 1):min(h, r + 2), max(0, c - 1):min(w, c + 2)] = True + + +# --- Flächen ------------------------------------------------------------------------------------- + +def _fill_areas(L, target, B): + """Nächste sichtbare Fläche; Stücke unter SOURCE_MIN_AREA (Kantenglättung) zählen nicht als Quelle.""" + if not target.any(): + return + source = np.zeros(L.shape, dtype=bool) + for name in AREA_CLASSES: + area = (L == CLASSES[name]) & ~B + comps, count = ndi.label(area) + if count: + sizes = ndi.sum(area, comps, np.arange(1, count + 1)) + source |= np.isin(comps, 1 + np.flatnonzero(sizes >= SOURCE_MIN_AREA)) + if not source.any(): + L[target] = CLASSES["stadt"] + return + fill_nearest(L, target, ~source) + + +# --- Hilfen ----------------------------------------------------------------------------------- + +def _window(mask, margin): + rows, cols = np.nonzero(mask) + h, w = mask.shape + return (slice(max(0, int(rows.min()) - margin), min(h, int(rows.max()) + 1 + margin)), + slice(max(0, int(cols.min()) - margin), min(w, int(cols.max()) + 1 + margin))) + + +def _length(path) -> float: + return sum(math.dist(path[i - 1], path[i]) for i in range(1, len(path))) + + +def _cos(a, b) -> float: + na, nb = math.hypot(*a), math.hypot(*b) + return (a[0] * b[0] + a[1] * b[1]) / (na * nb) if na and nb else -1.0 + + +def _angle(a, b) -> float: + return math.degrees(math.acos(max(-1.0, min(1.0, _cos(a, b))))) +``` + +- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen; dann die Suite** + +Run: `cd tools/extract && uv run pytest tests/test_inpaint.py -q && uv run pytest -q` +Expected: 13 passed, danach 90 passed. + +- [ ] **Step 5: Commit** + +```bash +git add tools/extract/leonida/inpaint.py tools/extract/tests/test_inpaint.py +git commit -m "feat(extract): Wiederaufbau von Straßen, Wegen und Flächen unter Beschriftungen + +Co-Authored-By: Claude Opus 5 " -- tools/extract/leonida/inpaint.py tools/extract/tests/test_inpaint.py +``` + +--- + +### Task 3: Wiederaufbau — Gebäude schließen und ergänzen + +**Files:** +- Modify: `tools/extract/leonida/inpaint.py` (vollständig ersetzen; der Diff zeigt nur die Gebäude-Teile) +- Modify: `tools/extract/tests/test_inpaint.py` (Tests anfügen) + +**Interfaces:** +- Consumes: alles aus Task 2 +- Produces: `rebuild(...)` schließt jetzt angeschnittene Gebäude und ergänzt Gebäude; `RebuildResult.buildings` enthält die Ecken jedes ergänzten Rechtecks in Bildkoordinaten (Zeile, Spalte); `RebuildStats.closed`, `.added` und `.note` („zu wenig Nachbarn“) werden gesetzt + +- [ ] **Step 1: Die fehlschlagenden Tests an `tools/extract/tests/test_inpaint.py` anfügen** + +```python +def test_a_bitten_building_is_closed_but_not_extended(): + labels = city() + labels[50:90, 50:110] = BUILDING + mask = box_mask(labels.shape, slice(70, 110), slice(90, 150)) + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.closed == 1 + assert (labels[70:90, 90:110] == BUILDING).all() + assert not (labels[92:110, 90:150] == BUILDING).any() + assert not (labels[70:90, 112:150] == BUILDING).any() + + +def block_city(): + """Ein Block zwischen vier Straßen, links sichtbar bebaut, rechts verdeckt.""" + labels = city(300, 400) + labels[20:40, :] = ROAD + labels[260:280, :] = ROAD + labels[:, 20:40] = ROAD + labels[:, 360:380] = ROAD + for top in (60, 100, 140, 180): + for left in (60, 120): + labels[top:top + 20, left:left + 30] = BUILDING + mask = box_mask(labels.shape, slice(60, 241), slice(220, 341)) + return labels, mask + + +def test_a_hidden_block_part_gets_the_density_of_the_visible_part(): + labels, mask = block_city() + visible_density = 8 * 600 / (220 * 320 - mask.sum()) + scribble(labels, mask) + result = rebuild(labels, mask) + added = (labels[mask] == BUILDING).sum() + assert result.stats.added == len(result.buildings) >= 3 + assert abs(added - visible_density * mask.sum()) <= 600 + 2 * (20 + 30) # ein Vorlage-Rechteck genau + + +def test_added_buildings_keep_setback_gaps_and_stay_in_the_hidden_part(): + labels, mask = block_city() + scribble(labels, mask) + result = rebuild(labels, mask) + new = (labels == BUILDING) & mask + assert not (new & ~mask).any() + road_distance = ndi.distance_transform_edt(~np.isin(labels, [ROAD, PATH])) + assert road_distance[new].min() >= 15 # Straßenabstand der Nachbarn, auf 15 m begrenzt + pieces, count = ndi.label(new) + assert count == len(result.buildings) + for k in range(1, count + 1): + others = new & (pieces != k) + assert ndi.distance_transform_edt(~others)[pieces == k].min() >= 3 + + +def test_no_buildings_are_added_in_a_park(): + labels, mask = block_city() + labels[40:260, 200:360] = PARK + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.added == 0 + + +def test_without_three_neighbours_nothing_is_added(): + labels, mask = block_city() + labels[labels == BUILDING] = STADT + labels[60:80, 60:90] = BUILDING + labels[100:120, 60:90] = BUILDING + scribble(labels, mask) + result = rebuild(labels, mask) + assert result.stats.added == 0 + assert result.stats.note == "zu wenig Nachbarn" + + +def test_rebuild_is_deterministic(): + first, mask = block_city() + second = first.copy() + scribble(first, mask) + scribble(second, mask) + a, b = rebuild(first, mask), rebuild(second, mask) + assert (first == second).all() + assert a.buildings == b.buildings +``` + +- [ ] **Step 2: Tests laufen lassen, vier müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_inpaint.py -q` +Expected: 4 failed, 15 passed — `test_a_bitten_building_is_closed_but_not_extended`, `test_a_hidden_block_part_gets_the_density_of_the_visible_part`, `test_added_buildings_keep_setback_gaps_and_stay_in_the_hidden_part`, `test_without_three_neighbours_nothing_is_added`. + +- [ ] **Step 3: `tools/extract/leonida/inpaint.py` vollständig ersetzen durch** + +```python +"""Wiederaufbau des Klassenbilds unter entfernten Kartenbeschriftungen. + +Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md, Abschnitt „Wiederaufbau“. +Alle Funktionen arbeiten auf dem Klassenbild aus classify() (1 px = 1 m) und verändern es an Ort +und Stelle. Pixelkoordinaten sind (Zeile, Spalte), Zeilen wachsen nach Süden. Weltkoordinaten +kennt dieses Modul nicht; die Umrechnung macht annotations.py. +""" +from __future__ import annotations + +import math +import warnings +from dataclasses import dataclass, field + +import numpy as np +from scipy import ndimage as ndi +from shapely.geometry import MultiPoint, Polygon +from skimage.draw import disk +from skimage.draw import polygon as fill_polygon +from skimage.morphology import skeletonize + +from .classify import CLASSES +from .vectors import _skeleton_graph + +ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt +AREA_CLASSES = ("wasser", "land", "gruen", "sand", "stadt") +WINDOW_MARGIN = 60 # m um das Hüllrechteck der Maske +THIN_DEPTH = 2.0 # m: bis zu diesem Abstand vom Maskenrand gilt eine Maske als dünn +TRUST = 10.0 # m: Mindestabstand vertrauter Mittellinienpunkte zur Maske +DIRECTION_LENGTH = 15.0 # m Mittellinie für die Richtung eines Straßenendes +WIDTH_LENGTH = 5.0 # m Mittellinie für die Halbbreite — weiter hinten liegt oft schon die Kreuzung +MIN_TRUSTED = 5.0 # m: kürzere vertraute Stücke ergeben kein Straßenende +REACH = 20.0 # m: so weit darf die Maske vor einem Straßenende liegen +CROSS_THROUGH = 60.0 # m: größter Abstand zwischen zwei Anschlüssen, über die ein Strahl weiterläuft +EDGE_MARGIN = 8 # m: so nah am Rand des Ausschnitts ist das Skelett unzuverlässig +MAX_ANGLE = 20.0 # Grad zwischen Richtung eines Endes und der Verbindungslinie +WIDTH_RATIO = 1.6 # Halbbreiten eines Paares höchstens um diesen Faktor verschieden +CORRIDOR_SHARE = 0.9 # Anteil der Kurvenpunkte, die auf Maske oder sichtbarer Straße liegen müssen +TARGET_MIN_INSIDE = 3 # Kurvenpunkte in der Maske, ab denen ein Ziel auf der anderen Seite liegt +SOURCE_MIN_AREA = 50 # m²: kleinere Flächenstücke sind Kantenglättung und taugen nicht als Füllquelle +TEMPLATE_MIN_AREA = 100 # m²: Gebäude, die als Vorlage und als Nachbarn zählen +NEIGHBOUR_RADIUS = 150 # m um die Maske für Gebäudevorlage und Nachbarschaftsdichte +MIN_NEIGHBOURS = 3 +SHORT_SIDE = (6.0, 40.0) # m, Grenzen der kurzen Seite ergänzter Gebäude +LONG_SIDE_MAX = 60.0 # m +SETBACK = (2.0, 15.0) # m, Grenzen des Straßenabstands +GAP = 3.0 # m zwischen Gebäuden +VISIBLE_SHARE = 0.25 # sichtbarer Blockteil zählt ab 25 % des Blocks … +VISIBLE_AREA = 400 # … und 400 m² +PIECE_MIN_AREA = 12 # m²: kleinere Gebäudestücke werden nicht geschlossen +CONTACT_MIN = 3 # Pixel direkt an der Maske, ab denen ein Gebäudestück angeschnitten ist +EIGHT = np.ones((3, 3), dtype=bool) + + +@dataclass +class RebuildStats: + connected: int = 0 # Paare von Straßenenden und Enden mit Ziel auf der anderen Seite + extended: int = 0 # geradeaus weitergeführte Enden, die anschließen + dead_ends: int = 0 # Enden ohne jeden Anschluss; sie werden nicht gemalt + closed: int = 0 # geschlossene Gebäude + added: int = 0 # ergänzte Gebäude + note: str = "" + + +@dataclass +class RebuildResult: + stats: RebuildStats + buildings: list[list[tuple[float, float]]] = field(default_factory=list) # Ecken (Zeile, Spalte) + + +@dataclass(frozen=True) +class RoadEnd: + row: float + col: float + dr: float # Einheitsvektor in Richtung Maske + dc: float + half: float # Halbbreite in m + + +def fill_nearest(labels: np.ndarray, target: np.ndarray, blocked: np.ndarray) -> None: + """Jedes Pixel in target übernimmt die Klasse des nächsten Pixels außerhalb von blocked.""" + if not target.any() or blocked.all(): + return + index = ndi.distance_transform_edt(blocked, return_distances=False, return_indices=True) + rows, cols = np.nonzero(target) + labels[rows, cols] = labels[index[0][rows, cols], index[1][rows, cols]] + + +def is_thin(mask: np.ndarray) -> bool: + """Dünn: kein Maskenpixel liegt weiter als THIN_DEPTH vom Maskenrand entfernt.""" + return float(ndi.distance_transform_edt(np.pad(mask, 1)).max()) <= THIN_DEPTH + + +def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult: + """Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf. + + blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne + Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, Flächen, + Gebäude schließen, Gebäude ergänzen. + """ + blocked = mask if blocked is None else blocked + stats = RebuildStats() + win = _window(mask, WINDOW_MARGIN) + L, M, B = labels[win], mask[win], blocked[win] + edges = (win[0].start == 0, win[0].stop == labels.shape[0], win[1].start == 0, win[1].stop == labels.shape[1]) + painted = np.zeros(M.shape, dtype=bool) + for road, points, halves in _plan_roads(L, M, B, edges, stats): + _stamp(L, M, painted, road, points, halves) + _fill_areas(L, M & ~painted, B) + _close_buildings(L, M, B, painted, stats) + corners = _add_buildings(labels, mask, blocked, stats) + return RebuildResult(stats, corners) + + +# --- Straßen und Wege ----------------------------------------------------------------------- + +def _plan_roads(L, M, B, edges, stats): + """Plant alle Striche, höherrangige Klassen zuerst, damit niedrigere an ihnen anschließen. + + Gemalt wird danach in ROAD_ORDER, damit an Kreuzungen die Straße gewinnt. + """ + dist_mask = ndi.distance_transform_edt(~M) + any_road = np.isin(L, [CLASSES[name] for name in ROAD_ORDER]) & ~B + centre = np.zeros(M.shape, dtype=bool) + strokes = {name: [] for name in ROAD_ORDER} + for name in reversed(ROAD_ORDER): + road = CLASSES[name] + visible = (L == road) & ~B + if not visible.any(): + continue + # Die Maske zählt beim Skelettieren als Straße: sonst schrumpft die Mittellinie einer + # abgeschnittenen Straße um ihre Halbbreite und erreicht die 10-m-Zone nie. + skeleton = skeletonize(visible | M) & ~M + graph = _skeleton_graph(skeleton) + width = ndi.distance_transform_edt(visible) + ends = [e for e in _road_ends(graph, dist_mask, width) if not _near_edge(e, M.shape, edges)] + pairs, single = _pair_ends(ends, M | any_road) + for a, b in pairs: + points = _hermite(a, b) + strokes[name].append((road, points, np.linspace(a.half, b.half, len(points)))) + _mark_centre(centre, points) + stats.connected += 1 + zone = np.argwhere(skeleton & visible & (dist_mask < TRUST)) + for end, length, joins in _cast_rays(L, B, dist_mask, road, single, centre, edges): + if joins: + count = max(2, int(math.ceil(length * 2)) + 1) + steps = np.linspace(0.0, length, count) + points = list(zip(end.row + end.dr * steps, end.col + end.dc * steps)) + stats.extended += 1 + else: + points = _to_target(end, zone, M, M | any_road) + if points is None: + stats.dead_ends += 1 # Sackgassen werden nicht gemalt + continue + stats.connected += 1 + strokes[name].append((road, points, [end.half] * len(points))) + _mark_centre(centre, points) + return [stroke for name in ROAD_ORDER for stroke in strokes[name]] + + +def _to_target(end: RoadEnd, zone, M, passable): + """Kurve zur sichtbaren Straße derselben Klasse auf der anderen Seite der Maske, sonst None.""" + best = None + for r, c in zone: + vr, vc = r - end.row, c - end.col + if math.hypot(vr, vc) == 0: + continue + angle = _angle((end.dr, end.dc), (vr, vc)) + if angle > MAX_ANGLE or (best is not None and angle >= best[0]): + continue + norm = math.hypot(vr, vc) + target = RoadEnd(float(r), float(c), -vr / norm, -vc / norm, end.half) + points = _hermite(end, target) + inside = sum(1 for pr, pc in points if M[int(round(pr)), int(round(pc))]) + if inside >= TARGET_MIN_INSIDE and _corridor(passable, points): + best = (angle, points) + return None if best is None else best[1] + + +def _road_ends(graph, dist_mask, width) -> list[RoadEnd]: + ends = [] + for node in sorted(graph.nodes): + if dist_mask[node] < TRUST: + continue + inward = [n for n in graph.neighbors(node) if dist_mask[n] < TRUST] + if not inward: + continue + trace = _trace(graph, node, inward, dist_mask) + if _length(trace) < MIN_TRUSTED: + continue + dr, dc = node[0] - trace[-1][0], node[1] - trace[-1][1] + norm = math.hypot(dr, dc) + near = [p for k, p in enumerate(trace) if _length(trace[:k + 1]) <= WIDTH_LENGTH] + half = float(np.median([width[p] for p in near])) + end = RoadEnd(float(node[0]), float(node[1]), dr / norm, dc / norm, half) + if not _reaches(dist_mask, end): + continue + if not any(math.hypot(e.row - end.row, e.col - end.col) <= 3 and e.dr * end.dr + e.dc * end.dc > 0.9 + for e in ends): + ends.append(end) + return ends + + +def _trace(graph, start, inward, dist_mask): + """Mittellinie ab start von der Maske weg, an Verzweigungen möglichst geradeaus.""" + path = [start] + seen = {start, *inward} + heading = (start[0] - sum(n[0] for n in inward) / len(inward), + start[1] - sum(n[1] for n in inward) / len(inward)) + while _length(path) < DIRECTION_LENGTH: + current = path[-1] + options = [n for n in graph.neighbors(current) if n not in seen and dist_mask[n] >= TRUST] + if not options: + break + if len(path) >= 3: + ref = path[max(0, len(path) - 6)] + heading = (current[0] - ref[0], current[1] - ref[1]) + nxt = max(options, key=lambda n: _cos((n[0] - current[0], n[1] - current[1]), heading)) + path.append(nxt) + seen.add(nxt) + return path + + +def _reaches(dist_mask, end: RoadEnd) -> bool: + """Liegt innerhalb von REACH vor dem Ende Maske höchstens eine Halbbreite neben dem Strahl?""" + h, w = dist_mask.shape + for s in np.arange(0.5, REACH + 0.25, 0.5): + r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s)) + if not (0 <= r < h and 0 <= c < w): + return False + if dist_mask[r, c] <= end.half: + return True + return False + + +def _pair_ends(ends, passable): + """Paare aufsteigend nach Winkelsumme; die Kurve muss über Maske oder sichtbarer Straße laufen.""" + candidates = [] + for i, a in enumerate(ends): + for j in range(i + 1, len(ends)): + b = ends[j] + vr, vc = b.row - a.row, b.col - a.col + if math.hypot(vr, vc) == 0: + continue + angle_a = _angle((a.dr, a.dc), (vr, vc)) + angle_b = _angle((b.dr, b.dc), (-vr, -vc)) + if angle_a > MAX_ANGLE or angle_b > MAX_ANGLE: + continue + if max(a.half, b.half) > WIDTH_RATIO * max(min(a.half, b.half), 0.5): + continue + candidates.append((angle_a + angle_b, i, j)) + used, pairs = set(), [] + for _, i, j in sorted(candidates): + if i in used or j in used or not _corridor(passable, _hermite(ends[i], ends[j])): + continue + used.update((i, j)) + pairs.append((ends[i], ends[j])) + return pairs, [e for k, e in enumerate(ends) if k not in used] + + +def _corridor(passable, points) -> bool: + h, w = passable.shape + hits = [0 <= int(round(r)) < h and 0 <= int(round(c)) < w and passable[int(round(r)), int(round(c))] + for r, c in points] + return sum(hits) >= CORRIDOR_SHARE * len(hits) + + +def _hermite(a: RoadEnd, b: RoadEnd): + dist = math.hypot(b.row - a.row, b.col - a.col) + t = np.linspace(0.0, 1.0, max(3, 2 * int(math.ceil(dist)) + 1)) + h00, h10 = 2 * t**3 - 3 * t**2 + 1, t**3 - 2 * t**2 + t + h01, h11 = -2 * t**3 + 3 * t**2, t**3 - t**2 + rows = h00 * a.row + h10 * a.dr * dist + h01 * b.row - h11 * b.dr * dist + cols = h00 * a.col + h10 * a.dc * dist + h01 * b.col - h11 * b.dc * dist + return list(zip(rows, cols)) + + +def _near_edge(end: RoadEnd, shape, edges) -> bool: + """Liegt das Ende näher als EDGE_MARGIN an einer Fensterkante, die zugleich Rand des Ausschnitts ist?""" + top, bottom, left, right = edges + h, w = shape + return ((top and end.row < EDGE_MARGIN) or (bottom and end.row > h - 1 - EDGE_MARGIN) + or (left and end.col < EDGE_MARGIN) or (right and end.col > w - 1 - EDGE_MARGIN)) + + +def _cast_rays(L, B, dist_mask, road, ends, centre, edges): + """Weiterführen: (Ende, Länge, schließt an) je Ende ohne Partner. + + „In der Maske“ heißt: Maske höchstens eine Halbbreite neben der Mittellinie, damit auch eine + seitlich angeschnittene Straße weiterläuft. Ein Anschluss ist eine gemalte Mittellinie in der + Maske (Kreuzung) oder sichtbare Straße der eigenen Klasse oder strasse höchstens eine Halbbreite + neben dem Strahl. Der Strahl läuft von Anschluss zu Anschluss weiter, solange der nächste + höchstens CROSS_THROUGH entfernt ist, und endet am letzten — im Straßenraster geht eine Straße + an einer Kreuzung meist weiter. Erreicht er in der Maske den Rand des Ausschnitts, schließt er + dort an: die Straße läuft über die Karte hinaus. Verlässt er die Maske auf anderem Boden, ist + Schluss; ohne jeden Anschluss ist das Ende eine Sackgasse. + """ + top, bottom, left, right = edges + h, w = dist_mask.shape + limit = math.hypot(h, w) + joinable = ((L == road) | (L == CLASSES["strasse"])) & ~B # Buchstabenkonturen zählen nicht + rays = [] + for end in ends: + reach = int(math.ceil(end.half)) + entered, joins, outside = None, [], 0.0 + s = 0.0 + while s <= limit: + r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s)) + if not (0 <= r < h and 0 <= c < w): + if entered is not None and ((r < 0 and top) or (r >= h and bottom) or (c < 0 and left) or (c >= w and right)): + joins.append(s - 0.5) # Rand des Ausschnitts: die Straße geht weiter + break + inside = dist_mask[r, c] <= end.half + if entered is None: + entered = s if inside else None + elif inside: + outside = 0.0 + if centre[r, c] and dist_mask[r, c] == 0: + joins.append(s) + elif joinable[max(0, r - reach):r + reach + 1, max(0, c - reach):c + reach + 1].any(): + joins.append(s) + outside += 0.5 + if outside > 2 * end.half + 2: + break # fest an sichtbarer Straße angekommen + else: + break # anderer Boden: hier ist Schluss + if joins and s - joins[-1] > CROSS_THROUGH: + break + s += 0.5 + if entered is not None: + rays.append([end, entered, joins[-1] if joins else s, bool(joins)]) + events = [] + for i in range(len(rays)): + for j in range(i + 1, len(rays)): + hit = _crossing(rays[i], rays[j]) + if hit is not None: + events.append((max(hit), i, j, *hit)) + for _, i, j, ti, tj in sorted(events): + if ti <= rays[i][2] and tj <= rays[j][2]: + rays[i][2:] = [ti, True] + rays[j][2:] = [tj, True] + return [(end, stop, joins) for end, _, stop, joins in rays] + + +def _crossing(ray_a, ray_b): + a, a_in, a_stop, _ = ray_a + b, b_in, b_stop, _ = ray_b + denom = a.dr * b.dc - a.dc * b.dr + if abs(denom) < 1e-9: + return None + qr, qc = b.row - a.row, b.col - a.col + ta = (qr * b.dc - qc * b.dr) / denom + tb = (qr * a.dc - qc * a.dr) / denom + if a_in <= ta <= a_stop and b_in <= tb <= b_stop: + return ta, tb + return None + + +def _stamp(L, M, painted, road, points, halves): + strip = np.zeros(M.shape, dtype=bool) + for (r, c), half in zip(points, halves): + rr, cc = disk((r, c), max(float(half), 0.5), shape=M.shape) + strip[rr, cc] = True + strip &= M + L[strip] = road + painted |= strip + + +def _mark_centre(centre, points): + h, w = centre.shape + for r, c in points: + r, c = int(round(r)), int(round(c)) + centre[max(0, r - 1):min(h, r + 2), max(0, c - 1):min(w, c + 2)] = True + + +# --- Flächen und Gebäude -------------------------------------------------------------------- + +def _fill_areas(L, target, B): + """Nächste sichtbare Fläche; Stücke unter SOURCE_MIN_AREA (Kantenglättung) zählen nicht als Quelle.""" + if not target.any(): + return + source = np.zeros(L.shape, dtype=bool) + for name in AREA_CLASSES: + area = (L == CLASSES[name]) & ~B + comps, count = ndi.label(area) + if count: + sizes = ndi.sum(area, comps, np.arange(1, count + 1)) + source |= np.isin(comps, 1 + np.flatnonzero(sizes >= SOURCE_MIN_AREA)) + if not source.any(): + L[target] = CLASSES["stadt"] + return + fill_nearest(L, target, ~source) + + +def _close_buildings(L, M, B, painted, stats): + building = CLASSES["gebaeude"] + pieces = (L == building) & ~B + comps, count = ndi.label(pieces, structure=EIGHT) + if count == 0: + return + index = np.arange(1, count + 1) + sizes = ndi.sum(pieces, comps, index) + contact = ndi.sum(ndi.binary_dilation(M, structure=EIGHT) & pieces, comps, index) + for k, sl in enumerate(ndi.find_objects(comps), start=1): + if sizes[k - 1] < PIECE_MIN_AREA or contact[k - 1] < CONTACT_MIN: + continue + rows, cols = np.nonzero(comps[sl] == k) + rect = _rectangle(rows + sl[0].start, cols + sl[1].start) + rr, cc = _raster(rect, M.shape) + inside = M[rr, cc] & ~painted[rr, cc] & (L[rr, cc] != building) + if inside.any(): + L[rr[inside], cc[inside]] = building + stats.closed += 1 + + +def _add_buildings(labels, mask, blocked, stats): + """Ergänzt Gebäude im verdeckten Teil der Blöcke; alles im Nachbarschaftsfenster (150 m).""" + building, urban, water = CLASSES["gebaeude"], CLASSES["stadt"], CLASSES["wasser"] + roads = [CLASSES[name] for name in ROAD_ORDER] + hood = _window(mask, NEIGHBOUR_RADIUS + 1) + L, M, B = labels[hood], mask[hood], blocked[hood] + + near = ndi.distance_transform_edt(~M) <= NEIGHBOUR_RADIUS + pieces = (L == building) & ~B + comps, count = ndi.label(pieces, structure=EIGHT) + index = np.arange(1, count + 1) + sizes = ndi.sum(pieces, comps, index) if count else [] + reach = ndi.maximum(near, comps, index) if count else [] + chosen = [k for k in range(1, count + 1) if sizes[k - 1] >= TEMPLATE_MIN_AREA and reach[k - 1]] + if len(chosen) < MIN_NEIGHBOURS: + stats.note = "zu wenig Nachbarn" + return [] + shorts, longs = [], [] + slices = ndi.find_objects(comps) + for k in chosen: + sl = slices[k - 1] + rows, cols = np.nonzero(comps[sl] == k) + short, long_ = _sides(_rectangle(rows + sl[0].start, cols + sl[1].start)) + shorts.append(short) + longs.append(long_) + short = float(np.clip(np.median(shorts), *SHORT_SIDE)) + long_ = float(np.clip(np.median(longs), short, LONG_SIDE_MAX)) + road = np.isin(L, roads) + road_dist = ndi.distance_transform_edt(~road) + neighbours = np.isin(comps, chosen) + outline = neighbours & ~ndi.binary_erosion(neighbours) + setback = float(np.clip(np.median(road_dist[outline]), *SETBACK)) + ground = ~B & near & ~np.isin(L, roads + [water]) + density_hood = float(((L == building) & ~B & near).sum() / max(int(ground.sum()), 1)) + + forbidden = ndi.distance_transform_edt(L != building) < GAP + buildable = (L == urban) & M & (road_dist >= setback) & ~forbidden + blocks, _ = ndi.label(~np.isin(L, roads + [water])) + placed_all = [] + for k, sl in enumerate(ndi.find_objects(blocks), start=1): + block = blocks[sl] == k + hidden = block & M[sl] + if not (hidden & buildable[sl]).any(): + continue + visible = block & ~B[sl] + if visible.sum() >= VISIBLE_SHARE * block.sum() and visible.sum() >= VISIBLE_AREA: + density = ((L[sl] == building) & visible).sum() / visible.sum() + else: + density = density_hood + target = density * hidden.sum() - ((L[sl] == building) & hidden).sum() + if target < 0.5 * short * long_: + continue + rows, cols = np.nonzero(block) + placed = _place_rows(L, buildable, forbidden, rows + sl[0].start, cols + sl[1].start, + road, short, long_, setback, target) + stats.added += len(placed) + placed_all += [[(r + hood[0].start, c + hood[1].start) for r, c in corners] for corners in placed] + return placed_all + + +def _place_rows(L, buildable, forbidden, rows, cols, road, short, long_, setback, target): + """Rechtecke reihenweise von der Straßenseite des Blocks her, bis target erreicht ist.""" + corners = list(_rectangle(rows, cols).exterior.coords)[:4] # (Spalte, Zeile) + p0 = np.array(corners[0]) + e1, e2 = np.array(corners[1]) - p0, np.array(corners[3]) - p0 + if np.hypot(*e1) < np.hypot(*e2): + e1, e2 = e2, e1 + U, V = float(np.hypot(*e1)), float(np.hypot(*e2)) + u, v = e1 / U, e2 / V + origin = p0 + + reach = setback + 2 + rr, cc = np.nonzero(road) + pu = (cc - origin[0]) * u[0] + (rr - origin[1]) * u[1] + pv = (cc - origin[0]) * v[0] + (rr - origin[1]) * v[1] + along = (pu >= 0) & (pu <= U) + near_origin_side = int((along & (pv < 0) & (pv >= -reach)).sum()) + near_far_side = int((along & (pv > V) & (pv <= V + reach)).sum()) + if near_far_side > near_origin_side: + origin, v = origin + e2, -v + + free = buildable[rows, cols] + if not free.any(): + return [] + fu = (cols[free] - origin[0]) * u[0] + (rows[free] - origin[1]) * u[1] + fv = (cols[free] - origin[0]) * v[0] + (rows[free] - origin[1]) * v[1] + u_start, u_end = max(0.0, float(fu.min()) - long_), min(U, float(fu.max())) + v_pos = max(setback, float(fv.min()) - short) + v_end = min(V, float(fv.max())) + + placed, area = [], 0.0 + while v_pos <= v_end and area < target: + u_pos, used = u_start, False + while u_pos <= u_end and area < target: + quad = [origin + u * du + v * dv for du, dv in + ((u_pos, v_pos), (u_pos + long_, v_pos), (u_pos + long_, v_pos + short), (u_pos, v_pos + short))] + polygon = Polygon([tuple(p) for p in quad]) + qr, qc = _raster(polygon, L.shape) + if qr.size and buildable[qr, qc].all() and not forbidden[qr, qc].any(): + L[qr, qc] = CLASSES["gebaeude"] + buildable[qr, qc] = False + gr, gc = _raster(polygon.buffer(GAP, join_style="mitre"), L.shape) + forbidden[gr, gc] = True + placed.append([(float(p[1]), float(p[0])) for p in quad]) + area += short * long_ + used = True + u_pos += long_ + GAP + else: + u_pos += 1.0 + v_pos += short + GAP if used else 1.0 + return placed + + +# --- Hilfen ----------------------------------------------------------------------------------- + +def _window(mask, margin): + rows, cols = np.nonzero(mask) + h, w = mask.shape + return (slice(max(0, int(rows.min()) - margin), min(h, int(rows.max()) + 1 + margin)), + slice(max(0, int(cols.min()) - margin), min(w, int(cols.max()) + 1 + margin))) + + +def _rectangle(rows, cols) -> Polygon: + """Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante.""" + points = MultiPoint(np.column_stack([cols, rows]).astype(float)) + with warnings.catch_warnings(): + # Siehe buildings.regularize(): harmlose GEOS-Warnung bei achsenparallelen Formen. + warnings.filterwarnings("ignore", message=r".*oriented_envelope.*", category=RuntimeWarning) + envelope = points.minimum_rotated_rectangle + return envelope.buffer(0.5, join_style="mitre") + + +def _sides(rect: Polygon) -> tuple[float, float]: + corners = list(rect.exterior.coords)[:4] + edges = sorted(math.dist(corners[i - 1], corners[i]) for i in range(4)) + return edges[0], edges[-1] + + +def _raster(polygon: Polygon, shape): + xs, ys = polygon.exterior.coords.xy + return fill_polygon(np.asarray(ys), np.asarray(xs), shape=shape) + + +def _length(path) -> float: + return sum(math.dist(path[i - 1], path[i]) for i in range(1, len(path))) + + +def _cos(a, b) -> float: + na, nb = math.hypot(*a), math.hypot(*b) + return (a[0] * b[0] + a[1] * b[1]) / (na * nb) if na and nb else -1.0 + + +def _angle(a, b) -> float: + return math.degrees(math.acos(max(-1.0, min(1.0, _cos(a, b))))) +``` + +- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen; dann die Suite** + +Run: `cd tools/extract && uv run pytest tests/test_inpaint.py -q && uv run pytest -q` +Expected: 19 passed, danach 96 passed. + +- [ ] **Step 5: Commit** + +```bash +git add tools/extract/leonida/inpaint.py tools/extract/tests/test_inpaint.py +git commit -m "feat(extract): angeschnittene Gebäude schließen und verdeckte Blockteile bebauen + +Co-Authored-By: Claude Opus 5 " -- tools/extract/leonida/inpaint.py tools/extract/tests/test_inpaint.py +``` + +--- + +### Task 4: Masken, Gitternetz, Entfernen und Markierung + +**Files:** +- Modify: `tools/extract/leonida/annotations.py` (vollständig ersetzen; der Diff zeigt nur die neuen Teile) +- Create: `tools/extract/tests/test_annotations_remove.py` + +**Interfaces:** +- Consumes: alles aus Task 1; `RebuildStats`, `fill_nearest`, `is_thin`, `rebuild` aus `leonida/inpaint.py`; `PALETTE` aus `leonida/classify.py` +- Produces in `leonida/annotations.py`: + - `candidates(rgb: np.ndarray, kind: str) -> np.ndarray` (bool) + - `@dataclass FoundMask(rows: slice, cols: slice, mask: np.ndarray, applied: bool, warning: str | None = None)` + - `annotation_mask(mosaic: np.ndarray, annotation: Annotation) -> FoundMask` + - `grid_mask(shape: tuple[int, int], grid: Grid) -> tuple[np.ndarray, int]` + - `@dataclass EntryReport(annotation, applied, warning=None, mask_area=0, stats=None, shared_with=None)` + - `@dataclass Removal(labels: np.ndarray, generated: list[Polygon], entries: list[EntryReport], grid_lines: int)` mit `report() -> str` + - `remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: AnnotationList) -> Removal` — gibt eine Kopie zurück, `generated` sind Weltpolygone + - `mark_generated(polygons: list[Polygon], heights: list[dict], generated: list[Polygon]) -> int` + +- [ ] **Step 1: Die fehlschlagenden Tests schreiben** + +`tools/extract/tests/test_annotations_remove.py`: + +```python +import numpy as np +from shapely.geometry import box + +from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_mask, candidates, grid_mask, + mark_generated, remove_annotations) +from leonida.classify import CLASSES, classify + +PARK, ROAD, URBAN = (192, 216, 128), (80, 80, 80), (216, 216, 216) +WHITE, OUTLINE = (248, 248, 248), (80, 88, 96) + + +def picture(h=200, w=300, colour=URBAN): + img = np.zeros((h, w, 3), dtype=np.uint8) + img[:] = colour + return img + + +def entry(kind, rows, cols, index=1, name="Test"): + """Eintrag, dessen Rahmen genau die Pixelzeilen rows und -spalten cols umfasst (halboffen).""" + (r0, r1), (c0, c1) = rows, cols + return Annotation(index, name, kind, (-1000.0 + c0, 2500.0 - (r1 - 1), -1000.0 + c1 - 1, 2500.0 - r0), None) + + +def letter(img, rows, cols, outline=3): + (r0, r1), (c0, c1) = rows, cols + img[r0 - outline:r1 + outline, c0 - outline:c1 + outline] = OUTLINE + img[r0:r1, c0:c1] = WHITE + + +def test_candidates_follow_the_colour_rules(): + colours = {"white": WHITE, "urban": URBAN, "red": (248, 112, 112), "speculative": (160, 72, 72), + "magenta": (200, 0, 200), "black": (20, 20, 20), "water": (48, 144, 192), "road": ROAD, + "park": PARK, "light": (230, 230, 230), "building": (176, 176, 176)} + img = np.array([list(colours.values())], dtype=np.uint8) + names = list(colours) + + def hits(kind): + return {name for name, hit in zip(names, candidates(img, kind)[0]) if hit} + + assert hits("text_white") == {"white"} + assert hits("text_red") == {"red"} + assert hits("marker") == {"magenta", "black"} + assert hits("symbol") == {"white", "magenta", "black"} + assert hits("street_name") == {"white", "urban", "light"} + + +def test_white_text_is_masked_together_with_its_outline(): + img = picture(colour=PARK) + img[:, 140:160] = ROAD + letter(img, (80, 120), (120, 180)) + found = annotation_mask(img, entry("text_white", (60, 141), (100, 201))) + mask = np.zeros(img.shape[:2], dtype=bool) + mask[found.rows, found.cols] = found.mask + assert found.applied and found.warning is None + assert mask[77:123, 117:183].all() # Füllung und Kontur + assert not mask[80:120, 107].any() # 10 m neben der Kontur ist wieder Karte + + +def test_text_filling_most_of_its_frame_after_growing_is_not_overfull(): + img = picture() + for left in range(60, 240, 30): + letter(img, (60, 140), (left, left + 12)) # sechs Buchstabenstriche, Rahmen mit 10 m Luft + found = annotation_mask(img, entry("text_white", (47, 154), (47, 244))) + assert found.mask.mean() > 0.6 + assert found.applied and found.warning is None + + +def test_street_names_do_not_warn_about_urban_ground_at_the_frame_edge(): + img = picture() + img[:, 140:160] = ROAD + img[80:120, 146:150] = WHITE # helle Schrift auf der Fahrbahn + found = annotation_mask(img, entry("street_name", (70, 131), (130, 171))) + assert found.applied and found.warning is None + assert found.mask[20, 17] and not found.mask[20, 2] + + +def test_colour_touching_the_frame_edge_stays_and_is_reported(): + img = picture() + letter(img, (80, 120), (60, 90)) + letter(img, (80, 120), (180, 240)) # ragt über den rechten Rahmenrand + found = annotation_mask(img, entry("text_white", (60, 141), (40, 211))) + assert found.applied + assert found.warning == "angeschnitten: Rahmen prüfen" + assert found.mask[30, 35] # Buchstabe innen + assert not found.mask[30, 160] # Buchstabe am Rand bleibt + + +def test_letter_counters_are_filled_but_large_enclosed_areas_stay(): + img = picture(420, 420) + img[20:80, 20:80] = WHITE + img[41:59, 41:59] = URBAN # 18 x 18 m großes „O“-Innere + img[150:290, 150:290] = WHITE + img[195:245, 195:245] = URBAN # 50 x 50 m: echte Fläche + small = annotation_mask(img, entry("text_white", (0, 130), (0, 130))) + large = annotation_mask(img, entry("text_white", (110, 331), (110, 331))) + assert small.applied and large.applied + assert small.mask[50, 50] + assert not large.mask[220 - 110, 220 - 110] + + +def test_region_masks_exactly_its_polygon(): + img = picture() + triangle = ((-950.0, 2450.0), (-850.0, 2450.0), (-950.0, 2350.0)) + found = annotation_mask(img, Annotation(1, "Rest", "region", None, triangle)) + assert found.applied + assert abs(int(found.mask.sum()) - 5000) <= 200 + mask = np.zeros(img.shape[:2], dtype=bool) + mask[found.rows, found.cols] = found.mask + assert mask[60, 60] and not mask[140, 140] + + +def test_empty_and_overfull_frames_are_skipped_with_a_warning(): + park = annotation_mask(picture(colour=PARK), entry("text_white", (50, 100), (50, 100))) + assert not park.applied and park.warning == "leer: keine Pixel der Art text_white im Rahmen" + img = picture() + img[52:98, 52:98] = WHITE + full = annotation_mask(img, entry("text_white", (50, 100), (50, 100))) + assert not full.applied and full.warning.startswith("randvoll: Farbpixel füllen") + + +def test_grid_mask_puts_a_stripe_on_every_line(): + mask, lines = grid_mask((30, 30), Grid(10, 2)) + assert lines == 8 + for index in (0, 9, 10, 19, 20, 29): + assert mask[:, index].all() and mask[index, :].all() + assert not mask[5, 5] and not mask[15, 25] + + +def test_removal_without_entries_changes_nothing(): + labels = classify(picture()) + removal = remove_annotations(picture(), labels, AnnotationList()) + assert (removal.labels == labels).all() and removal.generated == [] + assert removal.report() == "Beschriftungen: 0 angewendet, 0 übersprungen · Gitternetz: 0 Linien" + + +def test_removal_rebuilds_a_road_under_white_text(): + img = picture() + img[:, 140:160] = ROAD + letter(img, (80, 120), (60, 240)) + removal = remove_annotations(img, classify(img), AnnotationList([entry("text_white", (60, 141), (40, 261))])) + assert (removal.labels[80:120, 141:159] == CLASSES["strasse"]).all() + assert not (removal.labels[80:120, 60:137] == CLASSES["strasse"]).any() + report = removal.report() + assert "#1 Test · A1 · Maske" in report and "Straßen 1 verbunden" in report + + +def test_the_grid_is_closed_before_anything_is_rebuilt(): + img = picture() + img[:, 98:102] = (140, 140, 140) # abgedunkelte Gitterlinie über Stadtfläche + labels = classify(img) + assert (labels[:, 99] == CLASSES["gebaeude"]).all() + removal = remove_annotations(img, labels, AnnotationList(grid=Grid(100, 6))) + assert (removal.labels == CLASSES["stadt"]).all() + assert removal.grid_lines == 7 # Spalten 0, 100, 200, 300 und Zeilen 0, 100, 200 + + +def test_entries_sharing_one_mask_are_reported_together(): + img = picture() + img[:, 140:160] = ROAD + letter(img, (80, 120), (60, 150)) + letter(img, (80, 120), (155, 240)) + first = entry("text_white", (60, 141), (40, 153), index=1, name="Links") + second = entry("text_white", (60, 141), (152, 261), index=2, name="Rechts") + report = remove_annotations(img, classify(img), AnnotationList([first, second])).report() + assert "#2 Rechts · A1 · Maske" in report and "gemeinsam mit #1" in report + + +def test_buildings_mostly_inside_added_rectangles_are_marked(): + heights = [{"height": 9.0, "source": "district"}, {"height": 12.0, "source": "district"}] + marked = mark_generated([box(1, 1, 9, 9), box(6, 0, 16, 10)], heights, [box(0, 0, 10, 10)]) + assert marked == 1 + assert [h["source"] for h in heights] == ["annotation", "district"] +``` + +- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_annotations_remove.py -q` +Expected: FAIL — Sammelfehler `ImportError: cannot import name 'annotation_mask' from 'leonida.annotations'`. + +- [ ] **Step 3: `tools/extract/leonida/annotations.py` vollständig ersetzen durch** + +```python +"""Kartenbeschriftungen der Vorlage entfernen, bevor die Pipeline Geometrie daraus macht. + +Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md +Koordinaten in annotations.toml sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt. + +Wie in der Feinkalibrierung: ein Formfehler in der Datei bricht mit AnnotationError ab, damit kein +Eintrag still verschwindet; ein formal richtiger Eintrag, der inhaltlich nichts trifft, wird +gemeldet und übersprungen. +""" +from __future__ import annotations + +import math +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np +from scipy import ndimage as ndi +from shapely.geometry import Polygon, box +from shapely.strtree import STRtree +from skimage.color import rgb2hsv, rgb2lab +from skimage.draw import polygon as fill_polygon +from skimage.morphology import disk + +from .classify import PALETTE +from .coords import SLICE, chunk_for_world, chunk_name +from .inpaint import RebuildStats, fill_nearest, is_thin, rebuild + +KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region") +MARGIN = {"text_white": 7, "text_red": 7, "marker": 3, "symbol": 4, "street_name": 1, "region": 0} +RED = (248, 112, 112) +HOLE_MAX_AREA = 400 # m²: größere eingeschlossene Flächen sind echte Karte +MIN_BOX_SIDE = 2.0 # m +FULL_SHARE = 0.60 # randvoll ab diesem Anteil der Farbpixel (vor dem Wachsen) am Rahmen +CUT_MIN_PX = 20 # angeschnitten ab einem verworfenen Kandidaten dieser Größe; nicht bei street_name, + # dessen Farbregel auch die Stadtfläche neben der Fahrbahn trifft +GENERATED_SHARE = 0.5 # Anteil der Grundfläche in ergänzten Rechtecken für source = "annotation" +THIN_WINDOW = 20 # m Rand um eine dünne Maske beim Auffüllen +GRID_STRIP = 10 # m beiderseits einer Gitterlinie beim Auffüllen +EIGHT = np.ones((3, 3), dtype=bool) + + +class AnnotationError(ValueError): + """Formfehler in annotations.toml: der Lauf bricht ab.""" + + +@dataclass(frozen=True) +class Annotation: + index: int # 1-basiert, wie in allen Meldungen + name: str + kind: str + box: tuple[float, float, float, float] | None # x_min, y_min, x_max, y_max + polygon: tuple[tuple[float, float], ...] | None + reason: str = "" + + @property + def outline(self) -> Polygon: + return Polygon(self.polygon) if self.polygon else box(*self.box) + + @property + def chunk(self) -> str: + centre = self.outline.centroid + return chunk_name(*chunk_for_world(centre.x, centre.y)) or "?" + + +@dataclass(frozen=True) +class Keep: + index: int + box: tuple[float, float, float, float] + reason: str + + +@dataclass(frozen=True) +class Grid: + spacing: float + width: float + + +@dataclass(frozen=True) +class AnnotationList: + annotations: list[Annotation] = field(default_factory=list) + keep: list[Keep] = field(default_factory=list) + grid: Grid | None = None + + +# --- Liste lesen und prüfen ------------------------------------------------------------------- + +def load_annotations(path: Path) -> AnnotationList: + """Liste aus der Datei; fehlt sie, gibt es keine Einträge und kein Gitternetz.""" + if not path.exists(): + return AnnotationList() + return parse_annotations(path.read_text()) + + +def parse_annotations(text: str) -> AnnotationList: + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as err: + raise AnnotationError(f"annotations.toml ist kein gültiges TOML: {err}") from err + unknown = sorted(set(data) - {"grid", "annotation", "keep"}) + if unknown: + raise AnnotationError(f"annotations.toml: unbekannter Abschnitt {unknown[0]!r}, " + "erwartet [grid], [[annotation]] oder [[keep]]") + for section in ("annotation", "keep"): + if not isinstance(data.get(section, []), list): + raise AnnotationError(f"annotations.toml: {section} muss als [[{section}]] geschrieben sein") + return AnnotationList( + [_parse_annotation(i, entry) for i, entry in enumerate(data.get("annotation", []), start=1)], + [_parse_keep(i, entry) for i, entry in enumerate(data.get("keep", []), start=1)], + _parse_grid(data.get("grid")), + ) + + +def _number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _point(value) -> bool: + return isinstance(value, list) and len(value) == 2 and all(_number(c) for c in value) + + +def _inside_slice(x_min, y_min, x_max, y_max) -> bool: + return x_min >= SLICE["x0"] and x_max <= SLICE["x1"] and y_min >= SLICE["y0"] and y_max <= SLICE["y1"] + + +def _parse_box(value, fail): + if not (isinstance(value, list) and len(value) == 2 and all(_point(p) for p in value)): + fail(f"Feld 'box' muss zwei Ecken [[x, y], [x, y]] enthalten, ist {value!r}") + (xa, ya), (xb, yb) = value + x_min, x_max = sorted((float(xa), float(xb))) + y_min, y_max = sorted((float(ya), float(yb))) + if x_max - x_min < MIN_BOX_SIDE or y_max - y_min < MIN_BOX_SIDE: + fail(f"Feld 'box' muss auf beiden Achsen mindestens {MIN_BOX_SIDE:g} m messen, ist {value!r}") + if not _inside_slice(x_min, y_min, x_max, y_max): + fail(f"Feld 'box' liegt außerhalb des Ausschnitts, ist {value!r}") + return x_min, y_min, x_max, y_max + + +def _parse_polygon(value, fail): + if not (isinstance(value, list) and len(value) >= 3 and all(_point(p) for p in value)): + fail(f"Feld 'polygon' muss mindestens 3 Punkte [x, y] enthalten, ist {value!r}") + shape = Polygon(value) + if not shape.is_valid or shape.area <= 0: + fail(f"Feld 'polygon' muss ein Vieleck ohne sich schneidende Kanten sein, ist {value!r}") + if not _inside_slice(*shape.bounds): + fail(f"Feld 'polygon' liegt außerhalb des Ausschnitts, ist {value!r}") + return tuple((float(x), float(y)) for x, y in value) + + +def _parse_annotation(index: int, entry) -> Annotation: + def fail(message: str): + raise AnnotationError(f"Beschriftung {index}: {message}") + + if not isinstance(entry, dict): + fail("ist keine Tabelle") + for key in entry: + if key not in ("name", "kind", "box", "polygon", "reason"): + fail(f"Feld {key!r} gibt es nicht") + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + fail("Feld 'name' fehlt oder ist leer") + kind = entry.get("kind") + if kind not in KINDS: + fail(f"unbekanntes kind {kind!r}, erlaubt sind {', '.join(KINDS)}") + reason = entry.get("reason", "") + if not isinstance(reason, str): + fail("reason muss Text sein") + if kind == "region": + if ("box" in entry) == ("polygon" in entry): + fail("region braucht genau eines der Felder 'box' und 'polygon'") + elif "polygon" in entry: + fail(f"Feld 'polygon' gibt es nur bei region, nicht bei {kind}") + elif "box" not in entry: + fail("Feld 'box' fehlt") + area = _parse_box(entry["box"], fail) if "box" in entry else None + outline = _parse_polygon(entry["polygon"], fail) if "polygon" in entry else None + return Annotation(index, name, kind, area, outline, reason) + + +def _parse_keep(index: int, entry) -> Keep: + def fail(message: str): + raise AnnotationError(f"Ausnahme {index}: {message}") + + if not isinstance(entry, dict): + fail("ist keine Tabelle") + for key in entry: + if key not in ("box", "reason"): + fail(f"Feld {key!r} gibt es nicht") + if "box" not in entry: + fail("Feld 'box' fehlt") + reason = entry.get("reason") + if not isinstance(reason, str) or not reason.strip(): + fail("Feld 'reason' fehlt oder ist leer") + return Keep(index, _parse_box(entry["box"], fail), reason) + + +def _parse_grid(value) -> Grid | None: + if value is None: + return None + + def fail(message: str): + raise AnnotationError(f"Gitternetz: {message}") + + if not isinstance(value, dict): + fail("muss als [grid] geschrieben sein") + for key in value: + if key not in ("spacing", "width"): + fail(f"Feld {key!r} gibt es nicht") + for key in ("spacing", "width"): + if key not in value: + fail(f"Feld {key!r} fehlt") + if not (_number(value[key]) and value[key] > 0): + fail(f"Feld {key!r} muss eine Zahl größer als 0 sein, ist {value[key]!r}") + return Grid(float(value["spacing"]), float(value["width"])) + + +# --- Masken ------------------------------------------------------------------------------------- + +@dataclass +class FoundMask: + rows: slice + cols: slice + mask: np.ndarray # bool, Größe des Rahmens + applied: bool + warning: str | None = None + + +def _lab(rgb) -> np.ndarray: + return rgb2lab(np.array([[rgb]], dtype=np.float32) / 255.0)[0, 0] + + +def candidates(rgb: np.ndarray, kind: str) -> np.ndarray: + """Kandidatenpixel der Farbregel einer Art in einem RGB-Ausschnitt (Tabelle in der Spec).""" + values = rgb.astype(np.int16) + low, high = values.min(axis=2), values.max(axis=2) + white = (low >= 236) & (high - low <= 14) + if kind == "text_white": + return white + if kind == "street_name": + return (low >= 200) & (high - low <= 20) # helle Schrift; Gebäudegrau (176) bleibt + lab = rgb2lab(rgb.astype(np.float32) / 255.0) + if kind == "text_red": + return np.linalg.norm(lab - _lab(RED), axis=2) < 18 + if kind in ("marker", "symbol"): + hsv = rgb2hsv(rgb) + palette = np.stack([_lab(colour) for colour in PALETTE.values()]) + far = np.linalg.norm(lab[:, :, None, :] - palette[None, None], axis=3).min(axis=2) >= 25 + marker = ((hsv[..., 1] >= 0.6) & (hsv[..., 2] >= 0.5) & far) | (high <= 45) + return marker | white if kind == "symbol" else marker + raise ValueError(f"Art ohne Farbregel: {kind}") + + +def _pixel_bounds(annotation: Annotation, shape) -> tuple[int, int, int, int]: + """Zeilen und Spalten, deren Pixelmitte im Hüllrechteck liegt; Pixelmitte (r, c) = Welt (x0 + c, y1 − r).""" + h, w = shape + x_min, y_min, x_max, y_max = annotation.outline.bounds + c0, c1 = max(0, math.ceil(x_min - SLICE["x0"])), min(w, math.floor(x_max - SLICE["x0"]) + 1) + r0, r1 = max(0, math.ceil(SLICE["y1"] - y_max)), min(h, math.floor(SLICE["y1"] - y_min) + 1) + return r0, r1, c0, c1 + + +def annotation_mask(mosaic: np.ndarray, annotation: Annotation) -> FoundMask: + """Maske eines Eintrags in fünf Schritten (Spec, Abschnitt „Erkennung je Art“).""" + h, w = mosaic.shape[:2] + r0, r1, c0, c1 = _pixel_bounds(annotation, (h, w)) + rows, cols = slice(r0, r1), slice(c0, c1) + shape = (r1 - r0, c1 - c0) + if annotation.kind == "region": + mask = np.zeros(shape, dtype=bool) + xs, ys = annotation.outline.exterior.coords.xy + rr, cc = fill_polygon(SLICE["y1"] - np.asarray(ys) - r0, np.asarray(xs) - SLICE["x0"] - c0, shape=shape) + mask[rr, cc] = True + if not mask.any(): + return FoundMask(rows, cols, mask, False, "leer: das Vieleck trifft keine Pixelmitte") + return FoundMask(rows, cols, mask, True) + + found = candidates(mosaic[rows, cols], annotation.kind) + comps, count = ndi.label(found, structure=EIGHT) + border = np.zeros(shape, dtype=bool) + border[0, :], border[-1, :] = r0 > 0, r1 < h # Kanten auf dem Ausschnittsrand zählen nicht + border[:, 0] |= c0 > 0 + border[:, -1] |= c1 < w + touching = np.unique(comps[border & found]) + touching = touching[touching > 0] + cut = int(max(ndi.sum(found, comps, touching))) if touching.size else 0 + keep = found & ~np.isin(comps, touching) + if not keep.any(): + return FoundMask(rows, cols, keep, False, f"leer: keine Pixel der Art {annotation.kind} im Rahmen") + grown = ndi.binary_dilation(keep, structure=disk(MARGIN[annotation.kind]).astype(bool)) + holes = ndi.binary_fill_holes(grown) & ~grown + hole_ids, hole_count = ndi.label(holes) + if hole_count: + sizes = ndi.sum(holes, hole_ids, np.arange(1, hole_count + 1)) + grown |= np.isin(hole_ids, 1 + np.flatnonzero(sizes <= HOLE_MAX_AREA)) + share = float(keep.mean()) + if share > FULL_SHARE: + return FoundMask(rows, cols, grown, False, + f"randvoll: Farbpixel füllen {round(share * 100)} % des Rahmens, Rahmen zu eng oder Art falsch") + cut_off = cut >= CUT_MIN_PX and annotation.kind != "street_name" + return FoundMask(rows, cols, grown, True, "angeschnitten: Rahmen prüfen" if cut_off else None) + + +def grid_mask(shape: tuple[int, int], grid: Grid) -> tuple[np.ndarray, int]: + """Streifen der Breite grid.width mittig auf jeder Gitterlinie; dazu die Zahl der Linien.""" + h, w = shape + mask = np.zeros(shape, dtype=bool) + lines = 0 + for size, axis in ((w, 1), (h, 0)): + k = 0 + while k * grid.spacing <= size: + lo = max(0, int(round(k * grid.spacing - grid.width / 2))) + hi = min(size, int(round(k * grid.spacing + grid.width / 2))) + if lo < hi: + if axis == 1: + mask[:, lo:hi] = True + else: + mask[lo:hi, :] = True + lines += 1 + k += 1 + return mask, lines + + +def _grid_strips(shape: tuple[int, int], grid: Grid): + """Streifen von GRID_STRIP Metern beiderseits jeder Gitterlinie, damit nie das ganze Bild gefüllt wird.""" + h, w = shape + for size, axis in ((w, 1), (h, 0)): + k = 0 + while k * grid.spacing <= size: + lo = max(0, int(round(k * grid.spacing - grid.width / 2 - GRID_STRIP))) + hi = min(size, int(round(k * grid.spacing + grid.width / 2 + GRID_STRIP))) + if lo < hi: + yield (slice(None), slice(lo, hi)) if axis == 1 else (slice(lo, hi), slice(None)) + k += 1 + + +# --- Entfernen, Bericht, Markierung ---------------------------------------------------------- + +@dataclass +class EntryReport: + annotation: Annotation + applied: bool + warning: str | None = None + mask_area: int = 0 + stats: RebuildStats | None = None + shared_with: int | None = None + + +@dataclass +class Removal: + labels: np.ndarray + generated: list[Polygon] + entries: list[EntryReport] + grid_lines: int + + def report(self) -> str: + applied = sum(1 for e in self.entries if e.applied) + lines = [f"Beschriftungen: {applied} angewendet, {len(self.entries) - applied} übersprungen" + f" · Gitternetz: {self.grid_lines} Linien"] + for e in self.entries: + if not e.applied: + continue + a = e.annotation + parts = [f"#{a.index} {a.name}", a.chunk, f"Maske {e.mask_area:,} m²".replace(",", ".")] + if e.shared_with is not None: + parts.append(f"gemeinsam mit #{e.shared_with}") + elif e.stats is not None: + s = e.stats + parts.append(f"Straßen {s.connected} verbunden, {s.extended} weitergeführt, {s.dead_ends} Sackgassen") + parts.append(f"Gebäude {s.closed} geschlossen, {s.added} ergänzt" + (f" ({s.note})" if s.note else "")) + lines.append(" " + " · ".join(parts)) + warned = [e for e in self.entries if e.warning] + if warned: + lines.append("Warnungen:") + lines += [f" #{e.annotation.index} {e.annotation.name} · {e.annotation.chunk} · {e.warning}" for e in warned] + return "\n".join(lines) + + +def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: AnnotationList) -> Removal: + """Entfernt alle Einträge und das Gitternetz aus einer Kopie des Klassenbilds.""" + labels = labels.copy() + union = np.zeros(labels.shape, dtype=bool) + entries, found = [], [] + for annotation in annotations.annotations: + result = annotation_mask(mosaic, annotation) + entry = EntryReport(annotation, result.applied, result.warning) + if result.applied: + union[result.rows, result.cols] |= result.mask + entry.mask_area = int(result.mask.sum()) + entries.append(entry) + found.append(result) + + grid_lines = 0 + if annotations.grid is not None: + grid, grid_lines = grid_mask(labels.shape, annotations.grid) + blocked = grid | union + for strip in _grid_strips(labels.shape, annotations.grid): + fill_nearest(labels[strip], (grid & ~union)[strip], blocked[strip]) + + components, _ = ndi.label(union, structure=EIGHT) + owners: dict[int, EntryReport] = {} # Maske -> erster Eintrag in Dateireihenfolge + touched = [] + for entry, result in zip(entries, found): + ids = np.unique(components[result.rows, result.cols][result.mask]) if entry.applied else np.array([], int) + ids = ids[ids > 0] + for cid in ids: + owners.setdefault(int(cid), entry) + touched.append(ids) + + generated = [] + h, w = labels.shape + for cid, sl in enumerate(ndi.find_objects(components), start=1): + if sl is None: + continue + piece = components[sl] == cid + if is_thin(piece): + win = (slice(max(0, sl[0].start - THIN_WINDOW), min(h, sl[0].stop + THIN_WINDOW)), + slice(max(0, sl[1].start - THIN_WINDOW), min(w, sl[1].stop + THIN_WINDOW))) + fill_nearest(labels[win], components[win] == cid, union[win]) + continue + mask = np.zeros(labels.shape, dtype=bool) + mask[sl] = piece + result = rebuild(labels, mask, union) + owner = owners[cid] + owner.stats = result.stats if owner.stats is None else _merged(owner.stats, result.stats) + generated += [Polygon([(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in corners]) + for corners in result.buildings] + for entry, ids in zip(entries, touched): + if entry.applied and entry.stats is None and ids.size and owners[int(ids[0])] is not entry: + entry.shared_with = owners[int(ids[0])].annotation.index + return Removal(labels, generated, entries, grid_lines) + + +def _merged(a: RebuildStats, b: RebuildStats) -> RebuildStats: + return RebuildStats(a.connected + b.connected, a.extended + b.extended, a.dead_ends + b.dead_ends, + a.closed + b.closed, a.added + b.added, "; ".join(dict.fromkeys(n for n in (a.note, b.note) if n))) + + +def mark_generated(polygons: list[Polygon], heights: list[dict], generated: list[Polygon]) -> int: + """Setzt source = "annotation" bei Gebäuden, die zu mindestens der Hälfte in ergänzten Rechtecken liegen.""" + if not generated: + return 0 + tree = STRtree(generated) + marked = 0 + for polygon, height in zip(polygons, heights): + covered = sum(polygon.intersection(generated[k]).area for k in tree.query(polygon)) + if covered >= GENERATED_SHARE * polygon.area: + height["source"] = "annotation" + marked += 1 + return marked +``` + +- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen; dann die Suite** + +Run: `cd tools/extract && uv run pytest tests/test_annotations_remove.py tests/test_annotations_load.py -q && uv run pytest -q` +Expected: 34 passed, danach 110 passed. + +- [ ] **Step 5: Commit** + +```bash +git add tools/extract/leonida/annotations.py tools/extract/tests/test_annotations_remove.py +git commit -m "feat(extract): Beschriftungsmasken, Gitternetz und Entfernen mit Bericht + +Co-Authored-By: Claude Opus 5 " -- tools/extract/leonida/annotations.py tools/extract/tests/test_annotations_remove.py +``` + +--- + +### Task 5: Einhängen in `run.py` und Nullauf + +**Files:** +- Modify: `tools/extract/run.py` + +**Interfaces:** +- Consumes: `load_annotations`, `remove_annotations`, `mark_generated` aus `leonida/annotations.py`; `render_map(out_dir: Path, dst_dir: Path) -> dict` aus `overview.py` + +- [ ] **Step 1: `tools/extract/run.py` vollständig neu lesen** + +Eine andere Sitzung kann die Datei verändert haben. Die folgenden fünf Änderungen beziehen sich auf Zeilen, die es in jeder Fassung geben muss; findest du eine davon nicht wörtlich, halt an und melde es. + +- [ ] **Step 2: Die fünf Änderungen einfügen** + +Vor der Zeile `from leonida.buildings import building_polygons` einfügen: + +```python +from leonida.annotations import load_annotations, mark_generated, remove_annotations +``` + +Nach `from leonida.vectors import centerlines, land_polygons, mask_polygons, road_polygons` einfügen: + +```python +from overview import render_map +``` + +Direkt nach ` labels = drop_map_labels(labels)` einfügen: + +```python + removal = remove_annotations(mosaic, labels, load_annotations(Path("data/annotations.toml"))) + labels = removal.labels + print(removal.report()) +``` + +Direkt nach ` heights = assign_heights([p for p, _ in buildings], landmarks, districts, SEED)` einfügen: + +```python + marked = mark_generated([p for p, _ in buildings], heights, removal.generated) + print(f"Gebäude: {len(buildings)}, davon {marked} unter entfernten Beschriftungen ergänzt") +``` + +Nach dem abschließenden ` })` des Aufrufs `write_chunks(OUT, payloads, {…})` — als letzte Zeile von `main()` — einfügen: + +```python + print(render_map(OUT, OUT), "->", OUT) +``` + +- [ ] **Step 3: Nullauf — ohne `data/annotations.toml` muss alles Byte für Byte gleich bleiben** + +Run: `cd tools/extract && test ! -e data/annotations.toml && uv run python run.py` +Expected: Die Ausgabe enthält `Beschriftungen: 0 angewendet, 0 übersprungen · Gitternetz: 0 Linien`, `Gebäude: 1520, davon 0 unter entfernten Beschriftungen ergänzt` und endet mit `{'map': (3000, 3000), 'walkable': (750, 750), 'manifest_sha256': 'a14048879d3a3663e93ed488d52a065d8b0644de5fd1a510ac746249f25036f6'} -> …/public/data/vice-a`. + +Run: `git status --short public/data/vice-a` +Expected: keine Ausgabe — Chunks, Manifest, `map.png`, `map-walkable.png` und `map.json` sind unverändert. Zeigt Git Änderungen, anhalten und melden. + +- [ ] **Step 4: Suite** + +Run: `cd tools/extract && uv run pytest -q` +Expected: 110 passed. + +- [ ] **Step 5: Commit** + +```bash +git add tools/extract/run.py +git commit -m "feat(extract): Beschriftungen in run.py entfernen, Minimap am Ende erzeugen + +Co-Authored-By: Claude Opus 5 " -- tools/extract/run.py +``` + +--- + +### Task 6: Werkzeug `find_annotations.py` + +**Files:** +- Create: `tools/extract/find_annotations.py` +- Create: `tools/extract/tests/test_find_annotations.py` + +**Interfaces:** +- Consumes: `annotation_mask`, `candidates`, `load_annotations` aus `leonida/annotations.py`; `SLICE`, `chunk_for_world`, `chunk_name` aus `leonida/coords.py`; `load_mosaic` aus `leonida/tiles.py` +- Produces: `@dataclass(frozen=True) Spot(kind: str, rows: tuple[int, int], cols: tuple[int, int])` mit `centre` und `chunk`; `scan_mask(rgb, kind) -> np.ndarray`; `scan(rgb) -> list[Spot]`; `uncovered(spots, annotations) -> list[Spot]`; `propose_toml(spots) -> str`; `contact_sheet(rgb, spots, labels) -> Image.Image`; `review(rgb, annotations, picture, out_dir) -> list[Path]`; `main(argv=None) -> int` + +- [ ] **Step 1: Die fehlschlagenden Tests schreiben** + +`tools/extract/tests/test_find_annotations.py`: + +```python +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() +``` + +- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_find_annotations.py -q` +Expected: FAIL — Sammelfehler `ModuleNotFoundError: No module named 'find_annotations'`. + +- [ ] **Step 3: `tools/extract/find_annotations.py` anlegen** + +```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()) +``` + +- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen; dann die Suite** + +Run: `cd tools/extract && uv run pytest tests/test_find_annotations.py -q && uv run pytest -q` +Expected: 4 passed, danach 114 passed. + +- [ ] **Step 5: Suche an der echten Vorlage** + +Run: `cd tools/extract && uv run python find_annotations.py --propose` +Expected: `149 Fundstellen -> debug/annotations/vorschlag.toml, debug/annotations/vorschlag.png`, Laufzeit unter 10 s. Die ersten Einträge in `debug/annotations/vorschlag.toml` sind `name = "#1 A1"`, `kind = "text_red"`, `box = [[-1000, 2324], [-713, 2235]]` und `name = "#2 B1"`, `kind = "text_white"`. `vorschlag.png` ansehen: kein Treffer auf einer Küstenlinie. + +- [ ] **Step 6: Commit** + +```bash +git add tools/extract/find_annotations.py tools/extract/tests/test_find_annotations.py +git commit -m "feat(extract): Beschriftungen suchen, Liste auf Vollständigkeit prüfen, Prüfbilder + +Co-Authored-By: Claude Opus 5 " -- tools/extract/find_annotations.py tools/extract/tests/test_find_annotations.py +``` + +--- + +### Task 7: Liste, Lauf, Prüfbilder, Kennzahlen und Startpunkt + +**Files:** +- Create: `tools/extract/data/annotations.toml` +- Modify: `public/data/vice-a/*` (erzeugt) +- Modify: `src/main.js` + +**Interfaces:** +- Consumes: alles aus Task 1–6 + +- [ ] **Step 1: Kennzahlen vorher festhalten** + +`tools/extract/debug/kennzahlen.py` anlegen (nicht versioniert, `debug/` ist ignoriert): + +```python +import json +from pathlib import Path + +from shapely.geometry import Polygon + +D = Path("../../public/data/vice-a") +TOLERANCE = {"wasser": (0.30, 0.36), "strasse": (0.10, 0.14), "gebaeude": (0.09, 0.13)} +chunks = [json.loads(f.read_text()) for f in sorted(D.glob("chunk_*.json"))] +l8 = next(c for c in chunks if c["chunk"] == [11, 7]) +shares = json.loads((D / "manifest.json").read_text())["class_shares"] + + +def small_holes(chunk): + return sum(1 for rings in chunk["land"] for hole in rings[1:] if Polygon(hole).area < 2000) + + +print(json.dumps({ + "bruecken": sum(r["bridge"] for c in chunks for r in c["roads"]), + "bruecken_L8": sum(r["bridge"] for r in l8["roads"]), + "loecher": sum(small_holes(c) for c in chunks), + "loecher_L8": small_holes(l8), + "gebaeude_eintraege": sum(len(c["buildings"]) for c in chunks), + "ergaenzt_eintraege": sum(b["source"] == "annotation" for c in chunks for b in c["buildings"]), + "anteile_ok": all(lo <= shares[k] <= hi for k, (lo, hi) in TOLERANCE.items()), +})) +``` + +Run: `cd tools/extract && uv run python debug/kennzahlen.py` +Expected: `{"bruecken": 23, "bruecken_L8": 1, "loecher": 35, "loecher_L8": 2, "gebaeude_eintraege": 1895, "ergaenzt_eintraege": 0, "anteile_ok": true}` + +- [ ] **Step 2: `tools/extract/data/annotations.toml` anlegen** + +Aus `find_annotations.py --propose` entstanden, nach Augenschein benannt, in Arten eingeteilt und um `region`-Einträge, `keep`-Stellen und die drei Tennisschläger ergänzt. Hat der Nutzer bis hierhin weitere Überreste markiert, kommen sie als eigene `[[annotation]]`-Einträge mit `kind = "region"`, `box` und `reason = "vom Nutzer markiert"` **ans Ende** der Beschriftungen, vor die `[[keep]]`-Tabellen; die erwarteten Zahlen in Step 4 und 6 ändern sich dann entsprechend. + +```toml +# Kartenbeschriftungen der Vorlage (YANIS v15), die run.py vor der Vektorisierung entfernt. +# Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md +# Koordinaten sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt. + +[grid] +spacing = 500 # Meter zwischen den Gitterlinien der Vorlage +width = 6 # Meter, Linie samt Kantenglättung + +[[annotation]] +name = "SHA-WOCKA (OPA-LOCKA)" +kind = "text_red" +box = [[-1000, 2324], [-713, 2235]] + +[[annotation]] +name = "Schild 561 in B1" +kind = "symbol" +box = [[-672, 2404], [-627, 2356]] + +[[annotation]] +name = "BOHEMIA (DESIGN DISTRICT)" +kind = "text_red" +box = [[-357, 2373], [-62, 2284]] + +[[annotation]] +name = "Schild 3 in D2" +kind = "symbol" +box = [[-112, 2275], [-61, 2227]] + +[[annotation]] +name = "(INDIAN CREEK COUNTRY CLUB)" +kind = "text_red" +box = [[470, 2500], [991, 2452]] + +[[annotation]] +name = "Schild 10 in A2" +kind = "symbol" +box = [[-885, 2045], [-833, 1995]] + +[[annotation]] +name = "Schild 97 in B2" +kind = "symbol" +box = [[-551, 2199], [-497, 2147]] + +[[annotation]] +name = "Marker S3/13, S3/12, S3/15" +kind = "marker" +box = [[-300, 2218], [-189, 2127]] + +[[annotation]] +name = "Schild 10 in D2" +kind = "symbol" +box = [[-194, 2035], [-143, 1984]] + +[[annotation]] +name = "(NAUTILUS)" +kind = "text_white" +box = [[924, 2061], [1111, 2011]] + +[[annotation]] +name = "Stern" +kind = "symbol" +box = [[1004, 2118], [1043, 2078]] + +[[annotation]] +name = "(BAY HARBOR)" +kind = "text_white" +box = [[1397, 2179], [1627, 2128]] + +[[annotation]] +name = "…VILLE (…SVILLE)" +kind = "text_red" +box = [[-1000, 1850], [-877, 1763]] + +[[annotation]] +name = "Schild 561 in B3" +kind = "symbol" +box = [[-715, 1899], [-672, 1854]] + +[[annotation]] +name = "STOCKYARD (WYNWOOD)" +kind = "text_white" +box = [[-514, 1853], [-227, 1745]] + +[[annotation]] +name = "Marker S3/31" +kind = "marker" +box = [[-416, 1801], [-307, 1747]] + +[[annotation]] +name = "Marker S2/71" +kind = "marker" +box = [[356, 1882], [417, 1839]] + +[[annotation]] +name = "Schild 10 in G3" +kind = "symbol" +box = [[493, 1998], [544, 1948]] + +[[annotation]] +name = "(PELICAN HARBOR)" +kind = "text_white" +box = [[870, 1829], [1170, 1780]] + +[[annotation]] +name = "Schild 10 in H3" +kind = "symbol" +box = [[884, 1989], [935, 1938]] + +[[annotation]] +name = "Schild 10 in J3" +kind = "symbol" +box = [[1321, 1800], [1372, 1750]] + +[[annotation]] +name = "(BAYSHORE)" +kind = "text_white" +box = [[1453, 1814], [1651, 1765]] + +[[annotation]] +name = "Schild 10 in K4" +kind = "symbol" +box = [[1718, 1774], [1769, 1720]] + +[[annotation]] +name = "Marker T1/20" +kind = "marker" +box = [[1896, 1998], [1959, 1944]] + +[[annotation]] +name = "WA… am rechten Kartenrand" +kind = "text_white" +box = [[1937, 1896], [2000, 1842]] + +[[annotation]] +name = "Marker T1/42" +kind = "marker" +box = [[-977, 1708], [-891, 1666]] + +[[annotation]] +name = "Marker T1/40" +kind = "marker" +box = [[-879, 1641], [-816, 1597]] + +[[annotation]] +name = "„?“ in A4" +kind = "symbol" +box = [[-873, 1697], [-822, 1642]] + +[[annotation]] +name = "Marker T1/15" +kind = "marker" +box = [[-861, 1572], [-797, 1521]] + +[[annotation]] +name = "Schild 97 in B4" +kind = "symbol" +box = [[-652, 1601], [-592, 1539]] + +[[annotation]] +name = "Marker L1/27" +kind = "marker" +box = [[-601, 1732], [-479, 1583]] + +[[annotation]] +name = "Marker T1/13" +kind = "marker" +box = [[-474, 1737], [-384, 1672]] + +[[annotation]] +name = "(PICNIC ISLAND)" +kind = "text_white" +box = [[-141, 1629], [117, 1580]] + +[[annotation]] +name = "Marker T2/38, T2/37" +kind = "marker" +box = [[358, 1751], [425, 1672]] + +[[annotation]] +name = "„?“ in J4" +kind = "symbol" +box = [[1358, 1528], [1410, 1478]] + +[[annotation]] +name = "Marker L1/17/18" +kind = "marker" +box = [[1914, 1576], [2000, 1523]] + +[[annotation]] +name = "Marker S2… am rechten Kartenrand" +kind = "marker" +box = [[1964, 1667], [2000, 1583]] + +[[annotation]] +name = "…RLE (…AITI)" +kind = "text_white" +box = [[-1000, 1484], [-916, 1389]] + +[[annotation]] +name = "Marker T1/26, T1/25, S2/26" +kind = "marker" +box = [[-778, 1294], [-649, 1225]] + +[[annotation]] +name = "Marker L2/1" +kind = "marker" +box = [[-722, 1457], [-616, 1405]] + +[[annotation]] +name = "CROSSTOWN (OVERTOWN)" +kind = "text_white" +box = [[-665, 1455], [-344, 1333]] + +[[annotation]] +name = "Marker L1/39, L1/6, L1/24" +kind = "marker" +box = [[-539, 1270], [-393, 1195]] + +[[annotation]] +name = "(VENETIAN ISLANDS)" +kind = "text_white" +box = [[465, 1308], [799, 1247]] + +[[annotation]] +name = "Marker T2/80" +kind = "marker" +box = [[700, 1369], [760, 1320]] + +[[annotation]] +name = "Marker L1/15" +kind = "marker" +box = [[1388, 1396], [1449, 1345]] + +[[annotation]] +name = "Marker L1/5" +kind = "marker" +box = [[1440, 1336], [1515, 1289]] + +[[annotation]] +name = "Schild 97 in A6" +kind = "symbol" +box = [[-869, 1230], [-817, 1184]] + +[[annotation]] +name = "Marker L1/38, A2/05, S2/18, S2/27" +kind = "marker" +box = [[-782, 1228], [-619, 1143]] + +[[annotation]] +name = "„?“ in B6" +kind = "symbol" +box = [[-623, 1028], [-571, 977]] + +[[annotation]] +name = "Marker L1/42" +kind = "marker" +box = [[-376, 1210], [-288, 1152]] + +[[annotation]] +name = "Marker T1/16" +kind = "marker" +box = [[-126, 1093], [-40, 1056]] + +[[annotation]] +name = "„?“ in D6" +kind = "symbol" +box = [[-52, 1122], [0, 1071]] + +[[annotation]] +name = "Marker S2/08" +kind = "marker" +box = [[-50, 1210], [19, 1166]] + +[[annotation]] +name = "Marker T1/30" +kind = "marker" +box = [[-1000, 830], [-945, 774]] + +[[annotation]] +name = "Schild 97 in A7" +kind = "symbol" +box = [[-945, 933], [-893, 883]] + +[[annotation]] +name = "Marker T2/52" +kind = "marker" +box = [[-668, 969], [-605, 912]] + +[[annotation]] +name = "Marker T1/41" +kind = "marker" +box = [[-641, 870], [-580, 823]] + +[[annotation]] +name = "Marker S2/44" +kind = "marker" +box = [[-595, 944], [-530, 887]] + +[[annotation]] +name = "VICE CITY (MIAMI)" +kind = "text_white" +box = [[-190, 923], [696, 655]] + +[[annotation]] +name = "Marker S3/27" +kind = "marker" +box = [[311, 872], [377, 820]] + +[[annotation]] +name = "(FLAGLER MEMORIAL ISLAND)" +kind = "text_white" +box = [[625, 994], [1085, 946]] + +[[annotation]] +name = "Marker T2/71" +kind = "marker" +box = [[936, 851], [1006, 792]] + +[[annotation]] +name = "Marker T1/22" +kind = "marker" +box = [[1064, 894], [1153, 858]] + +[[annotation]] +name = "VICE BEACH (MIAMI BEACH)" +kind = "text_white" +box = [[1360, 857], [1887, 717]] + +[[annotation]] +name = "Marker L1/36" +kind = "marker" +box = [[1900, 869], [1967, 818]] + +[[annotation]] +name = "Marker T2/41" +kind = "marker" +box = [[-670, 530], [-613, 490]] + +[[annotation]] +name = "Schild 404 in B8" +kind = "symbol" +box = [[-626, 618], [-567, 575]] + +[[annotation]] +name = "Schild 3 in C9" +kind = "symbol" +box = [[-479, 516], [-429, 466]] + +[[annotation]] +name = "Catalan Blvd in C8" +kind = "street_name" +box = [[-436, 704], [-398, 608]] + +[[annotation]] +name = "Marker T1/12" +kind = "marker" +box = [[-169, 606], [-20, 558]] + +[[annotation]] +name = "(WATSON ISLAND)" +kind = "text_white" +box = [[38, 547], [375, 488]] + +[[annotation]] +name = "Marker S2/32, T2/65, T2/51" +kind = "marker" +box = [[1875, 656], [2000, 545]] + +[[annotation]] +name = "Marker T1/17" +kind = "marker" +box = [[1951, 538], [2000, 497]] + +[[annotation]] +name = "Shore Dr in L8" +kind = "street_name" +box = [[1966, 731], [2000, 659]] + +[[annotation]] +name = "Marker T1/18" +kind = "marker" +box = [[-624, 499], [-535, 458]] + +[[annotation]] +name = "Catalan Blvd in C9" +kind = "street_name" +box = [[-467, 415], [-432, 318]] + +[[annotation]] +name = "Marker T2/59" +kind = "marker" +box = [[-280, 482], [-218, 426]] + +[[annotation]] +name = "STARFISH ISLAND" +kind = "text_red" +box = [[486, 454], [899, 400]] + +[[annotation]] +name = "(STAR ISLAND)" +kind = "text_white" +box = [[575, 416], [810, 366]] + +[[annotation]] +name = "Marker S2/02" +kind = "marker" +box = [[656, 487], [743, 454]] + +[[annotation]] +name = "Marker T1/24" +kind = "marker" +box = [[1640, 415], [1723, 372]] + +[[annotation]] +name = "Marker L1/11" +kind = "marker" +box = [[1905, 298], [1970, 242]] + +[[annotation]] +name = "Shore Dr in L9" +kind = "street_name" +box = [[1933, 488], [1970, 416]] + +[[annotation]] +name = "Marker A1/0… am rechten Kartenrand" +kind = "marker" +box = [[1942, 459], [2000, 399]] + +[[annotation]] +name = "Schild 3 in C11" +kind = "symbol" +box = [[-457, 11], [-404, -39]] + +[[annotation]] +name = "(BAYFRONT)" +kind = "text_white" +box = [[-453, 85], [-253, -40]] + +[[annotation]] +name = "BAYSIDE" +kind = "text_red" +box = [[-452, 126], [-249, 73]] + +[[annotation]] +name = "OCEAN BEACH (SOUTH BEACH)" +kind = "text_white" +box = [[1581, 53], [1917, -39]] + +[[annotation]] +name = "Shore Dr in L10" +kind = "street_name" +box = [[1906, 214], [1936, 140]] + +[[annotation]] +name = "Marker in C11 (L1/3, T2/60)" +kind = "marker" +box = [[-466, -74], [-311, -238]] + +[[annotation]] +name = "CATALAN BAY (BISCAYNE BAY)" +kind = "text_red" +box = [[434, -70], [760, -155]] + +[[annotation]] +name = "(FERRY ISLAND)" +kind = "text_white" +box = [[857, -60], [1108, -109]] + +[[annotation]] +name = "Marker S3/01" +kind = "marker" +box = [[1423, -70], [1729, -158]] + +[[annotation]] +name = "Shore Dr in L11" +kind = "street_name" +box = [[1905, -60], [1934, -131]] + +[[annotation]] +name = "Schild 397 in B12" +kind = "symbol" +box = [[-685, -346], [-625, -397]] + +[[annotation]] +name = "Catalan Blvd in C12" +kind = "street_name" +box = [[-430, -288], [-392, -382]] + +[[annotation]] +name = "Marker T2/73/74" +kind = "marker" +box = [[-313, -398], [-169, -433]] + +[[annotation]] +name = "Schild 397 in D12" +kind = "symbol" +box = [[-146, -410], [-87, -460]] + +[[annotation]] +name = "Marker L1/7" +kind = "marker" +box = [[1132, -400], [1205, -446]] + +[[annotation]] +name = "Tennisschläger in L2 (Nord)" +kind = "symbol" +box = [[1763, 2123], [1793, 2093]] + +[[annotation]] +name = "Tennisschläger in L2 (Süd)" +kind = "symbol" +box = [[1803, 2048], [1833, 2018]] + +[[annotation]] +name = "Tennisschläger in E6" +kind = "symbol" +box = [[87, 1185], [117, 1155]] + +[[annotation]] +name = "VICE-DALE COUNTY (MIAMI DADE)" +kind = "region" +box = [[-822, 2500], [-240, 2462]] + +[[annotation]] +name = "Golf-Fähnchen" +kind = "region" +box = [[815, 2432], [862, 2375]] + +[[annotation]] +name = "Catalan Blvd in C10" +kind = "street_name" +box = [[-434, 58], [-404, 5]] + +[[annotation]] +name = "Shore Ct in L7" +kind = "region" +box = [[1941, 830], [1953, 798]] + +[[annotation]] +name = "Shore Ct in L8" +kind = "region" +box = [[1907, 577], [1920, 541]] + +[[keep]] +box = [[-550, 2500], [-471, 2359]] +reason = "rote spekulative Straße in B1, echt" + +[[keep]] +box = [[-283, 2237], [-142, 2127]] +reason = "rosa Gebäudekomplex im Design District, echt" + +[[keep]] +box = [[-361, 1154], [-277, 1015]] +reason = "rote Parkwege im Museum Park, echt" + +[[keep]] +box = [[-648, 567], [-579, 521]] +reason = "lachsrotes Rechteck in B8, echt" + +[[keep]] +box = [[-627, 502], [-582, 466]] +reason = "lachsrotes Rechteck in B9, echt" + +[[keep]] +box = [[-856, 194], [-795, 0]] +reason = "knallrotes Gebäude in A10, echt" + +[[keep]] +box = [[1748, 144], [1797, 24]] +reason = "knallrotes Gebäude über OCEAN BEACH, echt" + +[[keep]] +box = [[1926, 177], [2000, 101]] +reason = "knallrote Gebäude in L10, echt" + +[[keep]] +box = [[-411, 4], [-181, -402]] +reason = "rote Parkwege im Bayfront Park, echt" + +[[keep]] +box = [[-1000, -252], [-727, -457]] +reason = "rote spekulative Autobahnrampen in A12, echt" +``` + +- [ ] **Step 3: Vollständigkeit prüfen** + +Run: `cd tools/extract && uv run python find_annotations.py --check` +Expected: `0 Fundstellen ohne Eintrag`, Exit-Status 0. + +- [ ] **Step 4: Pipeline laufen lassen** + +Run: `cd tools/extract && uv run python run.py | tee debug/lauf.txt` +Expected: erste Zeile `Beschriftungen: 107 angewendet, 0 übersprungen · Gitternetz: 14 Linien`; **keine** Zeile `Warnungen:`; die Zeile zu VICE BEACH lautet ` #64 VICE BEACH (MIAMI BEACH) · K7 · Maske 46.296 m² · Straßen 7 verbunden, 1 weitergeführt, 0 Sackgassen · Gebäude 0 geschlossen, 2 ergänzt`; danach `Gebäude: 1310, davon 13 unter entfernten Beschriftungen ergänzt`; Ende mit `manifest_sha256': '5b5771c616b211c6bf199ddb6b36e90f5aa70885e0ac550c9f1b144bbfc3a280'}`. Laufzeit unter 60 s. + +- [ ] **Step 5: Prüfbilder ansehen** + +Run: `cd tools/extract && uv run python find_annotations.py --review` +Expected: `107 Prüfbilder -> debug/annotations`. + +Alle Bilder in `tools/extract/debug/annotations/` ansehen (links Vorlage mit Maskenumriss, rechts Ergebnis). Rechts darf keine Schrift, kein Marker und kein Schild mehr zu sehen sein. Straßen, die links hinter der Schrift weiterlaufen, laufen rechts durch. Zweifelhafte Stellen mit Nummer notieren. Der Controller schickt dem Nutzer die Seiten der großen Schriftzüge — VICE BEACH (#64), CROSSTOWN (#40 und #41), STOCKYARD (#15), OCEAN BEACH (#88), BAYSIDE (#85 bis #87), BOHEMIA (#3), STARFISH ISLAND (#78) — und jede zweifelhafte Stelle. + +- [ ] **Step 6: Kennzahlen nachher** + +Run: `cd tools/extract && uv run python debug/kennzahlen.py` +Expected: `{"bruecken": 14, "bruecken_L8": 0, "loecher": 13, "loecher_L8": 0, "gebaeude_eintraege": 1588, "ergaenzt_eintraege": 15, "anteile_ok": true}` + +- [ ] **Step 7: Startpunkt und Autos prüfen und versetzen** + +Figur und alle vier Autos standen bisher auf der „Straße“, die die Buchstabenkonturen von „(MIAMI BEACH)“ bildeten; nach dem Entfernen stehen die Figur und ein Auto in einem ergänzten Gebäude. Neue Punkte liegen auf der wiederaufgebauten Ost-West-Straße am Südrand des Parks (Autos) und auf dem Rasen nördlich davon (Figur), alle in K8 = `chunk_10_7`. + +`tools/extract/debug/startpunkt.py` anlegen: + +```python +import json +from pathlib import Path + +from PIL import Image +from shapely.geometry import LineString, Point, Polygon + +D = Path("../../public/data/vice-a") +POINTS = {"SPAWN": (1600, 690), "SUV 1": (1560, 658), "SUV 2": (1590, 659), "SUV 3": (1630, 661), "SUV 4": (1655, 663)} +walkable = Image.open(D / "map-walkable.png") +for name, (x, y) in POINTS.items(): + i, j = int((x + 1000) // 250), int((2500 - y) // 250) + chunk = json.loads((D / f"chunk_{i}_{j}.json").read_text()) + ox, oz = chunk["origin"] + p = Point(x - ox, -y - oz) + building = any(Polygon(b["poly"][0], b["poly"][1:]).contains(p) for b in chunk["buildings"]) + road = any(Polygon(r["poly"][0], r["poly"][1:]).contains(p) for r in chunk["roads"]) + line = min(LineString(c).distance(p) for c in chunk["centerlines"] if len(c) > 1) + free = walkable.getpixel((int((x + 1000) / 4), int((2500 - y) / 4))) == 255 + print(f"{name} {x}/{y} {'ABCDEFGHIJKL'[i]}{j + 1}: begehbar={free} Gebäude={building} Straße={road} Mittellinie={line:.1f} m") +``` + +Run: `cd tools/extract && uv run python debug/startpunkt.py` +Expected: +``` +SPAWN 1600/690 K8: begehbar=True Gebäude=False Straße=False Mittellinie=30.3 m +SUV 1 1560/658 K8: begehbar=True Gebäude=False Straße=True Mittellinie=0.1 m +SUV 2 1590/659 K8: begehbar=True Gebäude=False Straße=True Mittellinie=0.2 m +SUV 3 1630/661 K8: begehbar=True Gebäude=False Straße=True Mittellinie=0.4 m +SUV 4 1655/663 K8: begehbar=True Gebäude=False Straße=True Mittellinie=0.1 m +``` + +`src/main.js` neu lesen, dann `const SPAWN = { x: 1699, y: 3, z: -743 };` ersetzen durch: + +```js +const SPAWN = { x: 1600, y: 3, z: -690 }; +``` + +und die vier Einträge von `VEHICLE_SPAWNS` ersetzen durch: + +```js + new Vector3(1560, 1, -658), + new Vector3(1590, 1, -659), + new Vector3(1630, 1, -661), + new Vector3(1655, 1, -663), +``` + +Im Kommentar über `SPAWN` die Zeile `// Vice Beach; Task 8, Schritt 5. Auch initPos, damit der Streamer von Anfang an den richtigen` ersetzen durch `// Vice Beach, Rasen am Südrand des Parks (Beschriftungs-Plan, Task 7). Auch initPos, damit der Streamer von Anfang an den richtigen`. + +- [ ] **Step 8: Suiten** + +Run: `cd tools/extract && uv run pytest -q && cd ../.. && npm test` +Expected: 114 passed; vitest ohne Fehler. + +- [ ] **Step 9: Commits** + +```bash +git add tools/extract/data/annotations.toml public/data/vice-a +git commit -m "feat(extract): alle Kartenbeschriftungen entfernt und Welt neu erzeugt + +Co-Authored-By: Claude Opus 5 " -- tools/extract/data/annotations.toml public/data/vice-a +``` + +```bash +git add src/main.js +git commit -m "fix(world): Start und Autos auf die wiederaufgebaute Straße am Park + +Co-Authored-By: Claude Opus 5 " -- src/main.js +``` + +--- + +### Task 8: Dokumente nachziehen und Sichtprüfung im Spiel + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md` +- Modify: `docs/superpowers/plans/2026-09-13-feinkalibrierung.md` +- Modify: `docs/superpowers/specs/2026-09-13-minimap-design.md` +- Modify: `docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md` + +- [ ] **Step 1: Feinkalibrierung — Spec** + +In `docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md` den Satz `Die Umsetzung beginnt, sobald die Beschriftungs-Aufgabe task_999fed4a gelandet ist, die derzeit in einer anderen Sitzung `tools/extract/run.py` ändert.` ersetzen durch: + +```markdown +Die Umsetzung beginnt, sobald die Beschriftungs-Aufgabe gelandet ist (`docs/superpowers/specs/2026-09-13-beschriftungen-design.md`, ersetzt task_999fed4a); sie hat `tools/extract/run.py` geändert und `chunk_for_world` in `tools/extract/leonida/coords.py` schon angelegt. Fehler des automatischen Wiederaufbaus unter früheren Beschriftungen werden über `corrections.toml` behoben. +``` + +- [ ] **Step 2: Feinkalibrierung — Plan** + +In `docs/superpowers/plans/2026-09-13-feinkalibrierung.md`: + +Die Zeile `- **Ausführungsschranke:** Dieser Plan beginnt erst, wenn die Beschriftungs-Aufgabe task_999fed4a gelandet ist. Sie ändert in einer anderen Sitzung die Pipeline. Aufgabe 6 ändert `tools/extract/run.py` und muss die Datei vorher neu lesen.` ersetzen durch: + +```markdown +- **Ausführungsschranke:** Dieser Plan beginnt erst, wenn der Beschriftungs-Plan (`docs/superpowers/plans/2026-09-13-beschriftungen.md`) gelandet ist. Er hat `tools/extract/run.py` geändert — Aufgabe 6 muss die Datei vorher neu lesen — und `chunk_for_world` samt Test schon angelegt: in Aufgabe 1 entfallen die Änderung der Importzeile und der Test `test_chunk_for_world_matches_javascript` in `tests/test_coords.py` sowie Step 3; `tests/test_coords.py` und `leonida/coords.py` fallen aus Step 7 heraus. +``` + +- [ ] **Step 3: Minimap-Spec** + +In `docs/superpowers/specs/2026-09-13-minimap-design.md`: + +Den Absatz, der mit `**Erzeugung:** vorerst als eigener Schritt` beginnt, vollständig ersetzen durch: + +```markdown +**Erzeugung:** am Ende von `tools/extract/run.py` über `render_map` aus `overview.py`, seit der Beschriftungs-Plan gelandet ist. `cd tools/extract && uv run python overview.py` erzeugt dieselben Dateien weiterhin einzeln, etwa nach einer Änderung nur an den Farben. `manifest_sha256` sichert ab, dass Karte und Chunks zusammenpassen (siehe Fehlerfälle). +``` + +Die Zeile `- Einhängen der Kartenerzeugung in `run.py` — folgt, sobald die Beschriftungs-Aufgabe gelandet ist.` löschen. + +- [ ] **Step 4: Vice-Slice-Spec** + +In `docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md` die Stelle `Start ist die Promenade von Vice Beach bei X 1699, Y 743 (three.js: x = 1699, z = -743),` ersetzen durch: + +```markdown +Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743 auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan —, +``` + +- [ ] **Step 5: Sichtprüfung im Spiel** + +Dev-Server starten und im Browser-Panel öffnen. Läuft auf Port 5173 schon der Server einer anderen Sitzung, einen zweiten auf 5180 starten (vorübergehender Eintrag in `.claude/launch.json` mit `npm run dev -- --port 5180 --strictPort`, danach wieder entfernen). Hinweise, falls das Panel verborgen ist, stehen in der Gedächtnisnotiz „Browser verification with hidden pane“: `requestAnimationFrame` über einen Worker-Ticker antreiben, Zeigersperre vortäuschen, zwei Screenshots nehmen und dem zweiten trauen. + +Prüfen und je Punkt einen Screenshot festhalten: +1. Konsole ohne Fehler; `fetch` von `data/vice-a/map.json` und der Chunks ohne 404. +2. Start: Die Figur steht nach dem Laden auf dem Rasen bei x 1600 / y 690 und fällt nicht; die vier Autos stehen auf der Straße südlich davon. +3. Minimap (Taste M): keine Schrift, keine Marker, keine Schilder, kein Gitternetz; VICE BEACH mit freiem Park. +4. Über die Minimap nach CROSSTOWN (B5) und VICE BEACH (K7) springen; Straßen unter den früheren Schriftzügen sind befahrbar. + +Ergebnisse und Screenshots an den Controller melden. + +- [ ] **Step 6: Commit** + +```bash +git add docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md docs/superpowers/plans/2026-09-13-feinkalibrierung.md docs/superpowers/specs/2026-09-13-minimap-design.md docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md +git commit -m "docs: Folgen der Beschriftungs-Entfernung für Feinkalibrierung, Minimap und Start + +Co-Authored-By: Claude Opus 5 " -- docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md docs/superpowers/plans/2026-09-13-feinkalibrierung.md docs/superpowers/specs/2026-09-13-minimap-design.md docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md +``` diff --git a/docs/superpowers/specs/2026-09-13-beschriftungen-design.md b/docs/superpowers/specs/2026-09-13-beschriftungen-design.md index a09d774..b49fcf4 100644 --- a/docs/superpowers/specs/2026-09-13-beschriftungen-design.md +++ b/docs/superpowers/specs/2026-09-13-beschriftungen-design.md @@ -177,7 +177,7 @@ Die Farbanteil-Grenzen in `run.py` bleiben unverändert und prüfen das bereinig ``` Beschriftungen: 107 angewendet, 0 übersprungen · Gitternetz: 14 Linien #64 VICE BEACH (MIAMI BEACH) · K7 · Maske 46.296 m² · Straßen 7 verbunden, 1 weitergeführt, 0 Sackgassen · Gebäude 0 geschlossen, 2 ergänzt - #87 (BAYFRONT) · C10 · Maske 8.926 m² · gemeinsam mit #86 + #86 (BAYFRONT) · C10 · Maske 8.926 m² · gemeinsam mit #85 Warnungen: #52 L1/42 · C6 · angeschnitten: Rahmen prüfen ``` @@ -211,7 +211,7 @@ Der Chunk ist der des Rahmenmittelpunkts über `chunk_for_world`. Zählt eine Ma 3. **Lauf** ohne Formfehler; jede Warnung ist behoben oder begründet. 4. **Prüfbilder:** Claude sieht alle an und schickt dem Nutzer die großen Schriftzüge und jede zweifelhafte Stelle. 5. **Kennzahlen vorher und nachher:** Brücken auf Land (das Stück in L8 ist weg), Löcher unter 2.000 m² in der Landplatte (die zwei in L8 sind weg), Gebäudezahl samt ergänzten, Farbanteile innerhalb der Grenzen. -6. **Im Spiel:** Minimap zeigt keine Schrift mehr; Fahrt durch VICE BEACH und CROSSTOWN mit Screenshots. +6. **Im Spiel:** Die Figur startet auf begehbarem Boden und die Autos stehen auf der Straße; Minimap zeigt keine Schrift mehr; Fahrt durch VICE BEACH und CROSSTOWN mit Screenshots. 7. `cd tools/extract && uv run pytest -q` und `npm test` sauber. ## Probelauf @@ -227,6 +227,7 @@ Vor dem Plan lief das ganze Verfahren als Wegwerf-Prototyp gegen die echte Vorla - `docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md` und `docs/superpowers/plans/2026-09-13-feinkalibrierung.md`: Die Ausführungsschranke nennt diesen Entwurf statt task_999fed4a. `chunk_for_world` ist dann vorhanden; Aufgabe 1 des Plans legt die Funktion nicht erneut an. Fehler des Wiederaufbaus an einzelnen Stellen werden dort über `corrections.toml` behoben. - `docs/superpowers/specs/2026-09-13-minimap-design.md`: Die Kartenerzeugung hängt jetzt am Ende von `run.py`; der Hinweis „folgt, sobald die Beschriftungs-Aufgabe gelandet ist“ entfällt. +- `src/main.js` und `docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md`: Start (`SPAWN`, X 1699 / Y 743) und die vier Autos (`VEHICLE_SPAWNS`) standen auf der „Straße“, die die Buchstabenkonturen von „(MIAMI BEACH)“ bildeten. Am Prototyp stehen danach die Figur und ein Auto in einem ergänzten Gebäude. Sie ziehen auf die wiederaufgebaute Ost-West-Straße am Südrand des Parks (Autos bei X 1560, 1590, 1630, 1655, Y 658–663) und auf den Rasen nördlich davon (Figur bei X 1600 / Y 690), alle weiter in K8; ein Prüfskript bestätigt begehbaren Boden und Straße. ## Nicht Teil dieses Entwurfs