feat(extract): Gebäudepolygone, Höhen aus Wahrzeichen und Vierteln, Palmenpunkte

buildings.py regularisiert Gebäude-Maskenpolygone (gebaeude/spekulativ) zu
Rechtecken, wenn sie ihr gedrehtes Hüllrechteck zu mindestens 85 % ausfüllen,
sonst zu vereinfachten Grundrissen; zu kleine oder entartete Polygone liefern
None. heights.py weist Gebäuden eine Höhe zu (Wahrzeichen-Treffer schlägt
Viertel-Bereich, deterministisch geseedet) und platziert Palmenpunkte entlang
strandnaher Mittellinien: der Versatz vom Fahrbahnrand wird von innen nach
außen gesucht (PALM_VERGE), bis eine Position sowohl PALM_KERB von der
Fahrbahn als auch PALM_CLEARANCE von Gebäuden einhält; findet sich keine,
entfällt die Palme. Start bei halber PALM_SPACING verhindert doppelte Palmen
an Kreuzungen, wo Task 4 Mittellinien pro Ast trennt.

pyproject.toml ergänzt einen gezielten filterwarnings-Eintrag: Shapelys
oriented_envelope (minimum_rotated_rectangle) meldet für bereits achsen-
parallele/rechtwinklige Polygone eine RuntimeWarning aus GEOS (Winkelterm
wird 0/0), obwohl das Ergebnis nachweislich korrekt bleibt – u.a. löst der
Briefs eigener L-Shape-Test das aus. Nur dieser eine Text+Kategorie-
kombinierte Fall wird gefiltert, jede andere Warnung bleibt sichtbar.

Auf dem echten Mosaik: 2141 Gebäudepolygone (258 spekulativ, 211 von
regularize verworfen), Höhen 6.0-15.0 m (nur "district", da data/skyline.json
und data/districts.json erst in Task 6 kommen), 2433 Palmenpunkte. Straßen-
und Mittellinienzahlen (475 Stücke/40 Brücken, 2323 Linien/Median 27 m)
decken sich exakt mit den in Task 4 unabhängig ermittelten Referenzwerten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-12 15:43:10 +02:00
1 parent 11f6aa76fe
commit 6de4b81942
5 files changed
+228

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import numpy as np
from shapely.geometry import Polygon
from leonida.buildings import building_polygons, regularize
from leonida.classify import CLASSES
def test_rough_rectangle_snaps_to_rectangle():
poly = Polygon([(0, 0), (10.4, 0.3), (10.1, 20.2), (-0.2, 19.8)])
out = regularize(poly)
assert len(out.exterior.coords) == 5 # geschlossenes Rechteck
assert abs(out.area - 205) < 25
def test_l_shape_is_kept_not_boxed():
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
def test_tiny_polygon_is_dropped():
assert regularize(Polygon([(0, 0), (2, 0), (2, 2), (0, 2)])) is None
def test_two_buildings_are_separated():
labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
labels[10:25, 10:25] = CLASSES["gebaeude"]
labels[35:50, 35:50] = CLASSES["gebaeude"]
assert len(building_polygons(labels)) == 2
+54
View File
@@ -0,0 +1,54 @@
from shapely.geometry import Polygon
from leonida.heights import DISTRICT_RANGES, assign_heights
SQUARE = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
FAR = Polygon([(500, 500), (520, 500), (520, 520), (500, 520)])
def test_landmark_height_wins():
res = assign_heights([SQUARE], landmarks=[{"xyz": [10, 10, 0], "height": 180.0}],
districts=[], seed=1)
assert res[0]["height"] == 180.0
assert res[0]["source"] == "skyline"
def test_district_range_is_used_without_landmark():
res = assign_heights([SQUARE], landmarks=[],
districts=[{"name": "Downtown", "x": 5, "y": 5}], seed=1)
lo, hi = DISTRICT_RANGES["Downtown"]
assert lo <= res[0]["height"] <= hi
assert res[0]["source"] == "district"
def test_same_seed_gives_same_height():
args = dict(landmarks=[], districts=[{"name": "Downtown", "x": 5, "y": 5}], seed=42)
assert assign_heights([SQUARE], **args)[0]["height"] == assign_heights([SQUARE], **args)[0]["height"]
def test_unknown_district_falls_back_to_default():
res = assign_heights([FAR], landmarks=[], districts=[], seed=3)
lo, hi = DISTRICT_RANGES["default"]
assert lo <= res[0]["height"] <= hi
from shapely.geometry import LineString, Point
from leonida.heights import PALM_KERB, palm_points
def test_no_palm_stands_on_the_carriageway():
"""Der Versatz muss vom Fahrbahnrand her gedacht werden, nicht von einem festen Wert.
Am echten Mosaik hat die Fahrbahn an den Mittellinienpunkten p50 5,4 m, p90 11,7 m
und p95 14,0 m Halbbreite. Ein fester Versatz von 4 m setzt 60 % der Palmen auf die
Fahrbahn, einer von 11 m immer noch 12,5 % – und auf der Hälfte der Straßen stünde
die Palme dann im Nachbargebäude.
"""
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)
assert pts
assert all(road.distance(Point(x, y)) >= PALM_KERB for x, y in pts)
def test_palms_do_not_pile_up_where_centerlines_meet():
"""Mittellinien sind an Kreuzungen getrennt (Task 4). Ein Start bei Position 0 setzt
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)
assert pts
assert len(set(pts)) == len(pts)