fix(extract): Palmen nicht mehr im Wasser, regularize() nicht mehr doppelt vereinfacht
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6de4b81942
commit
42f2a4415d
5 files changed
+73
-17
No files matched your search
@@ -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]]:
|
||||
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user