start
This commit is contained in:
1 parent
6a056262a8
commit
a55dca5fb6
11 files changed
+132
-25
No files matched your search
@@ -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]]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user