From 120cafd7bfc0fc6061081620a6561287666958b2 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sat, 12 Sep 2026 15:14:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(extract):=20Br=C3=BCcke=20braucht=20N=C3=A4?= =?UTF-8?q?he=20zu=20Wasser,=20nicht=20nur=20Ferne=20von=20Land?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/extract/leonida/vectors.py | 25 +++++++++++++++++++++++-- tools/extract/tests/test_vectors.py | 12 ++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tools/extract/leonida/vectors.py b/tools/extract/leonida/vectors.py index b057885..c2375f1 100644 --- a/tools/extract/leonida/vectors.py +++ b/tools/extract/leonida/vectors.py @@ -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. 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]]: - """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 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 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 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]): if piece.geom_type == "Polygon" and piece.area >= 20: out.append((piece, bridge)) diff --git a/tools/extract/tests/test_vectors.py b/tools/extract/tests/test_vectors.py index 878b6ba..081638b 100644 --- a/tools/extract/tests/test_vectors.py +++ b/tools/extract/tests/test_vectors.py @@ -44,3 +44,15 @@ def test_crossing_roads_yield_separate_centerlines(): labels[55:65, :] = CLASSES["strasse"] labels[:, 55:65] = CLASSES["strasse"] 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)