Acht Aufgaben, Code und Liste aus dem gegen die echte Vorlage geprüften Prototyp; in Plan-Reihenfolge in einer Repository-Kopie nachgespielt. Spec: Start und Autos ziehen von der Buchstabenkontur auf die wiederaufgebaute Straße am Park, Nummern im Berichtsbeispiel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 KiB
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.tomlsind 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)mitSLICE = {"x0": -1000, "x1": 2000, "y0": -500, "y1": 2500}. - Chunk-Namen A1–L12: Spalte aus
i(West → Ost), Zeile ausj(Nord → Süd). K7 =chunk_10_6. - Formfehler brechen mit
AnnotationErrorab — Meldung beginnt mitBeschriftung N:,Ausnahme N:oderGitternetz:. 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. Niemalsgit add three-player-controller, niemalsgit add -A. Commits nur mit ausdrücklichen Pfaden:git add <pfade>undgit commit -m "…" -- <pfade>. - Alle Python-Befehle laufen in
tools/extract:cd tools/extract && uv run …. Die Kacheln liegen intools/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_nameausleonida/coords.py -
Produces:
chunk_for_world(x: float, y: float) -> tuple[int, int]inleonida/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 Eigenschaftenoutline -> Polygonundchunk -> 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:
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:
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:
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_worldantools/extract/leonida/coords.pyanfügen
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.pyanlegen
"""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
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 <noreply@anthropic.com>" -- 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:
CLASSESausleonida/classify.py,_skeleton_graph(skeleton: np.ndarray) -> nx.Graphausleonida/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ülltfill_nearest(labels: np.ndarray, target: np.ndarray, blocked: np.ndarray) -> None— ändertlabelsan Ort und Stelleis_thin(mask: np.ndarray) -> boolrebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult— ändertlabelsan Ort und Stelle; in dieser Aufgabe Straßen, Wege und Flächen
-
Step 1: Die fehlschlagenden Tests schreiben
tools/extract/tests/test_inpaint.py:
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.pyanlegen
"""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
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 <noreply@anthropic.com>" -- 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.buildingsenthält die Ecken jedes ergänzten Rechtecks in Bildkoordinaten (Zeile, Spalte);RebuildStats.closed,.addedund.note(„zu wenig Nachbarn“) werden gesetzt -
Step 1: Die fehlschlagenden Tests an
tools/extract/tests/test_inpaint.pyanfügen
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.pyvollständig ersetzen durch
"""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
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 <noreply@anthropic.com>" -- 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,rebuildausleonida/inpaint.py;PALETTEausleonida/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) -> FoundMaskgrid_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)mitreport() -> strremove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: AnnotationList) -> Removal— gibt eine Kopie zurück,generatedsind Weltpolygonemark_generated(polygons: list[Polygon], heights: list[dict], generated: list[Polygon]) -> int
-
Step 1: Die fehlschlagenden Tests schreiben
tools/extract/tests/test_annotations_remove.py:
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.pyvollständig ersetzen durch
"""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
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 <noreply@anthropic.com>" -- 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_generatedausleonida/annotations.py;render_map(out_dir: Path, dst_dir: Path) -> dictausoverview.py -
Step 1:
tools/extract/run.pyvollstä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:
from leonida.annotations import load_annotations, mark_generated, remove_annotations
Nach from leonida.vectors import centerlines, land_polygons, mask_polygons, road_polygons einfügen:
from overview import render_map
Direkt nach labels = drop_map_labels(labels) einfügen:
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:
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:
print(render_map(OUT, OUT), "->", OUT)
- Step 3: Nullauf — ohne
data/annotations.tomlmuss 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
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 <noreply@anthropic.com>" -- 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_annotationsausleonida/annotations.py;SLICE,chunk_for_world,chunk_nameausleonida/coords.py;load_mosaicausleonida/tiles.py -
Produces:
@dataclass(frozen=True) Spot(kind: str, rows: tuple[int, int], cols: tuple[int, int])mitcentreundchunk;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:
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.pyanlegen
"""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
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 <noreply@anthropic.com>" -- 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):
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.tomlanlegen
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.
# 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:
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:
const SPAWN = { x: 1600, y: 3, z: -690 };
und die vier Einträge von VEHICLE_SPAWNS ersetzen durch:
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
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 <noreply@anthropic.com>" -- tools/extract/data/annotations.toml public/data/vice-a
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 <noreply@anthropic.com>" -- 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:
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:
- **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:
**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:
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:
- Konsole ohne Fehler;
fetchvondata/vice-a/map.jsonund der Chunks ohne 404. - 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.
- Minimap (Taste M): keine Schrift, keine Marker, keine Schilder, kein Gitternetz; VICE BEACH mit freiem Park.
- Ü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
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 <noreply@anthropic.com>" -- 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