fix(extract): Brücke braucht Nähe zu Wasser, nicht nur Ferne von Land

Fix-Runde 2 (Ruling 17). Nachmessung am echten Mosaik zeigte: 143 der 190 aus
Fix-Runde 1 markierten Brücken (137.288 von 208.744 m² Fläche) hatten unter
10 % Wasser in ihrer Umgebung, darunter die beiden größten Stücke mit 2,4 %
und 0,1 % Wasser. Das waren breite Kreuzungs- und Parkflächen an Land: ihre
Mitte liegt allein deshalb weiter als LAND_REACH_PX von straßenfreiem Land
entfernt, weil dort ringsum nur Straße ist – kein Bezug zu Wasser. Ein
größeres LAND_REACH_PX heilt das nicht (ab 16 kippt der bestehende Test).

Straßenstücke jenseits von "Land" zählen jetzt nur noch als Brücke, wenn sie
zusätzlich innerhalb WATER_REACH_PX von Wasser liegen; neue Konstante
WATER_REACH_PX = 8, am echten Mosaik kalibriert (6-10 ein stabiles Plateau
bei ~40 Brücken, ab 14 bricht es weg und reißt wieder trockene Flächen mit).

Regressionstest zuerst rot gesehen (test_wide_road_area_on_land_is_not_a_bridge),
bestehende Tests unverändert und weiterhin grün. 17/17 Tests grün. Auf dem
echten Mosaik: 475 Straßenstücke, 40 Brücken, größtes Brückenstück 12.588 m² –
deckt sich mit den unabhängig ermittelten Referenzwerten der Prüfung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-12 15:14:12 +02:00
1 parent 47c3cb98da
commit 120cafd7bf
2 files changed
+35 -2

No files matched your search

+23 -2
View File
@@ -72,9 +72,17 @@ def road_mask(labels: np.ndarray) -> np.ndarray:
# zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge. # zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge.
LAND_REACH_PX = 10 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]]: def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
"""Straßenflächen, aufgeteilt in Stücke auf Land und Brückenstücke über Wasser. """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 "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() außen gewachsen. Ohne den Ausschluss der Straße wäre das zirkulär: land_polygons()
@@ -83,14 +91,27 @@ def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
Brücke auffallen. Der Wuchs sorgt dafür, dass eine Straße, die direkt an echtem 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 Land beginnt, dort noch als "auf Land" erkannt wird, statt an ihrer eigenen Kante
ins Leere zu greifen. 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( solid_ground = ndi.binary_dilation(
(labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX) (labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX)
) )
land = unary_union(mask_polygons(solid_ground, min_area=50, simplify=1.0)) 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 = [] out = []
for poly in mask_polygons(road_mask(labels), min_area=20, simplify=0.8): 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)): 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]): for piece in getattr(part, "geoms", [part]):
if piece.geom_type == "Polygon" and piece.area >= 20: if piece.geom_type == "Polygon" and piece.area >= 20:
out.append((piece, bridge)) out.append((piece, bridge))
+12
View File
@@ -44,3 +44,15 @@ def test_crossing_roads_yield_separate_centerlines():
labels[55:65, :] = CLASSES["strasse"] labels[55:65, :] = CLASSES["strasse"]
labels[:, 55:65] = CLASSES["strasse"] labels[:, 55:65] = CLASSES["strasse"]
assert len(centerlines(labels)) >= 3 # Arme werden an der Kreuzung getrennt assert len(centerlines(labels)) >= 3 # Arme werden an der Kreuzung getrennt
def test_wide_road_area_on_land_is_not_a_bridge():
"""Eine breite Kreuzungsfläche ohne Wasser in der Nähe ist nie eine Brücke.
Ihre Mitte liegt weiter als LAND_REACH_PX von straßenfreiem Land entfernt, weil
dort nur Straße ist — das allein darf sie nicht zur Brücke machen.
"""
labels = np.full((120, 120), CLASSES["land"], dtype=np.uint8)
labels[30:90, 30:90] = CLASSES["strasse"] # 60x60 Straßenfläche, kein Wasser
pieces = road_polygons(labels)
assert pieces
assert not any(bridge for _, bridge in pieces)