_sides() und _place_rows() nehmen die ersten vier Ringpunkte eines Rechtecks als seine Ecken. GEOS setzt nach dem Puffern je nach Plattform zusätzliche Punkte mitten auf die Kanten (macOS 0, Windows 2), und dann messen beide die falschen Kanten: ergänzte Gebäude bekamen unter entfernten Beschriftungen eine falsche Größe und eine falsche Richtung. simplify(0) entfernt genau diese Punkte und lässt die Form unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
277 lines
10 KiB
Python
277 lines
10 KiB
Python
import numpy as np
|
||
import pytest
|
||
from scipy import ndimage as ndi
|
||
|
||
from leonida.classify import CLASSES
|
||
from leonida.inpaint import _rectangle, _sides, fill_nearest, is_thin, rebuild
|
||
|
||
STADT, PARK, WATER = CLASSES["stadt"], CLASSES["gruen"], CLASSES["wasser"]
|
||
ROAD, PATH, BUILDING = CLASSES["strasse"], CLASSES["weg"], CLASSES["gebaeude"]
|
||
|
||
|
||
def city(h=200, w=300):
|
||
return np.full((h, w), STADT, dtype=np.uint8)
|
||
|
||
|
||
def box_mask(shape, rows, cols):
|
||
mask = np.zeros(shape, dtype=bool)
|
||
mask[rows, cols] = True
|
||
return mask
|
||
|
||
|
||
def scribble(labels, mask):
|
||
"""Schrift simulieren: unter der Maske steht Unsinn, der nichts mit der Karte zu tun hat."""
|
||
labels[mask] = BUILDING
|
||
labels[mask & (np.indices(labels.shape).sum(axis=0) % 3 == 0)] = ROAD
|
||
|
||
|
||
def test_fill_nearest_keeps_a_road_running_along_a_grid_line():
|
||
labels = city(60, 100)
|
||
labels[:, 40:60] = ROAD
|
||
band = box_mask(labels.shape, slice(None), slice(47, 53))
|
||
labels[band] = BUILDING
|
||
fill_nearest(labels, band, band)
|
||
assert (labels[:, 40:60] == ROAD).all()
|
||
|
||
|
||
def test_fill_nearest_closes_a_grid_slit_in_a_park():
|
||
labels = np.full((60, 100), PARK, dtype=np.uint8)
|
||
band = box_mask(labels.shape, slice(28, 34), slice(None))
|
||
labels[band] = ROAD
|
||
fill_nearest(labels, band, band)
|
||
assert (labels == PARK).all()
|
||
|
||
|
||
def test_thin_means_at_most_four_metres_thick():
|
||
assert is_thin(box_mask((40, 40), slice(10, 14), slice(5, 35)))
|
||
assert not is_thin(box_mask((40, 40), slice(10, 15), slice(5, 35)))
|
||
|
||
|
||
def test_straight_road_runs_through_a_text_band_in_the_same_width():
|
||
labels = city()
|
||
labels[:, 140:160] = ROAD
|
||
mask = box_mask(labels.shape, slice(80, 120), slice(60, 240))
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert result.stats.connected == 1
|
||
assert (labels[80:120, 141:159] == ROAD).all()
|
||
assert not (labels[80:120, 60:138] == ROAD).any()
|
||
assert not (labels[80:120, 162:240] == ROAD).any()
|
||
|
||
|
||
def test_a_slight_offset_is_bridged_smoothly():
|
||
labels = city()
|
||
labels[:100, 130:150] = ROAD
|
||
labels[100:, 138:158] = ROAD
|
||
mask = box_mask(labels.shape, slice(80, 120), slice(60, 240))
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert result.stats.connected == 1
|
||
roads, count = ndi.label(labels == ROAD)
|
||
assert count == 1 # oben und unten hängen jetzt zusammen
|
||
assert 16 <= (labels[100] == ROAD).sum() <= 24 # in der Mitte etwa straßenbreit
|
||
|
||
|
||
def test_roads_across_visible_ground_are_not_joined():
|
||
labels = city()
|
||
labels[90:110, :60] = ROAD
|
||
labels[90:110, 240:] = ROAD
|
||
mask = box_mask(labels.shape, slice(70, 130), slice(60, 140)) | box_mask(labels.shape, slice(70, 130), slice(180, 240))
|
||
mask |= box_mask(labels.shape, slice(70, 75), slice(140, 180)) # schmale Brücke: eine Maske
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert result.stats.connected == 0
|
||
assert result.stats.dead_ends == 2
|
||
assert (labels[90:110, 141:179] == STADT).all() # sichtbarer Boden dazwischen bleibt
|
||
assert not (labels[mask] == ROAD).any() # Sackgassen werden nicht gemalt
|
||
|
||
|
||
def test_a_corner_under_the_mask_has_no_overhang():
|
||
labels = city()
|
||
labels[:80, 100:120] = ROAD
|
||
labels[130:150, 181:] = ROAD
|
||
mask = box_mask(labels.shape, slice(80, 180), slice(60, 181))
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert result.stats.extended == 2
|
||
assert (labels[80:130, 102:118] == ROAD).all()
|
||
assert (labels[132:148, 122:181] == ROAD).all()
|
||
assert not (labels[153:180, 100:120] == ROAD).any()
|
||
assert not (labels[130:150, 60:97] == ROAD).any()
|
||
|
||
|
||
def test_a_side_street_joins_a_hidden_through_road():
|
||
labels = city()
|
||
labels[:80, 140:160] = ROAD
|
||
labels[130:150, :] = ROAD
|
||
mask = box_mask(labels.shape, slice(80, 180), slice(60, 240))
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert result.stats.connected == 1 and result.stats.extended == 1
|
||
assert (labels[80:131, 142:158] == ROAD).all()
|
||
assert not (labels[153:180, 140:160] == ROAD).any()
|
||
|
||
|
||
def test_a_road_clipped_on_one_side_keeps_its_full_width():
|
||
labels = city()
|
||
labels[:, 140:160] = ROAD
|
||
mask = box_mask(labels.shape, slice(80, 120), slice(150, 240))
|
||
scribble(labels, mask)
|
||
rebuild(labels, mask)
|
||
assert (labels[80:120, 140:159] == ROAD).all()
|
||
assert not (labels[80:120, 162:240] == ROAD).any()
|
||
|
||
|
||
def test_a_path_stays_a_path_and_the_street_wins_the_crossing():
|
||
labels = city()
|
||
labels[:, 145:150] = PATH
|
||
labels[95:115, :] = ROAD
|
||
mask = box_mask(labels.shape, slice(60, 140), slice(100, 200))
|
||
scribble(labels, mask)
|
||
rebuild(labels, mask)
|
||
assert (labels[97:113, 145:150] == ROAD).all()
|
||
assert (labels[62:90, 146:149] == PATH).all()
|
||
assert (labels[120:138, 146:149] == PATH).all()
|
||
|
||
|
||
def test_a_path_runs_through_to_a_stub_too_short_for_a_direction():
|
||
labels = city()
|
||
labels[:80, 145:150] = PATH
|
||
labels[120:134, 155:160] = PATH # kurzes, versetztes Stück: kein Straßenende
|
||
mask = box_mask(labels.shape, slice(80, 120), slice(100, 200))
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert (result.stats.connected, result.stats.extended, result.stats.dead_ends) == (1, 0, 0)
|
||
paths, count = ndi.label(labels == PATH)
|
||
assert count == 1 # oberer Weg und Stück hängen zusammen
|
||
|
||
|
||
def test_park_and_water_under_text_stay_what_they_are():
|
||
labels = city()
|
||
labels[:, :150] = PARK
|
||
labels[:, 250:] = WATER
|
||
mask = box_mask(labels.shape, slice(80, 120), slice(100, 280))
|
||
scribble(labels, mask)
|
||
rebuild(labels, mask)
|
||
assert (labels[80:120, 100:149] == PARK).all()
|
||
assert (labels[80:120, 151:249] == STADT).all()
|
||
assert (labels[80:120, 251:280] == WATER).all()
|
||
|
||
|
||
def test_edge_crumbs_are_no_fill_source():
|
||
labels = np.full((200, 300), PARK, dtype=np.uint8)
|
||
labels[74:78, 150:154] = CLASSES["sand"] # 16 m² Kantenglättung
|
||
mask = box_mask(labels.shape, slice(80, 120), slice(100, 200))
|
||
scribble(labels, mask)
|
||
rebuild(labels, mask)
|
||
assert (labels[mask] == PARK).all()
|
||
|
||
|
||
def test_a_road_running_off_the_map_under_text_is_kept_to_the_edge():
|
||
labels = city()
|
||
labels[:, 140:160] = ROAD
|
||
mask = box_mask(labels.shape, slice(0, 30), slice(60, 240))
|
||
scribble(labels, mask)
|
||
result = rebuild(labels, mask)
|
||
assert result.stats.extended == 1
|
||
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)
|
||
before = labels.copy()
|
||
result = rebuild(labels, mask)
|
||
assert (labels[~mask] == before[~mask]).all()
|
||
for corners in result.buildings: # Maske: Pixel 60–240 × 220–340 samt Pixelkante
|
||
assert all(59.5 <= r <= 240.5 and 219.5 <= c <= 340.5 for r, c in corners)
|
||
new = (labels == BUILDING) & mask
|
||
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_no_buildings_are_added_in_water():
|
||
labels, mask = block_city()
|
||
labels[40:260, 200:360] = WATER
|
||
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
|
||
|
||
|
||
def test_pixels_on_one_line_give_a_rectangle_with_four_corners():
|
||
rect = _rectangle(np.full(10, 5), np.arange(10, 20)) # 10 Pixel in einer Zeile
|
||
# Vier Ecken und der Schlusspunkt, ohne Zusatzpunkte auf den Kanten: _sides() und _place_rows()
|
||
# nehmen die ersten vier Ringpunkte als Ecken, GEOS setzt sie je nach Plattform verschieden.
|
||
assert len(rect.exterior.coords) == 5
|
||
assert _sides(rect) == pytest.approx((1.0, 10.0))
|