fix(extract): Mindestbreite gegen papierdünne und wandartige Gebäudegrundrisse

727 von 2.286 rohen Grundrissen (31,8 %, aber nur 1,8 % der bebauten Fläche) sind
unter 4 m an der kurzen Seite ihres gedrehten Hüllrechtecks — extrudiert werden
daraus Wände, keine Häuser. Eine Mindestbreite allein reicht nicht: einzelne Formen
bleiben bei >= 4 m Breite trotzdem extrem lang und dünn (z.B. 4,27 m x 153 m,
Verhältnis 35,9). MAX_ASPECT_RATIO=12 ergänzt die Mindestbreite MIN_BUILDING_WIDTH=4,
an einer echten Lücke in den gemessenen Verhältnissen festgemacht (größte plausible
große Grundfläche: Verhältnis 10,8 bei 7.196 m²; nächste eindeutig wandartige Form:
Verhältnis 12,0 bei 60,5 m²). Zusammen fallen 766 Grundrisse (33,5 %, 36.986 m² =
3,3 % der Fläche) weg.

Bestehende Fixture in test_small_staircase_building_is_not_dropped vergrößert (4x4-
auf 6x6-Block): ihre kurze Seite lag zufällig bei ~3,7 m, unter der neuen Schwelle,
und hätte den Test sonst für den falschen Grund bestehen lassen (Ruling 74 statt
Ruling 28).

Pipeline neu durchgelaufen: eindeutige Gebäude 2.282 -> 1.520 (-33,4 %), Dreiecke
über alle 144 Chunks 327.181 -> 307.678 (-6,0 %, vollständig aus der Gebäude-Ebene),
public/data/vice-a 2,73 M -> 2,50 M.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 05:14:42 +02:00
1 parent ad9bf63b36
commit 30cf64642b
129 files changed
+205 -138

No files matched your search

