fix(extract): Brückenerkennung per Verschneidung, Mittellinien ohne Dopplung
Fix-Runde 1, drei Befunde: - road_polygons: Straßennetz ist ein zusammenhängendes Polygon über den ganzen Ausschnitt; eine Fensterprüfung um seine Bounding-Box sieht daher zwangsläufig Wasser und meldet fast alles als Brücke. Ersetzt durch Verschneidung jedes Straßenpolygons mit der Landfläche (Ruling 13). Die wörtlich vorgegebene Landberechnung (unary_union(land_polygons(labels))) besteht die bestehenden Tests nicht: eine Straße ist nie "wasser", also zählt land_polygons() sie selbst als Land, und ein Damm quer durchs Meer überlappt dann mit sich selbst statt als Brücke aufzufallen (siehe Fix-Bericht für den Nachweis). Land wird stattdessen ohne die Straßenklasse berechnet und um LAND_REACH_PX gewachsen, damit eine Straße, die direkt an Land beginnt, dort noch erkannt wird. Der Radius wurde am echten Mosaik kalibriert: unter 8 px verkettet sich eine gewöhnliche Küstenstraße mangels Berührung mit "Land" erneut zu einem kartenweiten Brückenstück; 10 px behebt das mit Sicherheitsabstand zur Wasserlücke des bestehenden Tests. - centerlines: eine Kontur um das ein Pixel breite Skelett lief auf beiden Seiten des Bandes hin und zurück und lieferte jede Linie doppelt; an Kreuzungen ergab das eine einzelne Schleife statt getrennter Äste (Ruling 14, neue Tests zuerst rot gesehen). Ersetzt durch einen Pixelgraphen (networkx): Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten, Pfade zwischen Knoten mit Grad ungleich 2 je Zusammenhangskomponente, reine Ringe einmal umlaufen. - mask_polygons: Polygon(ring).buffer(0) kann bei selbstschneidenden Konturen ein MultiPolygon liefern; das schlug später mit AttributeError fehl. Beim Einsortieren in outers/holes wird jetzt nur noch ein sauberes Polygon akzeptiert (Ruling 15). 16/16 Tests grün. Auf dem echten Mosaik: 469 Straßenstücke (190 Brücken, größtes Brückenstück 26.294 m² auf einer lokalen Fläche von 643 x 564 m, nicht mehr kartenweit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb898b03b3
commit
47c3cb98da
2 files changed
+101
-30
No files matched your search
@@ -1,6 +1,8 @@
|
||||
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 closing, disk, skeletonize
|
||||
|
||||
@@ -24,7 +26,7 @@ def mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Po
|
||||
if len(ring) < 4:
|
||||
continue
|
||||
poly = Polygon(ring).buffer(0)
|
||||
if poly.is_empty or poly.area < min_area:
|
||||
if poly.is_empty or poly.area < min_area or poly.geom_type != "Polygon":
|
||||
continue
|
||||
(outers if _signed_area(local) > 0 else holes).append(poly)
|
||||
result = []
|
||||
@@ -60,41 +62,97 @@ def road_mask(labels: np.ndarray) -> np.ndarray:
|
||||
return closing(mask, disk(3))
|
||||
|
||||
|
||||
# Rand in Pixeln (= Metern) um die Straßenfläche zur Brückenprüfung: die Straßenfläche
|
||||
# selbst ist per Definition nie "wasser", daher muss das Fenster über ihren Rand
|
||||
# hinausschauen, um das Wasser zu sehen, das sie überquert.
|
||||
BRIDGE_MARGIN_PX = 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
|
||||
|
||||
|
||||
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 und Brückenstücke über Wasser.
|
||||
|
||||
"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.
|
||||
"""
|
||||
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))
|
||||
out = []
|
||||
for poly in polys:
|
||||
r0, c0, r1, c1 = _bbox_px(poly)
|
||||
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):
|
||||
for part, bridge in ((poly.intersection(land), False), (poly.difference(land), True)):
|
||||
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
|
||||
@@ -1,6 +1,6 @@
|
||||
import numpy as np
|
||||
from leonida.classify import CLASSES
|
||||
from leonida.vectors import land_polygons, mask_polygons, road_polygons
|
||||
from leonida.vectors import centerlines, land_polygons, mask_polygons, road_polygons
|
||||
|
||||
def test_square_becomes_one_polygon_with_right_area():
|
||||
mask = np.zeros((100, 100), dtype=bool)
|
||||
@@ -31,3 +31,16 @@ def test_crossing_roads_form_a_single_polygon():
|
||||
labels[:, 28:32] = CLASSES["strasse"]
|
||||
roads = road_polygons(labels)
|
||||
assert len(roads) == 1 # Kreuzung, kein Zerfall in vier Arme
|
||||
|
||||
def test_centerline_runs_once_along_a_straight_road():
|
||||
labels = np.full((60, 300), CLASSES["land"], dtype=np.uint8)
|
||||
labels[28:38, 50:250] = CLASSES["strasse"] # 200 lang, 10 breit
|
||||
lines = centerlines(labels)
|
||||
assert len(lines) == 1
|
||||
assert 180 <= lines[0].length <= 215 # einmal entlang, nicht hin und zurück
|
||||
|
||||
def test_crossing_roads_yield_separate_centerlines():
|
||||
labels = np.full((120, 120), CLASSES["land"], dtype=np.uint8)
|
||||
labels[55:65, :] = CLASSES["strasse"]
|
||||
labels[:, 55:65] = CLASSES["strasse"]
|
||||
assert len(centerlines(labels)) >= 3 # Arme werden an der Kreuzung getrennt
|
||||
Reference in new issue
Block a user