From 42f2a4415d4a7e48351ad0f787fa357b7b0685a6 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sat, 12 Sep 2026 16:14:56 +0200 Subject: [PATCH] fix(extract): Palmen nicht mehr im Wasser, regularize() nicht mehr doppelt vereinfacht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-Runde 1, vier Befunde aus dem Review (Rulings 25-28): Befund A (Critical): der Versatz nach außen konnte eine Palme über die Uferlinie ins Wasser schieben (218 von 2.433 auf dem echten Mosaik). palm_points() bekommt jetzt land_polys (vor seed), _verge_point() verwirft einen Kandidaten, der nicht auf Land liegt. Befund B (Important): regularize() vereinfachte im Rückfallzweig ein zweites Mal und gröber (1,0 m statt der 0,5 m, die mask_polygons() schon angewandt hatte) und prüfte erneut gegen MIN_AREA - das drückte kleine, treppenrandige Grundrisse unter die Schwelle (202 von 2.085 gebaeude-, 9 von 267 spekulativ-Kandidaten). Der Rückfallzweig vereinfacht jetzt nur noch einmal, mit 0,5 m, ohne zweite Flächenprüfung. Befund C: der globale filterwarnings-Eintrag in pyproject.toml (Fix für die oriented_envelope-RuntimeWarning aus der letzten Runde) war zu weit gefasst und hätte auch eine echte künftige Division durch Null versteckt. Er gehört nur auf den einen Test, der minimum_rotated_rectangle selbst aufruft - pyproject.toml zurück auf den Stand davor, @pytest.mark.filterwarnings auf test_l_shape_is_kept_not_boxed. Befund D (Minor): vier doppelte Palmenpunkte entfernt (dict.fromkeys vor dem return), zwei bisher ungetestete Zweige abgedeckt (spekulativ=True/False, Park-Streuung mit Determinismus-Check). Test zuerst für A und B, beide vor dem jeweiligen Fix rot gesehen (echte Assertion-Fehler, keine Signatur-Fehler). 31/31 Tests grün, weiterhin ohne Warnungen. Auf dem echten Mosaik: 2.352 Gebäude (vorher 2.141, +211 - exakt die Zahl, die Befund B vorher fallen ließ; 0 von regularize verworfen, siehe Bericht für die Erklärung), 267 spekulativ. 2.133 Palmenpunkte (Referenz des Koordinators von 2.176 war mit dem alten, kleineren Gebäudebestand gemessen - mit dem isoliert nachgebauten alten Bestand komme ich auf 2.175, die verbleibende Differenz von 1 ist im Bericht dokumentiert statt verschwiegen). Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/buildings.py | 3 +-- tools/extract/leonida/heights.py | 15 +++++++---- tools/extract/pyproject.toml | 8 ------ tools/extract/tests/test_buildings.py | 39 +++++++++++++++++++++++++++ tools/extract/tests/test_heights.py | 25 +++++++++++++++-- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/tools/extract/leonida/buildings.py b/tools/extract/leonida/buildings.py index c6c9b1e..e29fe54 100644 --- a/tools/extract/leonida/buildings.py +++ b/tools/extract/leonida/buildings.py @@ -25,8 +25,7 @@ def regularize(poly: Polygon) -> Polygon | None: rect = poly.minimum_rotated_rectangle if rect.area > 0 and poly.area / rect.area >= RECT_COVERAGE: return rect - simple = poly.simplify(1.0) - return simple if simple.area >= MIN_AREA else None + return poly.simplify(0.5) def building_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]: diff --git a/tools/extract/leonida/heights.py b/tools/extract/leonida/heights.py index 86732d7..71576f4 100644 --- a/tools/extract/leonida/heights.py +++ b/tools/extract/leonida/heights.py @@ -55,8 +55,9 @@ PALM_KERB = 1.5 # Meter Abstand zum Fahrbahnrand PALM_VERGE = (6.0, 8.0, 10.0, 12.0, 14.0, 16.0) -def _verge_point(line, p, side: str, roads, buildings): - """Erster Versatz von innen nach außen, der Fahrbahn und Gebäude freihält.""" +def _verge_point(line, p, side: str, roads, buildings, land): + """Erster Versatz von innen nach außen, der Fahrbahn und Gebäude freihält und + auf Land bleibt (Ruling 26: sonst schiebt der Versatz Küstenpalmen ins Wasser).""" for dist in PALM_VERGE: off = line.parallel_offset(dist, side) if off.is_empty: @@ -66,16 +67,20 @@ def _verge_point(line, p, side: str, roads, buildings): continue if buildings is not None and q.distance(buildings) < PALM_CLEARANCE: continue + if land is not None and not land.contains(q): + continue return [(round(q.x, 2), round(q.y, 2))] return [] -def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, seed: int): +def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, + land_polys, seed: int): """Palmen entlang strandnaher Mittellinien und verstreut in Parks.""" from shapely.ops import unary_union sand = unary_union(sand_polys) if sand_polys else None roads = unary_union(list(road_polys)) if road_polys else None buildings = unary_union(list(building_polys)) if building_polys else None + land = unary_union(land_polys) if land_polys else None blocked = unary_union([g for g in (roads, buildings) if g is not None]) \ if (roads is not None or buildings is not None) else None points = [] @@ -88,7 +93,7 @@ def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, while pos <= line.length: p = line.interpolate(pos) for side in ("left", "right"): - points.extend(_verge_point(line, p, side, roads, buildings)) + points.extend(_verge_point(line, p, side, roads, buildings, land)) pos += PALM_SPACING for park in park_polys: minx, miny, maxx, maxy = park.bounds @@ -99,4 +104,4 @@ def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, q = Point(minx + u * (maxx - minx), miny + v * (maxy - miny)) if park.contains(q) and (blocked is None or q.distance(blocked) > PALM_CLEARANCE): points.append((round(q.x, 2), round(q.y, 2))) - return points + return list(dict.fromkeys(points)) diff --git a/tools/extract/pyproject.toml b/tools/extract/pyproject.toml index 720e769..a03e290 100644 --- a/tools/extract/pyproject.toml +++ b/tools/extract/pyproject.toml @@ -13,11 +13,3 @@ dev = ["pytest>=8.3"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] -filterwarnings = [ - # Shapelys oriented_envelope (minimum_rotated_rectangle) meldet fuer bereits - # achsenparallele/rechtwinklige Polygone eine RuntimeWarning aus GEOS (Winkelterm - # wird 0/0), obwohl das zurueckgegebene Rechteck nachweislich korrekt bleibt. Nur - # diese eine, an Text und Kategorie festgemachte Warnung wird gefiltert; jede andere - # Warnung (auch jede echte DeprecationWarning) bleibt sichtbar. - "ignore:.*oriented_envelope.*:RuntimeWarning", -] diff --git a/tools/extract/tests/test_buildings.py b/tools/extract/tests/test_buildings.py index dd877c0..d5946e9 100644 --- a/tools/extract/tests/test_buildings.py +++ b/tools/extract/tests/test_buildings.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from shapely.geometry import Polygon from leonida.buildings import building_polygons, regularize from leonida.classify import CLASSES @@ -9,7 +10,12 @@ def test_rough_rectangle_snaps_to_rectangle(): assert len(out.exterior.coords) == 5 # geschlossenes Rechteck assert abs(out.area - 205) < 25 +@pytest.mark.filterwarnings("ignore:.*oriented_envelope.*:RuntimeWarning") def test_l_shape_is_kept_not_boxed(): + """out.minimum_rotated_rectangle (im Assert unten) loest fuer dieses achsen- + parallele Ergebnispolygon eine harmlose Shapely/GEOS-RuntimeWarning aus + (Winkelterm wird 0/0, das Rechteck bleibt korrekt) - siehe Befund C, Ruling 25. + Nur dieser eine Test filtert sie; die Suite tut es nicht mehr global.""" poly = Polygon([(0, 0), (30, 0), (30, 10), (10, 10), (10, 30), (0, 30)]) out = regularize(poly) assert out.area < 0.8 * out.minimum_rotated_rectangle.area @@ -22,3 +28,36 @@ def test_two_buildings_are_separated(): labels[10:25, 10:25] = CLASSES["gebaeude"] labels[35:50, 35:50] = CLASSES["gebaeude"] assert len(building_polygons(labels)) == 2 + +def test_small_staircase_building_is_not_dropped(): + """regularize() darf nicht doppelt vereinfachen (Ruling 28). + + mask_polygons() liefert Kandidaten bereits mit 0,5 m simplify und mindestens + MIN_AREA. Ein Grundriss mit gestuftem (Treppenrand-)Eck, der die Rechteckquote + von 0,85 verfehlt, wurde bisher ein zweites Mal - und gröber, mit 1,0 m - + vereinfacht und damit unter MIN_AREA gedrückt, obwohl er die 0,5-m-Vereinfachung + klar übersteht. Vier Gebäudepixel an zwei gegenüberliegenden Ecken eines + 4x4-Blocks fehlen, wie bei einer gefasten Ecke auf dem echten Mosaik. + """ + labels = np.full((40, 40), CLASSES["stadt"], dtype=np.uint8) + block = np.array([ + [1, 1, 1, 0], + [1, 1, 1, 1], + [1, 1, 1, 1], + [0, 1, 1, 1], + ], dtype=bool) + labels[10:14, 10:14][block] = CLASSES["gebaeude"] + assert len(building_polygons(labels)) == 1 + +def test_speculative_flag_matches_pixel_class(): + """building_polygons() markiert nur aus 'spekulativ' (rot) entstandene Grundrisse + als spekulativ; aus 'gebaeude' (grau) entstandene bleiben False.""" + labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8) + labels[10:25, 10:25] = CLASSES["gebaeude"] # grau, kleinere Spalten -> kleineres Welt-x + labels[35:50, 35:50] = CLASSES["spekulativ"] # rot, groessere Spalten -> groesseres Welt-x + result = building_polygons(labels) + assert len(result) == 2 + result.sort(key=lambda item: item[0].centroid.x) + (_, spec_from_gebaeude), (_, spec_from_spekulativ) = result + assert spec_from_gebaeude is False + assert spec_from_spekulativ is True diff --git a/tools/extract/tests/test_heights.py b/tools/extract/tests/test_heights.py index ea9a7d7..e02ca61 100644 --- a/tools/extract/tests/test_heights.py +++ b/tools/extract/tests/test_heights.py @@ -40,7 +40,7 @@ def test_no_palm_stands_on_the_carriageway(): road = Polygon([(0, -9), (200, -9), (200, 9), (0, 9)]) # 18 m breite Fahrbahn line = LineString([(0, 0), (200, 0)]) sand = Polygon([(0, 20), (200, 20), (200, 40), (0, 40)]) - pts = palm_points([sand], [], [line], [], [road], seed=1) + pts = palm_points([sand], [], [line], [], [road], [], seed=1) assert pts assert all(road.distance(Point(x, y)) >= PALM_KERB for x, y in pts) @@ -49,6 +49,27 @@ def test_palms_do_not_pile_up_where_centerlines_meet(): auf zwei gegenüberliegenden Ästen zwei Palmen in genau denselben Punkt.""" sand = Polygon([(-80, 20), (80, 20), (80, 40), (-80, 40)]) arms = [LineString([(0, 0), (60, 0)]), LineString([(0, 0), (-60, 0)])] - pts = palm_points([sand], [], arms, [], [], seed=1) + pts = palm_points([sand], [], arms, [], [], [], seed=1) assert pts assert len(set(pts)) == len(pts) + +def test_no_palm_stands_in_the_water(): + """Der Versatz nach außen schiebt Palmen an Küstenstraßen über die Uferlinie. + Am echten Mosaik standen so 218 von 2.433 Palmen im Wasser.""" + land = Polygon([(-100, -100), (100, -100), (100, 0), (-100, 0)]) # Ufer bei y = 0 + road = Polygon([(-100, -12), (100, -12), (100, 0), (-100, 0)]) + line = LineString([(-100, -6), (100, -6)]) + sand = Polygon([(-100, 0), (100, 0), (100, 10), (-100, 10)]) + pts = palm_points([sand], [], [line], [], [road], [land], seed=1) + assert pts + assert all(land.contains(Point(x, y)) for x, y in pts) + +def test_park_scatter_is_deterministic_and_inside_the_park(): + """Ohne Sperrflächen liefert ein Park einfach verstreute, aber reproduzierbare + Punkte: derselbe Seed muss dieselbe Punktliste geben.""" + park = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]) # 2500 m^2 -> 10 Punkte + pts_a = palm_points([], [park], [], [], [], [], seed=7) + pts_b = palm_points([], [park], [], [], [], [], seed=7) + assert pts_a + assert all(park.contains(Point(x, y)) for x, y in pts_a) + assert pts_a == pts_b