This commit is contained in:
knso committed 2026-09-15 21:05:18 +02:00
1 parent 6a056262a8
commit a55dca5fb6
11 files changed
+132 -25

No files matched your search

+7 -7
View File
@@ -38,7 +38,7 @@ box = [[-885, 2045], [-833, 1995]]
[[annotation]]
name = "Schild 97 in B2"
kind = "symbol"
kind = "region"
box = [[-551, 2199], [-497, 2147]]
[[annotation]]
@@ -153,7 +153,7 @@ box = [[-861, 1572], [-797, 1521]]
[[annotation]]
name = "Schild 97 in B4"
kind = "symbol"
kind = "region"
box = [[-652, 1601], [-592, 1539]]
[[annotation]]
@@ -238,7 +238,7 @@ box = [[1440, 1336], [1515, 1289]]
[[annotation]]
name = "Schild 97 in A6"
kind = "symbol"
kind = "region"
box = [[-869, 1230], [-817, 1184]]
[[annotation]]
@@ -278,7 +278,7 @@ box = [[-1000, 830], [-945, 774]]
[[annotation]]
name = "Schild 97 in A7"
kind = "symbol"
kind = "region"
box = [[-945, 933], [-893, 883]]
[[annotation]]
@@ -338,7 +338,7 @@ box = [[-670, 530], [-613, 490]]
[[annotation]]
name = "Schild 404 in B8"
kind = "symbol"
kind = "region"
box = [[-626, 618], [-567, 575]]
[[annotation]]
@@ -478,7 +478,7 @@ box = [[1905, -60], [1934, -131]]
[[annotation]]
name = "Schild 397 in B12"
kind = "symbol"
kind = "region"
box = [[-685, -346], [-625, -397]]
[[annotation]]
@@ -493,7 +493,7 @@ box = [[-313, -398], [-169, -433]]
[[annotation]]
name = "Schild 397 in D12"
kind = "symbol"
kind = "region"
box = [[-146, -410], [-87, -460]]
[[annotation]]
+11 -3
View File
@@ -24,7 +24,7 @@ from skimage.morphology import disk
from .classify import PALETTE
from .coords import SLICE, chunk_for_world, chunk_name
from .inpaint import RebuildStats, fill_nearest, is_thin, rebuild
from .inpaint import RebuildStats, add_buildings, fill_nearest, is_thin, rebuild
KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region")
MARGIN = {"text_white": 7, "text_red": 7, "marker": 3, "symbol": 4, "street_name": 1, "region": 0}
@@ -406,7 +406,7 @@ def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: Anno
owners.setdefault(int(cid), entry)
touched.append(ids)
generated = []
generated, rebuilt = [], []
h, w = labels.shape
for cid, sl in enumerate(ndi.find_objects(components), start=1):
if sl is None:
@@ -419,9 +419,17 @@ def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: Anno
continue
mask = np.zeros(labels.shape, dtype=bool)
mask[sl] = piece
result = rebuild(labels, mask, union)
result = rebuild(labels, mask, union, add=False)
owner = owners[cid]
owner.stats = result.stats if owner.stats is None else _merged(owner.stats, result.stats)
rebuilt.append((cid, sl))
# Ergänzt wird erst, wenn alle Masken aufgebaut sind: sonst zählten Buchstabenkonturen noch nicht
# aufgebauter Masken als Straße und teilten Blöcke, und das Ergebnis hinge von der Reihenfolge ab.
for cid, sl in rebuilt:
mask = np.zeros(labels.shape, dtype=bool)
mask[sl] = components[sl] == cid
result = add_buildings(labels, mask, union)
owners[cid].stats = _merged(owners[cid].stats, result.stats)
generated += [Polygon([(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in corners])
for corners in result.buildings]
for entry, ids in zip(entries, touched):
+18 -4
View File
@@ -90,12 +90,14 @@ def is_thin(mask: np.ndarray) -> bool:
return float(ndi.distance_transform_edt(np.pad(mask, 1)).max()) <= THIN_DEPTH
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None,
add: bool = True) -> RebuildResult:
"""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, Flächen,
Gebäude schließen, Gebäude ergänzen.
Gebäude schließen, Gebäude ergänzen. Mit add=False endet der Aufbau nach dem Schließen;
ergänzt wird dann über add_buildings, sobald alle Masken aufgebaut sind.
"""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
@@ -107,6 +109,14 @@ def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = N
_stamp(L, M, painted, road, points, halves)
_fill_areas(L, M & ~painted, B)
_close_buildings(L, M, B, painted, stats)
corners = _add_buildings(labels, mask, blocked, stats) if add else []
return RebuildResult(stats, corners)
def add_buildings(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
"""Ergänzt Gebäude unter einer schon aufgebauten Maske; die Statistik trägt nur added und note."""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
corners = _add_buildings(labels, mask, blocked, stats)
return RebuildResult(stats, corners)
@@ -535,13 +545,17 @@ def _window(mask, margin):
def _rectangle(rows, cols) -> Polygon:
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante."""
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante.
Liegen die Pixel auf einer Linie, ist das Hüllrechteck eine Linie; eckige Enden machen daraus
wieder ein Rechteck mit vier Ecken statt einer Kapsel.
"""
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")
return envelope.buffer(0.5, cap_style="square", join_style="mitre")
def _sides(rect: Polygon) -> tuple[float, float]:
+1
View File
@@ -25,6 +25,7 @@ def main() -> None:
labels = drop_map_labels(labels)
removal = remove_annotations(mosaic, labels, load_annotations(Path("data/annotations.toml")))
labels = removal.labels
labels = drop_map_labels(labels) # Inselreste, die erst das Entfernen im Wasser hinterlässt
print(removal.report())
if unknown_share > 0.05:
raise SystemExit(f"{unknown_share:.1%} der Pixel unbekannt. Palette kalibrieren, nicht die Grenze anheben.")
+51 -1
View File
@@ -1,4 +1,5 @@
import numpy as np
from scipy import ndimage as ndi
from shapely.geometry import box
from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_mask, candidates, grid_mask,
@@ -6,7 +7,7 @@ from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_ma
from leonida.classify import CLASSES, classify
PARK, ROAD, URBAN = (192, 216, 128), (80, 80, 80), (216, 216, 216)
WHITE, OUTLINE = (248, 248, 248), (80, 88, 96)
WHITE, OUTLINE, BUILDING = (248, 248, 248), (80, 88, 96), (176, 176, 176)
def picture(h=200, w=300, colour=URBAN):
@@ -154,6 +155,24 @@ def test_the_grid_is_closed_before_anything_is_rebuilt():
assert removal.grid_lines == 7 # Spalten 0, 100, 200, 300 und Zeilen 0, 100, 200
def test_a_grid_line_through_an_entry_leaves_no_strip_in_it():
img = picture(colour=PARK)
img[:, 98:102] = ROAD # Gitterlinie, als Straße gelesen, quer durch den Schriftzug
letter(img, (80, 120), (60, 140))
annotations = AnnotationList([entry("text_white", (60, 141), (40, 161))], grid=Grid(100, 6))
removal = remove_annotations(img, classify(img), annotations)
assert (removal.labels == CLASSES["land"]).all() # erst danach aufgebaut, liefe dort ein Straßenstreifen
def test_a_thin_mask_takes_the_nearest_class_and_leaves_no_slit():
img = picture()
img[60:140, 100:200] = BUILDING
line = entry("region", (99, 102), (90, 211), name="Zeigerlinie") # 3 m breit, quer über das Gebäude
removal = remove_annotations(img, classify(img), AnnotationList([line]))
assert (removal.labels[60:140, 100:200] == CLASSES["gebaeude"]).all()
assert (removal.labels[99:102, 90:100] == CLASSES["stadt"]).all()
def test_entries_sharing_one_mask_are_reported_together():
img = picture()
img[:, 140:160] = ROAD
@@ -165,6 +184,37 @@ def test_entries_sharing_one_mask_are_reported_together():
assert "#2 Rechts · A1 · Maske" in report and "gemeinsam mit #1" in report
class ReversedComponents:
"""scipy.ndimage für annotations.py, nur nummeriert label() die Flächen rückwärts."""
def __getattr__(self, name):
return getattr(ndi, name)
@staticmethod
def label(image, structure=None):
numbers, count = ndi.label(image, structure=structure)
return np.where(numbers > 0, count + 1 - numbers, 0), count
def test_added_buildings_do_not_depend_on_the_order_of_the_masks(monkeypatch):
img = picture(300, 400)
img[20:40, :] = ROAD
img[260:280, :] = ROAD
img[:, 20:40] = ROAD
img[:, 360:380] = ROAD
for top in (60, 100, 140, 180):
for left in (60, 120):
img[top:top + 20, left:left + 30] = BUILDING # links sichtbar bebaut
letter(img, (43, 257), (180, 190)) # Kontur in Straßenfarbe teilt den Block bis zum Aufbau
annotations = AnnotationList([entry("text_white", (30, 271), (165, 206), index=1, name="Balken"),
entry("region", (60, 241), (220, 341), index=2, name="Rest")])
forward = remove_annotations(img, classify(img), annotations)
monkeypatch.setattr("leonida.annotations.ndi", ReversedComponents())
backward = remove_annotations(img, classify(img), annotations)
assert forward.generated # im verdeckten Teil rechts entstehen Gebäude
assert (forward.labels == backward.labels).all()
def test_buildings_mostly_inside_added_rectangles_are_marked():
heights = [{"height": 9.0, "source": "district"}, {"height": 12.0, "source": "district"}]
marked = mark_generated([box(1, 1, 9, 9), box(6, 0, 16, 10)], heights, [box(0, 0, 10, 10)])
+32 -2
View File
@@ -1,8 +1,9 @@
import numpy as np
import pytest
from scipy import ndimage as ndi
from leonida.classify import CLASSES
from leonida.inpaint import fill_nearest, is_thin, rebuild
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"]
@@ -133,6 +134,18 @@ def test_a_path_stays_a_path_and_the_street_wins_the_crossing():
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
@@ -203,9 +216,12 @@ def test_a_hidden_block_part_gets_the_density_of_the_visible_part():
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
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)
@@ -223,6 +239,14 @@ def test_no_buildings_are_added_in_a_park():
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
@@ -242,3 +266,9 @@ def test_rebuild_is_deterministic():
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
assert len(rect.exterior.coords) == 5 # vier Ecken und der Schlusspunkt
assert _sides(rect) == pytest.approx((1.0, 10.0))