# 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 ```