Files
gta6/tools/extract/leonida/inpaint.py
T

395 lines
17 KiB
Python

"""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)))))