feat(extract): angeschnittene Gebäude schließen und verdeckte Blockteile bebauen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-13 19:08:53 +02:00
1 parent 368ea90b3a
commit ce93e57936
2 files changed
+258 -5

No files matched your search

+177 -4
View File
@@ -8,11 +8,14 @@ kennt dieses Modul nicht; die Umrechnung macht annotations.py.
from __future__ import annotations
import math
import warnings
from dataclasses import dataclass, field
import numpy as np
from scipy import ndimage as ndi
from shapely.geometry import MultiPoint, Polygon
from skimage.draw import disk
from skimage.draw import polygon as fill_polygon
from skimage.morphology import skeletonize
from .classify import CLASSES
@@ -34,6 +37,18 @@ WIDTH_RATIO = 1.6 # Halbbreiten eines Paares höchstens um diesen Faktor
CORRIDOR_SHARE = 0.9 # Anteil der Kurvenpunkte, die auf Maske oder sichtbarer Straße liegen müssen
TARGET_MIN_INSIDE = 3 # Kurvenpunkte in der Maske, ab denen ein Ziel auf der anderen Seite liegt
SOURCE_MIN_AREA = 50 # m²: kleinere Flächenstücke sind Kantenglättung und taugen nicht als Füllquelle
TEMPLATE_MIN_AREA = 100 # m²: Gebäude, die als Vorlage und als Nachbarn zählen
NEIGHBOUR_RADIUS = 150 # m um die Maske für Gebäudevorlage und Nachbarschaftsdichte
MIN_NEIGHBOURS = 3
SHORT_SIDE = (6.0, 40.0) # m, Grenzen der kurzen Seite ergänzter Gebäude
LONG_SIDE_MAX = 60.0 # m
SETBACK = (2.0, 15.0) # m, Grenzen des Straßenabstands
GAP = 3.0 # m zwischen Gebäuden
VISIBLE_SHARE = 0.25 # sichtbarer Blockteil zählt ab 25 % des Blocks …
VISIBLE_AREA = 400 # … und 400 m²
PIECE_MIN_AREA = 12 # m²: kleinere Gebäudestücke werden nicht geschlossen
CONTACT_MIN = 3 # Pixel direkt an der Maske, ab denen ein Gebäudestück angeschnitten ist
EIGHT = np.ones((3, 3), dtype=bool)
@dataclass
@@ -79,7 +94,8 @@ def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = N
"""Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf.
blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne
Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, dann Flächen.
Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, Flächen,
Gebäude schließen, Gebäude ergänzen.
"""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
@@ -90,7 +106,9 @@ def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = N
for road, points, halves in _plan_roads(L, M, B, edges, stats):
_stamp(L, M, painted, road, points, halves)
_fill_areas(L, M & ~painted, B)
return RebuildResult(stats)
_close_buildings(L, M, B, painted, stats)
corners = _add_buildings(labels, mask, blocked, stats)
return RebuildResult(stats, corners)
# --- Straßen und Wege -----------------------------------------------------------------------
@@ -354,7 +372,7 @@ def _mark_centre(centre, points):
centre[max(0, r - 1):min(h, r + 2), max(0, c - 1):min(w, c + 2)] = True
# --- Flächen -------------------------------------------------------------------------------------
# --- Flächen und Gebäude --------------------------------------------------------------------
def _fill_areas(L, target, B):
"""Nächste sichtbare Fläche; Stücke unter SOURCE_MIN_AREA (Kantenglättung) zählen nicht als Quelle."""
@@ -373,6 +391,140 @@ def _fill_areas(L, target, B):
fill_nearest(L, target, ~source)
def _close_buildings(L, M, B, painted, stats):
building = CLASSES["gebaeude"]
pieces = (L == building) & ~B
comps, count = ndi.label(pieces, structure=EIGHT)
if count == 0:
return
index = np.arange(1, count + 1)
sizes = ndi.sum(pieces, comps, index)
contact = ndi.sum(ndi.binary_dilation(M, structure=EIGHT) & pieces, comps, index)
for k, sl in enumerate(ndi.find_objects(comps), start=1):
if sizes[k - 1] < PIECE_MIN_AREA or contact[k - 1] < CONTACT_MIN:
continue
rows, cols = np.nonzero(comps[sl] == k)
rect = _rectangle(rows + sl[0].start, cols + sl[1].start)
rr, cc = _raster(rect, M.shape)
inside = M[rr, cc] & ~painted[rr, cc] & (L[rr, cc] != building)
if inside.any():
L[rr[inside], cc[inside]] = building
stats.closed += 1
def _add_buildings(labels, mask, blocked, stats):
"""Ergänzt Gebäude im verdeckten Teil der Blöcke; alles im Nachbarschaftsfenster (150 m)."""
building, urban, water = CLASSES["gebaeude"], CLASSES["stadt"], CLASSES["wasser"]
roads = [CLASSES[name] for name in ROAD_ORDER]
hood = _window(mask, NEIGHBOUR_RADIUS + 1)
L, M, B = labels[hood], mask[hood], blocked[hood]
near = ndi.distance_transform_edt(~M) <= NEIGHBOUR_RADIUS
pieces = (L == building) & ~B
comps, count = ndi.label(pieces, structure=EIGHT)
index = np.arange(1, count + 1)
sizes = ndi.sum(pieces, comps, index) if count else []
reach = ndi.maximum(near, comps, index) if count else []
chosen = [k for k in range(1, count + 1) if sizes[k - 1] >= TEMPLATE_MIN_AREA and reach[k - 1]]
if len(chosen) < MIN_NEIGHBOURS:
stats.note = "zu wenig Nachbarn"
return []
shorts, longs = [], []
slices = ndi.find_objects(comps)
for k in chosen:
sl = slices[k - 1]
rows, cols = np.nonzero(comps[sl] == k)
short, long_ = _sides(_rectangle(rows + sl[0].start, cols + sl[1].start))
shorts.append(short)
longs.append(long_)
short = float(np.clip(np.median(shorts), *SHORT_SIDE))
long_ = float(np.clip(np.median(longs), short, LONG_SIDE_MAX))
road = np.isin(L, roads)
road_dist = ndi.distance_transform_edt(~road)
neighbours = np.isin(comps, chosen)
outline = neighbours & ~ndi.binary_erosion(neighbours)
setback = float(np.clip(np.median(road_dist[outline]), *SETBACK))
ground = ~B & near & ~np.isin(L, roads + [water])
density_hood = float(((L == building) & ~B & near).sum() / max(int(ground.sum()), 1))
forbidden = ndi.distance_transform_edt(L != building) < GAP
buildable = (L == urban) & M & (road_dist >= setback) & ~forbidden
blocks, _ = ndi.label(~np.isin(L, roads + [water]))
placed_all = []
for k, sl in enumerate(ndi.find_objects(blocks), start=1):
block = blocks[sl] == k
hidden = block & M[sl]
if not (hidden & buildable[sl]).any():
continue
visible = block & ~B[sl]
if visible.sum() >= VISIBLE_SHARE * block.sum() and visible.sum() >= VISIBLE_AREA:
density = ((L[sl] == building) & visible).sum() / visible.sum()
else:
density = density_hood
target = density * hidden.sum() - ((L[sl] == building) & hidden).sum()
if target < 0.5 * short * long_:
continue
rows, cols = np.nonzero(block)
placed = _place_rows(L, buildable, forbidden, rows + sl[0].start, cols + sl[1].start,
road, short, long_, setback, target)
stats.added += len(placed)
placed_all += [[(r + hood[0].start, c + hood[1].start) for r, c in corners] for corners in placed]
return placed_all
def _place_rows(L, buildable, forbidden, rows, cols, road, short, long_, setback, target):
"""Rechtecke reihenweise von der Straßenseite des Blocks her, bis target erreicht ist."""
corners = list(_rectangle(rows, cols).exterior.coords)[:4] # (Spalte, Zeile)
p0 = np.array(corners[0])
e1, e2 = np.array(corners[1]) - p0, np.array(corners[3]) - p0
if np.hypot(*e1) < np.hypot(*e2):
e1, e2 = e2, e1
U, V = float(np.hypot(*e1)), float(np.hypot(*e2))
u, v = e1 / U, e2 / V
origin = p0
reach = setback + 2
rr, cc = np.nonzero(road)
pu = (cc - origin[0]) * u[0] + (rr - origin[1]) * u[1]
pv = (cc - origin[0]) * v[0] + (rr - origin[1]) * v[1]
along = (pu >= 0) & (pu <= U)
near_origin_side = int((along & (pv < 0) & (pv >= -reach)).sum())
near_far_side = int((along & (pv > V) & (pv <= V + reach)).sum())
if near_far_side > near_origin_side:
origin, v = origin + e2, -v
free = buildable[rows, cols]
if not free.any():
return []
fu = (cols[free] - origin[0]) * u[0] + (rows[free] - origin[1]) * u[1]
fv = (cols[free] - origin[0]) * v[0] + (rows[free] - origin[1]) * v[1]
u_start, u_end = max(0.0, float(fu.min()) - long_), min(U, float(fu.max()))
v_pos = max(setback, float(fv.min()) - short)
v_end = min(V, float(fv.max()))
placed, area = [], 0.0
while v_pos <= v_end and area < target:
u_pos, used = u_start, False
while u_pos <= u_end and area < target:
quad = [origin + u * du + v * dv for du, dv in
((u_pos, v_pos), (u_pos + long_, v_pos), (u_pos + long_, v_pos + short), (u_pos, v_pos + short))]
polygon = Polygon([tuple(p) for p in quad])
qr, qc = _raster(polygon, L.shape)
if qr.size and buildable[qr, qc].all() and not forbidden[qr, qc].any():
L[qr, qc] = CLASSES["gebaeude"]
buildable[qr, qc] = False
gr, gc = _raster(polygon.buffer(GAP, join_style="mitre"), L.shape)
forbidden[gr, gc] = True
placed.append([(float(p[1]), float(p[0])) for p in quad])
area += short * long_
used = True
u_pos += long_ + GAP
else:
u_pos += 1.0
v_pos += short + GAP if used else 1.0
return placed
# --- Hilfen -----------------------------------------------------------------------------------
def _window(mask, margin):
@@ -382,6 +534,27 @@ def _window(mask, margin):
slice(max(0, int(cols.min()) - margin), min(w, int(cols.max()) + 1 + margin)))
def _rectangle(rows, cols) -> Polygon:
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante."""
points = MultiPoint(np.column_stack([cols, rows]).astype(float))
with warnings.catch_warnings():
# Siehe buildings.regularize(): harmlose GEOS-Warnung bei achsenparallelen Formen.
warnings.filterwarnings("ignore", message=r".*oriented_envelope.*", category=RuntimeWarning)
envelope = points.minimum_rotated_rectangle
return envelope.buffer(0.5, join_style="mitre")
def _sides(rect: Polygon) -> tuple[float, float]:
corners = list(rect.exterior.coords)[:4]
edges = sorted(math.dist(corners[i - 1], corners[i]) for i in range(4))
return edges[0], edges[-1]
def _raster(polygon: Polygon, shape):
xs, ys = polygon.exterior.coords.xy
return fill_polygon(np.asarray(ys), np.asarray(xs), shape=shape)
def _length(path) -> float:
return sum(math.dist(path[i - 1], path[i]) for i in range(1, len(path)))
@@ -392,4 +565,4 @@ def _cos(a, b) -> float:
def _angle(a, b) -> float:
return math.degrees(math.acos(max(-1.0, min(1.0, _cos(a, b)))))
return math.degrees(math.acos(max(-1.0, min(1.0, _cos(a, b)))))