docs: Plan auf Rulings 24-29 umstellen (Palmen, Gebäude)

palm_points bekommt die Landflächen und verwirft, was nicht darauf liegt:
am echten Mosaik standen 218 von 2.433 Palmen im Wasser. regularize
vereinfacht nicht mehr ein zweites Mal und gröber als mask_polygons, was
202 von 2.085 Gebäuden verworfen hatte. Dazu die vier neuen Tests, der
Marker am einzelnen Test statt eines suiteweiten Filters, die Entdoppelung
der Palmenpunkte und die korrigierte Signatur von assign_heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-12 16:17:39 +02:00
1 parent 42f2a4415d
commit 2afae81bce
1 file changed
+74 -11
@@ -893,7 +893,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
**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 <noreply@anthropic.com>"
```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):