Files
gta6/tools/extract/tests/test_inpaint.py
T
knsoandClaude Sonnet 5 73a6df7f08 fix(extract): Hüllrechteck ohne Zusatzpunkte auf den Kanten
_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>
2026-09-16 05:54:22 +02:00

277 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))