From 368ea90b3a6dc658cdc8c6e955e74fda8264f313 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 18:37:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Wiederaufbau=20von=20Stra?= =?UTF-8?q?=C3=9Fen,=20Wegen=20und=20Fl=C3=A4chen=20unter=20Beschriftungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- tools/extract/leonida/inpaint.py | 395 ++++++++++++++++++++++++++++ tools/extract/tests/test_inpaint.py | 164 ++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 tools/extract/leonida/inpaint.py create mode 100644 tools/extract/tests/test_inpaint.py diff --git a/tools/extract/leonida/inpaint.py b/tools/extract/leonida/inpaint.py new file mode 100644 index 0000000..752039d --- /dev/null +++ b/tools/extract/leonida/inpaint.py @@ -0,0 +1,395 @@ +"""Wiederaufbau des Klassenbilds unter entfernten Kartenbeschriftungen. + +Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md, Abschnitt „Wiederaufbau“. +Alle Funktionen arbeiten auf dem Klassenbild aus classify() (1 px = 1 m) und verändern es an Ort +und Stelle. Pixelkoordinaten sind (Zeile, Spalte), Zeilen wachsen nach Süden. Weltkoordinaten +kennt dieses Modul nicht; die Umrechnung macht annotations.py. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +import numpy as np +from scipy import ndimage as ndi +from skimage.draw import disk +from skimage.morphology import skeletonize + +from .classify import CLASSES +from .vectors import _skeleton_graph + +ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt +AREA_CLASSES = ("wasser", "land", "gruen", "sand", "stadt") +WINDOW_MARGIN = 60 # m um das Hüllrechteck der Maske +THIN_DEPTH = 2.0 # m: bis zu diesem Abstand vom Maskenrand gilt eine Maske als dünn +TRUST = 10.0 # m: Mindestabstand vertrauter Mittellinienpunkte zur Maske +DIRECTION_LENGTH = 15.0 # m Mittellinie für die Richtung eines Straßenendes +WIDTH_LENGTH = 5.0 # m Mittellinie für die Halbbreite — weiter hinten liegt oft schon die Kreuzung +MIN_TRUSTED = 5.0 # m: kürzere vertraute Stücke ergeben kein Straßenende +REACH = 20.0 # m: so weit darf die Maske vor einem Straßenende liegen +CROSS_THROUGH = 60.0 # m: größter Abstand zwischen zwei Anschlüssen, über die ein Strahl weiterläuft +EDGE_MARGIN = 8 # m: so nah am Rand des Ausschnitts ist das Skelett unzuverlässig +MAX_ANGLE = 20.0 # Grad zwischen Richtung eines Endes und der Verbindungslinie +WIDTH_RATIO = 1.6 # Halbbreiten eines Paares höchstens um diesen Faktor verschieden +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 +SOURCE_MIN_AREA = 50 # m²: kleinere Flächenstücke sind Kantenglättung und taugen nicht als Füllquelle + + +@dataclass +class RebuildStats: + connected: int = 0 # Paare von Straßenenden und Enden mit Ziel auf der anderen Seite + extended: int = 0 # geradeaus weitergeführte Enden, die anschließen + dead_ends: int = 0 # Enden ohne jeden Anschluss; sie werden nicht gemalt + closed: int = 0 # geschlossene Gebäude + added: int = 0 # ergänzte Gebäude + note: str = "" + + +@dataclass +class RebuildResult: + stats: RebuildStats + buildings: list[list[tuple[float, float]]] = field(default_factory=list) # Ecken (Zeile, Spalte) + + +@dataclass(frozen=True) +class RoadEnd: + row: float + col: float + dr: float # Einheitsvektor in Richtung Maske + dc: float + half: float # Halbbreite in m + + +def fill_nearest(labels: np.ndarray, target: np.ndarray, blocked: np.ndarray) -> None: + """Jedes Pixel in target übernimmt die Klasse des nächsten Pixels außerhalb von blocked.""" + if not target.any() or blocked.all(): + return + index = ndi.distance_transform_edt(blocked, return_distances=False, return_indices=True) + rows, cols = np.nonzero(target) + labels[rows, cols] = labels[index[0][rows, cols], index[1][rows, cols]] + + +def is_thin(mask: np.ndarray) -> bool: + """Dünn: kein Maskenpixel liegt weiter als THIN_DEPTH vom Maskenrand entfernt.""" + 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: + """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, dann Flächen. + """ + blocked = mask if blocked is None else blocked + stats = RebuildStats() + win = _window(mask, WINDOW_MARGIN) + L, M, B = labels[win], mask[win], blocked[win] + edges = (win[0].start == 0, win[0].stop == labels.shape[0], win[1].start == 0, win[1].stop == labels.shape[1]) + painted = np.zeros(M.shape, dtype=bool) + for road, points, halves in _plan_roads(L, M, B, edges, stats): + _stamp(L, M, painted, road, points, halves) + _fill_areas(L, M & ~painted, B) + return RebuildResult(stats) + + +# --- Straßen und Wege ----------------------------------------------------------------------- + +def _plan_roads(L, M, B, edges, stats): + """Plant alle Striche, höherrangige Klassen zuerst, damit niedrigere an ihnen anschließen. + + Gemalt wird danach in ROAD_ORDER, damit an Kreuzungen die Straße gewinnt. + """ + dist_mask = ndi.distance_transform_edt(~M) + any_road = np.isin(L, [CLASSES[name] for name in ROAD_ORDER]) & ~B + centre = np.zeros(M.shape, dtype=bool) + strokes = {name: [] for name in ROAD_ORDER} + for name in reversed(ROAD_ORDER): + road = CLASSES[name] + visible = (L == road) & ~B + if not visible.any(): + continue + # Die Maske zählt beim Skelettieren als Straße: sonst schrumpft die Mittellinie einer + # abgeschnittenen Straße um ihre Halbbreite und erreicht die 10-m-Zone nie. + skeleton = skeletonize(visible | M) & ~M + graph = _skeleton_graph(skeleton) + width = ndi.distance_transform_edt(visible) + ends = [e for e in _road_ends(graph, dist_mask, width) if not _near_edge(e, M.shape, edges)] + pairs, single = _pair_ends(ends, M | any_road) + for a, b in pairs: + points = _hermite(a, b) + strokes[name].append((road, points, np.linspace(a.half, b.half, len(points)))) + _mark_centre(centre, points) + stats.connected += 1 + zone = np.argwhere(skeleton & visible & (dist_mask < TRUST)) + for end, length, joins in _cast_rays(L, B, dist_mask, road, single, centre, edges): + if joins: + count = max(2, int(math.ceil(length * 2)) + 1) + steps = np.linspace(0.0, length, count) + points = list(zip(end.row + end.dr * steps, end.col + end.dc * steps)) + stats.extended += 1 + else: + points = _to_target(end, zone, M, M | any_road) + if points is None: + stats.dead_ends += 1 # Sackgassen werden nicht gemalt + continue + stats.connected += 1 + strokes[name].append((road, points, [end.half] * len(points))) + _mark_centre(centre, points) + return [stroke for name in ROAD_ORDER for stroke in strokes[name]] + + +def _to_target(end: RoadEnd, zone, M, passable): + """Kurve zur sichtbaren Straße derselben Klasse auf der anderen Seite der Maske, sonst None.""" + best = None + for r, c in zone: + vr, vc = r - end.row, c - end.col + if math.hypot(vr, vc) == 0: + continue + angle = _angle((end.dr, end.dc), (vr, vc)) + if angle > MAX_ANGLE or (best is not None and angle >= best[0]): + continue + norm = math.hypot(vr, vc) + target = RoadEnd(float(r), float(c), -vr / norm, -vc / norm, end.half) + points = _hermite(end, target) + inside = sum(1 for pr, pc in points if M[int(round(pr)), int(round(pc))]) + if inside >= TARGET_MIN_INSIDE and _corridor(passable, points): + best = (angle, points) + return None if best is None else best[1] + + +def _road_ends(graph, dist_mask, width) -> list[RoadEnd]: + ends = [] + for node in sorted(graph.nodes): + if dist_mask[node] < TRUST: + continue + inward = [n for n in graph.neighbors(node) if dist_mask[n] < TRUST] + if not inward: + continue + trace = _trace(graph, node, inward, dist_mask) + if _length(trace) < MIN_TRUSTED: + continue + dr, dc = node[0] - trace[-1][0], node[1] - trace[-1][1] + norm = math.hypot(dr, dc) + near = [p for k, p in enumerate(trace) if _length(trace[:k + 1]) <= WIDTH_LENGTH] + half = float(np.median([width[p] for p in near])) + end = RoadEnd(float(node[0]), float(node[1]), dr / norm, dc / norm, half) + if not _reaches(dist_mask, end): + continue + if not any(math.hypot(e.row - end.row, e.col - end.col) <= 3 and e.dr * end.dr + e.dc * end.dc > 0.9 + for e in ends): + ends.append(end) + return ends + + +def _trace(graph, start, inward, dist_mask): + """Mittellinie ab start von der Maske weg, an Verzweigungen möglichst geradeaus.""" + path = [start] + seen = {start, *inward} + heading = (start[0] - sum(n[0] for n in inward) / len(inward), + start[1] - sum(n[1] for n in inward) / len(inward)) + while _length(path) < DIRECTION_LENGTH: + current = path[-1] + options = [n for n in graph.neighbors(current) if n not in seen and dist_mask[n] >= TRUST] + if not options: + break + if len(path) >= 3: + ref = path[max(0, len(path) - 6)] + heading = (current[0] - ref[0], current[1] - ref[1]) + nxt = max(options, key=lambda n: _cos((n[0] - current[0], n[1] - current[1]), heading)) + path.append(nxt) + seen.add(nxt) + return path + + +def _reaches(dist_mask, end: RoadEnd) -> bool: + """Liegt innerhalb von REACH vor dem Ende Maske höchstens eine Halbbreite neben dem Strahl?""" + h, w = dist_mask.shape + for s in np.arange(0.5, REACH + 0.25, 0.5): + r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s)) + if not (0 <= r < h and 0 <= c < w): + return False + if dist_mask[r, c] <= end.half: + return True + return False + + +def _pair_ends(ends, passable): + """Paare aufsteigend nach Winkelsumme; die Kurve muss über Maske oder sichtbarer Straße laufen.""" + candidates = [] + for i, a in enumerate(ends): + for j in range(i + 1, len(ends)): + b = ends[j] + vr, vc = b.row - a.row, b.col - a.col + if math.hypot(vr, vc) == 0: + continue + angle_a = _angle((a.dr, a.dc), (vr, vc)) + angle_b = _angle((b.dr, b.dc), (-vr, -vc)) + if angle_a > MAX_ANGLE or angle_b > MAX_ANGLE: + continue + if max(a.half, b.half) > WIDTH_RATIO * max(min(a.half, b.half), 0.5): + continue + candidates.append((angle_a + angle_b, i, j)) + used, pairs = set(), [] + for _, i, j in sorted(candidates): + if i in used or j in used or not _corridor(passable, _hermite(ends[i], ends[j])): + continue + used.update((i, j)) + pairs.append((ends[i], ends[j])) + return pairs, [e for k, e in enumerate(ends) if k not in used] + + +def _corridor(passable, points) -> bool: + h, w = passable.shape + hits = [0 <= int(round(r)) < h and 0 <= int(round(c)) < w and passable[int(round(r)), int(round(c))] + for r, c in points] + return sum(hits) >= CORRIDOR_SHARE * len(hits) + + +def _hermite(a: RoadEnd, b: RoadEnd): + dist = math.hypot(b.row - a.row, b.col - a.col) + t = np.linspace(0.0, 1.0, max(3, 2 * int(math.ceil(dist)) + 1)) + h00, h10 = 2 * t**3 - 3 * t**2 + 1, t**3 - 2 * t**2 + t + h01, h11 = -2 * t**3 + 3 * t**2, t**3 - t**2 + rows = h00 * a.row + h10 * a.dr * dist + h01 * b.row - h11 * b.dr * dist + cols = h00 * a.col + h10 * a.dc * dist + h01 * b.col - h11 * b.dc * dist + return list(zip(rows, cols)) + + +def _near_edge(end: RoadEnd, shape, edges) -> bool: + """Liegt das Ende näher als EDGE_MARGIN an einer Fensterkante, die zugleich Rand des Ausschnitts ist?""" + top, bottom, left, right = edges + h, w = shape + return ((top and end.row < EDGE_MARGIN) or (bottom and end.row > h - 1 - EDGE_MARGIN) + or (left and end.col < EDGE_MARGIN) or (right and end.col > w - 1 - EDGE_MARGIN)) + + +def _cast_rays(L, B, dist_mask, road, ends, centre, edges): + """Weiterführen: (Ende, Länge, schließt an) je Ende ohne Partner. + + „In der Maske“ heißt: Maske höchstens eine Halbbreite neben der Mittellinie, damit auch eine + seitlich angeschnittene Straße weiterläuft. Ein Anschluss ist eine gemalte Mittellinie in der + Maske (Kreuzung) oder sichtbare Straße der eigenen Klasse oder strasse höchstens eine Halbbreite + neben dem Strahl. Der Strahl läuft von Anschluss zu Anschluss weiter, solange der nächste + höchstens CROSS_THROUGH entfernt ist, und endet am letzten — im Straßenraster geht eine Straße + an einer Kreuzung meist weiter. Erreicht er in der Maske den Rand des Ausschnitts, schließt er + dort an: die Straße läuft über die Karte hinaus. Verlässt er die Maske auf anderem Boden, ist + Schluss; ohne jeden Anschluss ist das Ende eine Sackgasse. + """ + top, bottom, left, right = edges + h, w = dist_mask.shape + limit = math.hypot(h, w) + joinable = ((L == road) | (L == CLASSES["strasse"])) & ~B # Buchstabenkonturen zählen nicht + rays = [] + for end in ends: + reach = int(math.ceil(end.half)) + entered, joins, outside = None, [], 0.0 + s = 0.0 + while s <= limit: + r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s)) + if not (0 <= r < h and 0 <= c < w): + if entered is not None and ((r < 0 and top) or (r >= h and bottom) or (c < 0 and left) or (c >= w and right)): + joins.append(s - 0.5) # Rand des Ausschnitts: die Straße geht weiter + break + inside = dist_mask[r, c] <= end.half + if entered is None: + entered = s if inside else None + elif inside: + outside = 0.0 + if centre[r, c] and dist_mask[r, c] == 0: + joins.append(s) + elif joinable[max(0, r - reach):r + reach + 1, max(0, c - reach):c + reach + 1].any(): + joins.append(s) + outside += 0.5 + if outside > 2 * end.half + 2: + break # fest an sichtbarer Straße angekommen + else: + break # anderer Boden: hier ist Schluss + if joins and s - joins[-1] > CROSS_THROUGH: + break + s += 0.5 + if entered is not None: + rays.append([end, entered, joins[-1] if joins else s, bool(joins)]) + events = [] + for i in range(len(rays)): + for j in range(i + 1, len(rays)): + hit = _crossing(rays[i], rays[j]) + if hit is not None: + events.append((max(hit), i, j, *hit)) + for _, i, j, ti, tj in sorted(events): + if ti <= rays[i][2] and tj <= rays[j][2]: + rays[i][2:] = [ti, True] + rays[j][2:] = [tj, True] + return [(end, stop, joins) for end, _, stop, joins in rays] + + +def _crossing(ray_a, ray_b): + a, a_in, a_stop, _ = ray_a + b, b_in, b_stop, _ = ray_b + denom = a.dr * b.dc - a.dc * b.dr + if abs(denom) < 1e-9: + return None + qr, qc = b.row - a.row, b.col - a.col + ta = (qr * b.dc - qc * b.dr) / denom + tb = (qr * a.dc - qc * a.dr) / denom + if a_in <= ta <= a_stop and b_in <= tb <= b_stop: + return ta, tb + return None + + +def _stamp(L, M, painted, road, points, halves): + strip = np.zeros(M.shape, dtype=bool) + for (r, c), half in zip(points, halves): + rr, cc = disk((r, c), max(float(half), 0.5), shape=M.shape) + strip[rr, cc] = True + strip &= M + L[strip] = road + painted |= strip + + +def _mark_centre(centre, points): + h, w = centre.shape + for r, c in points: + r, c = int(round(r)), int(round(c)) + centre[max(0, r - 1):min(h, r + 2), max(0, c - 1):min(w, c + 2)] = True + + +# --- Flächen ------------------------------------------------------------------------------------- + +def _fill_areas(L, target, B): + """Nächste sichtbare Fläche; Stücke unter SOURCE_MIN_AREA (Kantenglättung) zählen nicht als Quelle.""" + if not target.any(): + return + source = np.zeros(L.shape, dtype=bool) + for name in AREA_CLASSES: + area = (L == CLASSES[name]) & ~B + comps, count = ndi.label(area) + if count: + sizes = ndi.sum(area, comps, np.arange(1, count + 1)) + source |= np.isin(comps, 1 + np.flatnonzero(sizes >= SOURCE_MIN_AREA)) + if not source.any(): + L[target] = CLASSES["stadt"] + return + fill_nearest(L, target, ~source) + + +# --- Hilfen ----------------------------------------------------------------------------------- + +def _window(mask, margin): + rows, cols = np.nonzero(mask) + h, w = mask.shape + return (slice(max(0, int(rows.min()) - margin), min(h, int(rows.max()) + 1 + margin)), + slice(max(0, int(cols.min()) - margin), min(w, int(cols.max()) + 1 + margin))) + + +def _length(path) -> float: + return sum(math.dist(path[i - 1], path[i]) for i in range(1, len(path))) + + +def _cos(a, b) -> float: + na, nb = math.hypot(*a), math.hypot(*b) + return (a[0] * b[0] + a[1] * b[1]) / (na * nb) if na and nb else -1.0 + + +def _angle(a, b) -> float: + return math.degrees(math.acos(max(-1.0, min(1.0, _cos(a, b))))) \ No newline at end of file diff --git a/tools/extract/tests/test_inpaint.py b/tools/extract/tests/test_inpaint.py new file mode 100644 index 0000000..c3e9f11 --- /dev/null +++ b/tools/extract/tests/test_inpaint.py @@ -0,0 +1,164 @@ +import numpy as np +from scipy import ndimage as ndi + +from leonida.classify import CLASSES +from leonida.inpaint import 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_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() \ No newline at end of file