diff --git a/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md b/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md index 49646cb..1bdbfda 100644 --- a/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md +++ b/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md @@ -893,7 +893,7 @@ Co-Authored-By: Claude Opus 5 " **Interfaces:** - Consumes: `mask_polygons`, `centerlines` aus Task 4, `CLASSES` aus Task 3 -- Produces: `regularize(poly) -> Polygon | None`, `building_polygons(labels) -> list[tuple[Polygon, bool]]` (Polygon, `spekulativ`), `assign_heights(polys, landmarks, districts, seed) -> list[float]`, `height_sources(...) -> list[str]`, `palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, seed) -> list[tuple[float, float]]` +- Produces: `regularize(poly) -> Polygon | None`, `building_polygons(labels) -> list[tuple[Polygon, bool]]` (Polygon, `spekulativ`), `assign_heights(polys, landmarks, districts, seed) -> list[dict]` (je Gebäude `height` und `source`), `palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, seed) -> list[tuple[float, float]]` - [ ] **Step 1: Den fehlschlagenden Test für Gebäude schreiben** @@ -901,6 +901,7 @@ Co-Authored-By: Claude Opus 5 " ```python import numpy as np +import pytest from shapely.geometry import Polygon from leonida.buildings import building_polygons, regularize from leonida.classify import CLASSES @@ -911,7 +912,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(): + """Das Assert unten ruft minimum_rotated_rectangle selbst auf und löst damit die + harmlose Shapely-RuntimeWarning aus (Winkelterm 0/0, Rechteck korrekt). Nur dieser + eine Test filtert sie — nicht die Suite, sonst wäre auch eine echte Division durch + Null künftig unsichtbar.""" 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 @@ -924,6 +930,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. + + mask_polygons liefert Kandidaten bereits mit 0,5 m simplify und mindestens MIN_AREA. + Ein Grundriss mit gestuftem Eck, der die Rechteckquote verfehlt, wurde bisher ein + zweites Mal und gröber (1,0 m) vereinfacht und damit unter MIN_AREA gedrückt. Am + echten Mosaik betraf das 202 von 2.085 Gebäuden. + """ + 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(): + """Nur aus 'spekulativ' (rot) entstandene Grundrisse sind spekulativ.""" + labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8) + labels[10:25, 10:25] = CLASSES["gebaeude"] # grau -> kleineres Welt-x + labels[35:50, 35:50] = CLASSES["spekulativ"] # rot -> größeres 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 ``` - [ ] **Step 2: Test laufen lassen, er muss scheitern** @@ -952,8 +988,9 @@ 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 + # Gleiche Toleranz wie mask_polygons: ein zweiter, gröberer Durchgang schrumpfte + # kleine Grundrisse unter MIN_AREA und verwarf am echten Mosaik 202 von 2.085. + return poly.simplify(0.5) def building_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]: @@ -1087,7 +1124,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) @@ -1096,9 +1133,29 @@ 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 verstreut ein Park reproduzierbare Punkte.""" + park = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]) # 2500 m² -> 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 ``` - [ ] **Step 10: Palmenpunkte ergänzen** @@ -1116,8 +1173,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 (sonst schiebt der Versatz Küstenpalmen ins Wasser).""" for dist in PALM_VERGE: off = line.parallel_offset(dist, side) if off.is_empty: @@ -1127,16 +1185,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 = [] @@ -1149,7 +1211,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 @@ -1160,7 +1222,7 @@ 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)) ``` - [ ] **Step 11: Test laufen lassen, er muss bestehen** @@ -1394,7 +1456,8 @@ def main() -> None: landmarks = json.loads(Path("data/skyline.json").read_text())["landmarks"] districts = json.loads(Path("data/districts.json").read_text()) heights = assign_heights([p for p, _ in buildings], landmarks, districts, SEED) - palms = palm_points(sand, parks, lines, [p for p, _ in buildings], road_shapes, SEED) + palms = palm_points(sand, parks, lines, [p for p, _ in buildings], road_shapes, + land, SEED) payloads = [] for j in range(12):