Files
gta6/tools/extract/leonida/vectors.py
T
knsoandClaude Sonnet 5 3fe3cd5c45 feat(extract): Straßennetz als Graph aus dem Skelett
centerlines() verwirft alles unter 15 m und zerlegt das Netz damit an jeder
Kreuzung: von 3.038 Linienenden treffen sich nur 81. Der neue Graph lässt
das Skelett ungefiltert, macht aus den 5.970 Pixeln mit Grad >= 3 durch
Verschmelzen im Umkreis von 8 m die 862 Kreuzungen und hängt die Kanten
dort ein.

_skeleton_graph/_to_world in vectors.py sind jetzt öffentliche Schnittstelle
für roads.py (skeleton_graph/to_world); inpaint.py zieht seinen einzigen
Aufruf von skeleton_graph mit, sonst bräche dessen Import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 00:36:40 +02:00

180 lines
8.2 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 networkx as nx
import numpy as np
from scipy import ndimage as ndi
from shapely.geometry import LineString, Polygon
from shapely.ops import unary_union
from skimage.measure import find_contours
from skimage.morphology import closing, disk, skeletonize
from .classify import CLASSES
from .coords import SLICE
ROAD_CLASSES = ("strasse", "weg", "spekulativ")
def to_world(contour: np.ndarray) -> list[tuple[float, float]]:
"""Konturpunkte (Zeile, Spalte) → Weltkoordinaten in Metern."""
return [(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in contour]
def mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Polygon]:
padded = np.pad(mask, 1)
outers, holes = [], []
for contour in find_contours(padded.astype(float), 0.5):
local = contour - 1
ring = to_world(local)
if len(ring) < 4:
continue
poly = Polygon(ring).buffer(0)
if poly.is_empty or poly.area < min_area or poly.geom_type != "Polygon":
continue
(outers if _signed_area(local) > 0 else holes).append(poly)
result = []
for outer in outers:
inner = [h.exterior.coords for h in holes if outer.contains(h)]
result.append(Polygon(outer.exterior.coords, inner).buffer(0).simplify(simplify))
return [p for p in result if p.geom_type == "Polygon" and p.area >= min_area]
def _signed_area(contour: np.ndarray) -> float:
"""Shoelace-Summe in Pixelkoordinaten (Zeile, Spalte).
find_contours liefert Außenkonturen und Löcher mit entgegengesetztem Umlaufsinn;
das Vorzeichen trennt sie zuverlässig. Eine Stichprobe der Maske am Mittelpunkt der
Kontur (naheliegende Alternative) versagt bei konzentrischen Formen wie einer Insel
mit Lagune: der Mittelpunkt der äußeren Kontur liegt dann selbst in der Lagune.
"""
row, col = contour[:, 0], contour[:, 1]
return float(0.5 * np.sum(col * np.roll(row, -1) - np.roll(col, -1) * row))
def land_polygons(labels: np.ndarray) -> list[Polygon]:
land = labels != CLASSES["wasser"]
land = ndi.binary_closing(ndi.binary_opening(land, disk(2)), disk(2))
return mask_polygons(land, min_area=50, simplify=1.0)
def road_mask(labels: np.ndarray) -> np.ndarray:
mask = np.zeros(labels.shape, dtype=bool)
for name in ROAD_CLASSES:
mask |= labels == CLASSES[name]
# Schließen überbrückt die eingezeichneten Straßennamen.
return closing(mask, disk(3))
# Wuchs der Landmaske in Pixeln (= Metern) für die Brückenprüfung. Am echten Mosaik
# gemessen: 3-5 px reichen nicht, weil Bordstein/Anti-Aliasing zwischen "land" und der
# angrenzenden Straße auf Karten mit echtem Rauschen mehr Lücke lassen als im
# künstlichen Test. Ist der Wuchs zu knapp, berührt eine ganz normale Küstenstraße
# nirgends "Land" und verkettet sich mit jeder anderen zu knappen Stelle zu einem
# Brückenstück, das (wie der ursprüngliche Fensteransatz) fast die ganze Karte
# überspannt. Ab 8 px reißt diese Kette am echten Mosaik; 10 px lässt zusätzlich Raum
# zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge.
LAND_REACH_PX = 10
# Reichweite in Pixeln (= Metern) für die "nahe am Wasser"-Prüfung: fern von Land allein
# macht noch keine Brücke (siehe unten). Am echten Mosaik gemessen (WATER_REACH_PX →
# Brücken gesamt / davon "trocken" mit < 10 % Wasser im Umkreis: 6→40/1, 8→40/1,
# 10→41/2, 14→72/17) ist 6 bis 10 ein stabiles Plateau, das ab 14 abbricht und wieder
# trockene Flächen als Brücke mitreißt; 8 liegt in dessen Mitte.
WATER_REACH_PX = 8
def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
"""Straßenflächen, aufgeteilt in Stücke auf Land, Brückenstücke über Wasser und
breite Flächen an Land, die nur zufällig weit von "Land" entfernt liegen.
"Land" ist hier die Geländeklasse ohne die Straße selbst, um LAND_REACH_PX nach
außen gewachsen. Ohne den Ausschluss der Straße wäre das zirkulär: land_polygons()
zählt "nicht Wasser" als Land, und eine Straße ist nie "wasser" – ein Damm quer
durchs Meer würde dann fast vollständig mit sich selbst überlappen und nie als
Brücke auffallen. Der Wuchs sorgt dafür, dass eine Straße, die direkt an echtem
Land beginnt, dort noch als "auf Land" erkannt wird, statt an ihrer eigenen Kante
ins Leere zu greifen.
"Fern von Land" reicht als Brückenkriterium allein nicht aus: eine breite Kreuzungs-
oder Parkfläche ist überall nur Straße, ihre Mitte liegt also zwangsläufig weiter als
LAND_REACH_PX von straßenfreiem Land entfernt, obwohl gar kein Wasser in der Nähe
ist. Ein Stück jenseits von "Land" zählt deshalb nur dann als Brücke, wenn es
zusätzlich innerhalb WATER_REACH_PX von Wasser liegt; der Rest – fern von Land, aber
auch fern von Wasser – bleibt "auf Land".
"""
solid_ground = ndi.binary_dilation(
(labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX)
)
land = unary_union(mask_polygons(solid_ground, min_area=50, simplify=1.0))
wet = unary_union(mask_polygons(
ndi.binary_dilation(labels == CLASSES["wasser"], disk(WATER_REACH_PX)),
min_area=50, simplify=1.0))
out = []
for poly in mask_polygons(road_mask(labels), min_area=20, simplify=0.8):
off = poly.difference(land)
for part, bridge in ((poly.intersection(land), False), # auf Land
(off.intersection(wet), True), # über Wasser: Brücke
(off.difference(wet), False)): # breite Fläche an Land
for piece in getattr(part, "geoms", [part]):
if piece.geom_type == "Polygon" and piece.area >= 20:
out.append((piece, bridge))
return out
def centerlines(labels: np.ndarray) -> list[LineString]:
"""Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen.
Eine Kontur um das ein Pixel breite Skelett liefert jede Linie doppelt (sie umrundet
das Band und läuft auf der anderen Seite zurück). Stattdessen wird das Skelett als
Pixelgraph gelesen: Skelettpixel sind Knoten, 8er-Nachbarschaft ist eine Kante. Je
Zusammenhangskomponente werden die Pfade zwischen Knoten mit Grad ungleich 2 verfolgt
(Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird
einmal umlaufen.
"""
graph = skeleton_graph(skeletonize(road_mask(labels)))
lines = []
for path in _skeleton_paths(graph):
line = LineString(to_world(path)).simplify(1.5)
if line.length >= 15:
lines.append(line)
return lines
def skeleton_graph(skeleton: np.ndarray) -> nx.Graph:
"""Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten."""
pixels = set(zip(*np.nonzero(skeleton)))
graph = nx.Graph()
graph.add_nodes_from(pixels)
for r, c in pixels:
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
neighbour = (r + dr, c + dc)
if (dr or dc) and neighbour in pixels:
graph.add_edge((r, c), neighbour)
return graph
def _skeleton_paths(graph: nx.Graph) -> list[list[tuple[int, int]]]:
"""Pfade zwischen Enden/Kreuzungen (Grad ≠ 2) je Zusammenhangskomponente;
ein reiner Ring aus lauter Grad-2-Knoten wird einmal umlaufen."""
paths, seen = [], set()
for component in nx.connected_components(graph):
sub = graph.subgraph(component)
branch_nodes = [n for n in sub if sub.degree(n) != 2]
if not branch_nodes:
start = next(iter(component))
ring = list(nx.dfs_preorder_nodes(sub, start))
ring.append(start)
paths.append(ring)
continue
for start in branch_nodes:
for first in sub.neighbors(start):
if frozenset((start, first)) in seen:
continue
path, prev, current = [start, first], start, first
seen.add(frozenset((start, first)))
while sub.degree(current) == 2:
nxt = next(n for n in sub.neighbors(current) if n != prev)
seen.add(frozenset((current, nxt)))
path.append(nxt)
prev, current = current, nxt
paths.append(path)
return paths