docs: Plan auf Rulings 13-17 umstellen (Brücken, Mittellinien)
Brückenerkennung im Plan von der Bounding-Box auf die Verschneidung
umgestellt und um die Wassernähe ergänzt, Mittellinien über den
Pixelgraphen statt über Konturen. Der Plan zeigt jetzt den Code, der
in 120cafd tatsächlich gebaut wurde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
120cafd7bf
commit
830e9f87e0
1 file changed
+111
-29
@@ -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**
|
||||
|
||||
|
||||
Reference in new issue
Block a user