+38 -1
View File
@@ -1,3 +1,4 @@
import math
import warnings
import numpy as np
@@ -9,6 +10,21 @@ from .vectors import mask_polygons
MIN_AREA = 12.0
RECT_COVERAGE = 0.85
# Befund C (Ruling 74): an den 2.286 rohen Grundrissen des echten Mosaiks gemessen (kurze
# Seite des gedrehten Huellrechtecks) - p5 1,0 m, p25 3,5 m, p50 5,0 m, die fuenf duennsten
# 0,50 m breit bei bis zu 164 m Laenge. Unter MIN_BUILDING_WIDTH liegen 727 Grundrisse
# (31,8 % der Anzahl, aber nur 20.575 m² = 1,8 % der bebauten Flaeche) - extrudiert werden
# daraus Waende, keine Haeuser. 4 m reicht als alleinige Schwelle nicht: einige Grundrisse
# bleiben bei >= 4 m Breite trotzdem sehr lang und duenn (z.B. 4,27 m x 153 m, Verhaeltnis
# 35,9) - MAX_ASPECT_RATIO faengt genau diese "4x100-m-Wand"-Faelle zusaetzlich ab. Schwelle
# aus einer Luecke in den gemessenen Verhaeltnissen hergeleitet: die groesste noch plausible
# grosse Grundflaeche (30,1 m x 324 m, 7.196 m², Verhaeltnis 10,8) bleibt darunter, die naechst-
# hoehere Form im Datensatz (Verhaeltnis 12,0, nur 60,5 m²) darueber - 12 liegt in dieser Luecke.
# Zusammen fallen 766 Grundrisse (33,5 %, 36.986 m² = 3,3 % der Flaeche) weg, s. Bericht fuer
# die vollstaendige Herleitung und die Verteilung.
MIN_BUILDING_WIDTH = 4.0
MAX_ASPECT_RATIO = 12.0
def regularize(poly: Polygon) -> Polygon | None:
"""Rechteck, wenn das Polygon sein gedrehtes Hüllrechteck gut ausfüllt; sonst vereinfacht."""
@@ -28,12 +44,33 @@ def regularize(poly: Polygon) -> Polygon | None:
return poly.simplify(0.5)
def _rect_sides(poly: Polygon) -> tuple[float, float]:
"""Kurze und lange Seite von poly.minimum_rotated_rectangle, in Metern."""
with warnings.catch_warnings():
# Siehe Kommentar in regularize(): dieselbe harmlose GEOS-RuntimeWarning bei bereits
# achsenparallelen/rechtwinkligen Polygonen.
warnings.filterwarnings("ignore", message=r".*oriented_envelope.*", category=RuntimeWarning)
rect = poly.minimum_rotated_rectangle
corners = list(rect.exterior.coords)[:4]
edges = [math.dist(corners[i - 1], corners[i]) for i in range(4)]
return min(edges), max(edges)
def _is_wall_like(poly: Polygon) -> bool:
"""Befund C (Ruling 74): papierduenne oder sehr lange schmale Grundrisse ergeben beim
Extrudieren Waende statt Haeuser, s. Konstanten oben fuer die Herleitung der Schwellen."""
short, long_ = _rect_sides(poly)
if short < MIN_BUILDING_WIDTH:
return True
return (long_ / short) > MAX_ASPECT_RATIO
def building_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
out = []
for name, speculative in (("gebaeude", False), ("spekulativ", True)):
mask = labels == CLASSES[name]
for poly in mask_polygons(mask, min_area=MIN_AREA, simplify=0.5):
reg = regularize(poly)
if reg is not None:
if reg is not None and not _is_wall_like(reg):
out.append((reg, speculative))
return out
+40 -10
View File
@@ -36,17 +36,47 @@ def test_small_staircase_building_is_not_dropped():
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. An zwei gegenüberliegenden Ecken eines 4x4-Blocks fehlt je ein
Gebäudepixel, wie bei einer gefasten Ecke auf dem echten Mosaik.
klar übersteht. An zwei gegenüberliegenden Ecken eines 6x6-Blocks fehlt je ein
2x2-Gebäudepixelquadrat, wie bei einer gefasten Ecke auf dem echten Mosaik.
Fix-Runde 3 / Befund C: die Fixture war ursprünglich ein 4x4-Block mit je einem
fehlenden Eckpixel (kurze Seite danach ca. 3,7 m) - das kollidierte zufällig mit
der neuen Mindestbreite (Ruling 74, MIN_BUILDING_WIDTH=4.0) und hätte diesen Test
in einen Test für den falschen Grund verwandelt (weggefallen wegen Breite statt
weil die Rechteckquote verfehlt wird). Größer gewählt (kurze Seite ca. 5,8 m),
damit dieser Test weiterhin ausschließlich Ruling 28 prüft, unabhängig von 74.
"""
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"]
labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
block = np.ones((6, 6), dtype=bool)
block[0:2, 4:6] = False # obere rechte 2x2-Ecke fehlt
block[4:6, 0:2] = False # untere linke 2x2-Ecke fehlt
labels[10:16, 10:16][block] = CLASSES["gebaeude"]
assert len(building_polygons(labels)) == 1
# Befund C (Ruling 74, Fix-Runde 3): 727 von 2.286 rohen Grundrissen (31,8 %, aber nur 1,8 %
# der bebauten Flaeche) sind unter 4 m an der kurzen Seite ihres gedrehten Huellrechtecks -
# extrudiert werden daraus Waende, keine Haeuser. Zahlen an den echten Daten nachgemessen,
# s. Bericht.
def test_thin_sliver_building_is_dropped():
"""Unter MIN_BUILDING_WIDTH (4 m) kurzer Seite faellt ein Grundriss weg, unabhaengig von
der Flaeche (hier 60 m2, weit ueber MIN_AREA=12 - die Flaeche allein rettet es nicht)."""
labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
labels[10:12, 10:40] = CLASSES["gebaeude"] # 2 m breit, 30 m lang
assert building_polygons(labels) == []
def test_long_narrow_building_just_above_min_width_is_still_dropped():
"""Ruling 74: 'eine 4x100-m-Form ist immer noch eine Wand' - Mindestbreite allein reicht
nicht; ab MIN_BUILDING_WIDTH greift zusaetzlich eine Obergrenze fuer laenge/breite."""
labels = np.full((150, 150), CLASSES["stadt"], dtype=np.uint8)
labels[10:15, 10:110] = CLASSES["gebaeude"] # 5 m breit, 100 m lang -> Verhaeltnis 20
assert building_polygons(labels) == []
def test_moderately_elongated_building_above_min_width_is_kept():
"""Regression: die Verhaeltnis-Obergrenze darf normale, nur maessig lang gestreckte
Gebaeude nicht treffen (10 m breit, 30 m lang -> Verhaeltnis 3, klar unter der Schwelle)."""
labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
labels[10:20, 10:40] = CLASSES["gebaeude"]
assert len(building_polygons(labels)) == 1
def test_speculative_flag_matches_pixel_class():