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:
1 parent
368ea90b3a
commit
ce93e57936
2 files changed
+258
-5
No files matched your search
@@ -8,11 +8,14 @@ kennt dieses Modul nicht; die Umrechnung macht annotations.py.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import warnings
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy import ndimage as ndi
|
from scipy import ndimage as ndi
|
||||||
|
from shapely.geometry import MultiPoint, Polygon
|
||||||
from skimage.draw import disk
|
from skimage.draw import disk
|
||||||
|
from skimage.draw import polygon as fill_polygon
|
||||||
from skimage.morphology import skeletonize
|
from skimage.morphology import skeletonize
|
||||||
|
|
||||||
from .classify import CLASSES
|
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
|
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
|
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
|
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
|
@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.
|
"""Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf.
|
||||||
|
|
||||||
blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne
|
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
|
blocked = mask if blocked is None else blocked
|
||||||
stats = RebuildStats()
|
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):
|
for road, points, halves in _plan_roads(L, M, B, edges, stats):
|
||||||
_stamp(L, M, painted, road, points, halves)
|
_stamp(L, M, painted, road, points, halves)
|
||||||
_fill_areas(L, M & ~painted, B)
|
_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 -----------------------------------------------------------------------
|
# --- 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
|
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):
|
def _fill_areas(L, target, B):
|
||||||
"""Nächste sichtbare Fläche; Stücke unter SOURCE_MIN_AREA (Kantenglättung) zählen nicht als Quelle."""
|
"""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)
|
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 -----------------------------------------------------------------------------------
|
# --- Hilfen -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
def _window(mask, margin):
|
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)))
|
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:
|
def _length(path) -> float:
|
||||||
return sum(math.dist(path[i - 1], path[i]) for i in range(1, len(path)))
|
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:
|
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)))))
|
||||||
@@ -161,4 +161,84 @@ def test_a_road_running_off_the_map_under_text_is_kept_to_the_edge():
|
|||||||
scribble(labels, mask)
|
scribble(labels, mask)
|
||||||
result = rebuild(labels, mask)
|
result = rebuild(labels, mask)
|
||||||
assert result.stats.extended == 1
|
assert result.stats.extended == 1
|
||||||
assert (labels[0:30, 142:158] == ROAD).all()
|
assert (labels[0:30, 142:158] == ROAD).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bitten_building_is_closed_but_not_extended():
|
||||||
|
labels = city()
|
||||||
|
labels[50:90, 50:110] = BUILDING
|
||||||
|
mask = box_mask(labels.shape, slice(70, 110), slice(90, 150))
|
||||||
|
scribble(labels, mask)
|
||||||
|
result = rebuild(labels, mask)
|
||||||
|
assert result.stats.closed == 1
|
||||||
|
assert (labels[70:90, 90:110] == BUILDING).all()
|
||||||
|
assert not (labels[92:110, 90:150] == BUILDING).any()
|
||||||
|
assert not (labels[70:90, 112:150] == BUILDING).any()
|
||||||
|
|
||||||
|
|
||||||
|
def block_city():
|
||||||
|
"""Ein Block zwischen vier Straßen, links sichtbar bebaut, rechts verdeckt."""
|
||||||
|
labels = city(300, 400)
|
||||||
|
labels[20:40, :] = ROAD
|
||||||
|
labels[260:280, :] = ROAD
|
||||||
|
labels[:, 20:40] = ROAD
|
||||||
|
labels[:, 360:380] = ROAD
|
||||||
|
for top in (60, 100, 140, 180):
|
||||||
|
for left in (60, 120):
|
||||||
|
labels[top:top + 20, left:left + 30] = BUILDING
|
||||||
|
mask = box_mask(labels.shape, slice(60, 241), slice(220, 341))
|
||||||
|
return labels, mask
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hidden_block_part_gets_the_density_of_the_visible_part():
|
||||||
|
labels, mask = block_city()
|
||||||
|
visible_density = 8 * 600 / (220 * 320 - mask.sum())
|
||||||
|
scribble(labels, mask)
|
||||||
|
result = rebuild(labels, mask)
|
||||||
|
added = (labels[mask] == BUILDING).sum()
|
||||||
|
assert result.stats.added == len(result.buildings) >= 3
|
||||||
|
assert abs(added - visible_density * mask.sum()) <= 600 + 2 * (20 + 30) # ein Vorlage-Rechteck genau
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_buildings_keep_setback_gaps_and_stay_in_the_hidden_part():
|
||||||
|
labels, mask = block_city()
|
||||||
|
scribble(labels, mask)
|
||||||
|
result = rebuild(labels, mask)
|
||||||
|
new = (labels == BUILDING) & mask
|
||||||
|
assert not (new & ~mask).any()
|
||||||
|
road_distance = ndi.distance_transform_edt(~np.isin(labels, [ROAD, PATH]))
|
||||||
|
assert road_distance[new].min() >= 15 # Straßenabstand der Nachbarn, auf 15 m begrenzt
|
||||||
|
pieces, count = ndi.label(new)
|
||||||
|
assert count == len(result.buildings)
|
||||||
|
for k in range(1, count + 1):
|
||||||
|
others = new & (pieces != k)
|
||||||
|
assert ndi.distance_transform_edt(~others)[pieces == k].min() >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_buildings_are_added_in_a_park():
|
||||||
|
labels, mask = block_city()
|
||||||
|
labels[40:260, 200:360] = PARK
|
||||||
|
scribble(labels, mask)
|
||||||
|
result = rebuild(labels, mask)
|
||||||
|
assert result.stats.added == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_three_neighbours_nothing_is_added():
|
||||||
|
labels, mask = block_city()
|
||||||
|
labels[labels == BUILDING] = STADT
|
||||||
|
labels[60:80, 60:90] = BUILDING
|
||||||
|
labels[100:120, 60:90] = BUILDING
|
||||||
|
scribble(labels, mask)
|
||||||
|
result = rebuild(labels, mask)
|
||||||
|
assert result.stats.added == 0
|
||||||
|
assert result.stats.note == "zu wenig Nachbarn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_is_deterministic():
|
||||||
|
first, mask = block_city()
|
||||||
|
second = first.copy()
|
||||||
|
scribble(first, mask)
|
||||||
|
scribble(second, mask)
|
||||||
|
a, b = rebuild(first, mask), rebuild(second, mask)
|
||||||
|
assert (first == second).all()
|
||||||
|
assert a.buildings == b.buildings
|
||||||
Reference in new issue
Block a user