diff --git a/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md b/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md index 3bb9fb4..74e70f4 100644 --- a/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md +++ b/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md @@ -690,18 +690,18 @@ Expected: FAIL, `ModuleNotFoundError: No module named 'leonida.vectors'` - [ ] **Step 3: `leonida/vectors.py` schreiben** ```python +import networkx as nx import numpy as np from scipy import ndimage as ndi from shapely.geometry import LineString, Polygon from shapely.ops import unary_union from skimage.measure import find_contours -from skimage.morphology import binary_closing, disk, skeletonize +from skimage.morphology import closing, disk, skeletonize from .classify import CLASSES from .coords import SLICE ROAD_CLASSES = ("strasse", "weg", "spekulativ") -BRIDGE_MARGIN_PX = 3 def _to_world(contour: np.ndarray) -> list[tuple[float, float]]: @@ -750,48 +750,130 @@ def road_mask(labels: np.ndarray) -> np.ndarray: for name in ROAD_CLASSES: mask |= labels == CLASSES[name] # Schließen überbrückt die eingezeichneten Straßennamen. - return binary_closing(mask, disk(3)) + return closing(mask, disk(3)) + + +# Wuchs der Landmaske in Pixeln (= Metern) für die Brückenprüfung. Am echten Mosaik +# gemessen: 3-5 px reichen nicht, weil Bordstein/Anti-Aliasing zwischen "land" und der +# angrenzenden Straße auf Karten mit echtem Rauschen mehr Lücke lassen als im +# künstlichen Test. Ist der Wuchs zu knapp, berührt eine ganz normale Küstenstraße +# nirgends "Land" und verkettet sich mit jeder anderen zu knappen Stelle zu einem +# Brückenstück, das (wie der ursprüngliche Fensteransatz) fast die ganze Karte +# überspannt. Ab 8 px reißt diese Kette am echten Mosaik; 10 px lässt zusätzlich Raum +# zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge. +LAND_REACH_PX = 10 + +# Reichweite in Pixeln (= Metern) für die "nahe am Wasser"-Prüfung: fern von Land allein +# macht noch keine Brücke (siehe unten). Am echten Mosaik gemessen (WATER_REACH_PX → +# Brücken gesamt / davon "trocken" mit < 10 % Wasser im Umkreis: 6→40/1, 8→40/1, +# 10→41/2, 14→72/17) ist 6 bis 10 ein stabiles Plateau, das ab 14 abbricht und wieder +# trockene Flächen als Brücke mitreißt; 8 liegt in dessen Mitte. +WATER_REACH_PX = 8 def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]: - water = labels == CLASSES["wasser"] - polys = mask_polygons(road_mask(labels), min_area=20, simplify=0.8) + """Straßenflächen, aufgeteilt in Stücke auf Land, Brückenstücke über Wasser und + breite Flächen an Land, die nur zufällig weit von "Land" entfernt liegen. + + "Land" ist hier die Geländeklasse ohne die Straße selbst, um LAND_REACH_PX nach + außen gewachsen. Ohne den Ausschluss der Straße wäre das zirkulär: land_polygons() + zählt "nicht Wasser" als Land, und eine Straße ist nie "wasser" – ein Damm quer + durchs Meer würde dann fast vollständig mit sich selbst überlappen und nie als + Brücke auffallen. Der Wuchs sorgt dafür, dass eine Straße, die direkt an echtem + Land beginnt, dort noch als "auf Land" erkannt wird, statt an ihrer eigenen Kante + ins Leere zu greifen. + + "Fern von Land" reicht als Brückenkriterium allein nicht aus: eine breite Kreuzungs- + oder Parkfläche ist überall nur Straße, ihre Mitte liegt also zwangsläufig weiter als + LAND_REACH_PX von straßenfreiem Land entfernt, obwohl gar kein Wasser in der Nähe + ist. Ein Stück jenseits von "Land" zählt deshalb nur dann als Brücke, wenn es + zusätzlich innerhalb WATER_REACH_PX von Wasser liegt; der Rest – fern von Land, aber + auch fern von Wasser – bleibt "auf Land". + """ + solid_ground = ndi.binary_dilation( + (labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX) + ) + land = unary_union(mask_polygons(solid_ground, min_area=50, simplify=1.0)) + wet = unary_union(mask_polygons( + ndi.binary_dilation(labels == CLASSES["wasser"], disk(WATER_REACH_PX)), + min_area=50, simplify=1.0)) out = [] - for poly in polys: - r0, c0, r1, c1 = _bbox_px(poly) - # Ohne Rand enthält das Fenster nur Straßenpixel und damit nie Wasser. - r0 = max(r0 - BRIDGE_MARGIN_PX, 0) - c0 = max(c0 - BRIDGE_MARGIN_PX, 0) - r1 = min(r1 + BRIDGE_MARGIN_PX, water.shape[0] - 1) - c1 = min(c1 + BRIDGE_MARGIN_PX, water.shape[1] - 1) - window = water[r0:r1 + 1, c0:c1 + 1] - out.append((poly, bool(window.mean() > 0.3))) + for poly in mask_polygons(road_mask(labels), min_area=20, simplify=0.8): + off = poly.difference(land) + for part, bridge in ((poly.intersection(land), False), # auf Land + (off.intersection(wet), True), # über Wasser: Brücke + (off.difference(wet), False)): # breite Fläche an Land + for piece in getattr(part, "geoms", [part]): + if piece.geom_type == "Polygon" and piece.area >= 20: + out.append((piece, bridge)) return out -def _bbox_px(poly: Polygon) -> tuple[int, int, int, int]: - minx, miny, maxx, maxy = poly.bounds - return (int(SLICE["y1"] - maxy), int(minx - SLICE["x0"]), - int(SLICE["y1"] - miny), int(maxx - SLICE["x0"])) - - def centerlines(labels: np.ndarray) -> list[LineString]: - """Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen.""" - skeleton = skeletonize(road_mask(labels)) + """Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen. + + Eine Kontur um das ein Pixel breite Skelett liefert jede Linie doppelt (sie umrundet + das Band und läuft auf der anderen Seite zurück). Stattdessen wird das Skelett als + Pixelgraph gelesen: Skelettpixel sind Knoten, 8er-Nachbarschaft ist eine Kante. Je + Zusammenhangskomponente werden die Pfade zwischen Knoten mit Grad ungleich 2 verfolgt + (Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird + einmal umlaufen. + """ + graph = _skeleton_graph(skeletonize(road_mask(labels))) lines = [] - for contour in find_contours(skeleton.astype(float), 0.5): - pts = _to_world(contour) - if len(pts) >= 2: - line = LineString(pts).simplify(1.5) - if line.length >= 15: - lines.append(line) + for path in _skeleton_paths(graph): + line = LineString(_to_world(path)).simplify(1.5) + if line.length >= 15: + lines.append(line) return lines + + +def _skeleton_graph(skeleton: np.ndarray) -> nx.Graph: + """Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten.""" + pixels = set(zip(*np.nonzero(skeleton))) + graph = nx.Graph() + graph.add_nodes_from(pixels) + for r, c in pixels: + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + neighbour = (r + dr, c + dc) + if (dr or dc) and neighbour in pixels: + graph.add_edge((r, c), neighbour) + return graph + + +def _skeleton_paths(graph: nx.Graph) -> list[list[tuple[int, int]]]: + """Pfade zwischen Enden/Kreuzungen (Grad ≠ 2) je Zusammenhangskomponente; + ein reiner Ring aus lauter Grad-2-Knoten wird einmal umlaufen.""" + paths, seen = [], set() + for component in nx.connected_components(graph): + sub = graph.subgraph(component) + branch_nodes = [n for n in sub if sub.degree(n) != 2] + if not branch_nodes: + start = next(iter(component)) + ring = list(nx.dfs_preorder_nodes(sub, start)) + ring.append(start) + paths.append(ring) + continue + for start in branch_nodes: + for first in sub.neighbors(start): + if frozenset((start, first)) in seen: + continue + path, prev, current = [start, first], start, first + seen.add(frozenset((start, first))) + while sub.degree(current) == 2: + nxt = next(n for n in sub.neighbors(current) if n != prev) + seen.add(frozenset((current, nxt))) + path.append(nxt) + prev, current = current, nxt + paths.append(path) + return paths ``` - [ ] **Step 4: Test laufen lassen, er muss bestehen** Run: `cd tools/extract && uv run pytest tests/test_vectors.py -v` -Expected: PASS, 4 Tests +Expected: PASS, 5 Tests - [ ] **Step 5: Commit**