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>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 00:36:40 +02:00
1 parent 505b1969ec
commit 3fe3cd5c45
4 files changed
+182 -7

No files matched your search

+2 -2
View File
@@ -19,7 +19,7 @@ from skimage.draw import polygon as fill_polygon
from skimage.morphology import skeletonize
from .classify import CLASSES
from .vectors import _skeleton_graph
from .vectors import skeleton_graph
ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt
AREA_CLASSES = ("wasser", "land", "gruen", "sand", "stadt")
@@ -140,7 +140,7 @@ def _plan_roads(L, M, B, edges, stats):
# 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)
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)
+115
View File
@@ -0,0 +1,115 @@
"""Straßennetz als Graph statt als nachgezeichnete Kontur.
Der Entwurf (docs/superpowers/specs/2026-09-16-strassen-design.md) misst nach, dass die
Mittellinien im Median nur um den Faktor 1,0104 von der Luftlinie abweichen: Die Straßen sind
längst gerade, das Zackige sitzt allein in den Rändern von road_polygons(). Wer die Ränder aus
Mittellinie und Breite neu baut, statt sie zu glätten, bekommt gerade Straßen fast geschenkt.
centerlines() in vectors.py taugt dafür nicht: Sein Filter length >= 15 wirft an jeder Kreuzung
die kurzen Stummel weg, und von 3.038 Linienenden treffen sich dadurch nur 81 zu Kreuzungen.
Hier bleibt das Skelett deshalb ungefiltert, und die Kreuzungen entstehen aus den Pixeln mit
Grad >= 3 statt aus zufällig aufeinandertreffenden Linienenden.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import networkx as nx
import numpy as np
from scipy import ndimage as ndi
from skimage.morphology import disk
from .vectors import skeleton_graph, to_world
# Radius, in dem Kreuzungspixel zu einem Knoten verschmelzen. Am echten Mosaik gemessen: Die
# 5.970 Pixel mit Grad >= 3 fallen damit auf 862 Knoten zusammen. Kleiner, und eine breite
# Kreuzung zerfällt in mehrere Knoten, weil das Skelett dort sternförmig ausfranst.
JUNCTION_RADIUS = 8
@dataclass(frozen=True)
class Node:
point: tuple[float, float]
@dataclass
class Edge:
a: int
b: int
points: list[tuple[float, float]]
width: float = 0.0
klass: str = ""
@dataclass
class Graph:
nodes: list[Node] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
def incident(self, node: int) -> list[int]:
return [k for k, e in enumerate(self.edges) if e.a == node or e.b == node]
def degree(self, node: int) -> int:
# Eine Schleife (a == b) zählt zweimal, wie in jedem Graphen.
return sum((e.a == node) + (e.b == node) for e in self.edges)
def graph_from_skeleton(skel: np.ndarray) -> Graph:
"""Knoten aus den Kreuzungspixeln, Kanten aus dem, was zwischen ihnen übrig bleibt."""
pixels = skeleton_graph(skel)
clusters, points = _junction_clusters(pixels, skel.shape)
graph = Graph(nodes=[Node(p) for p in points])
rest = pixels.copy()
rest.remove_nodes_from([p for p in pixels if clusters[p] > 0])
for component in nx.connected_components(rest):
chain = _chain(rest.subgraph(component))
if not chain:
continue
a = _attach(graph, pixels, clusters, chain[0])
b = _attach(graph, pixels, clusters, chain[-1])
line = to_world(chain)
if graph.nodes[a].point != line[0]:
line.insert(0, graph.nodes[a].point)
if graph.nodes[b].point != line[-1]:
line.append(graph.nodes[b].point)
graph.edges.append(Edge(a=a, b=b, points=line))
return graph
def _junction_clusters(pixels: nx.Graph, shape) -> tuple[np.ndarray, list[tuple[float, float]]]:
"""Kreuzungspixel, um JUNCTION_RADIUS gewachsen, in Zusammenhangskomponenten.
Der Knotenpunkt ist der Schwerpunkt der *ungewachsenen* Pixel: Das Wachsen dient nur dazu,
benachbarte Kreuzungen zusammenzufassen, und würde den Punkt sonst nach außen ziehen.
"""
junction = np.zeros(shape, dtype=bool)
for pixel in pixels:
if pixels.degree(pixel) >= 3:
junction[pixel] = True
labelled, count = ndi.label(ndi.binary_dilation(junction, disk(JUNCTION_RADIUS)))
points = []
for k in range(1, count + 1):
rows, cols = np.nonzero(junction & (labelled == k))
points.append(to_world([(rows.mean(), cols.mean())])[0])
# labelled traegt die Knotennummer auf jedem *gewachsenen* Pixel. Genau das braucht _attach():
# Das Ende einer Kette liegt neben einem gewachsenen Pixel, nicht neben einem Kreuzungspixel.
return labelled, points
def _chain(sub: nx.Graph) -> list[tuple[int, int]]:
"""Die Pixel einer Kette in Laufrichtung. Ohne Kreuzungspixel ist jede Komponente eine."""
ends = [n for n in sub if sub.degree(n) <= 1]
if not ends:
return [] # geschlossener Ring ohne Anschluss: keine Straße, die irgendwo hinführt
if len(ends) == 1:
return [ends[0]]
return nx.shortest_path(sub, ends[0], ends[-1])
def _attach(graph: Graph, pixels: nx.Graph, clusters: np.ndarray, pixel) -> int:
"""Knoten-ID am Ende einer Kette: der berührte Kreuzungsknoten oder ein neues Sackgassenende."""
for neighbour in pixels.neighbors(pixel):
if clusters[neighbour] > 0:
return int(clusters[neighbour]) - 1
graph.nodes.append(Node(to_world([pixel])[0]))
return len(graph.nodes) - 1
+5 -5
View File
@@ -12,7 +12,7 @@ from .coords import SLICE
ROAD_CLASSES = ("strasse", "weg", "spekulativ")
def _to_world(contour: np.ndarray) -> list[tuple[float, float]]:
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]
@@ -22,7 +22,7 @@ def mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Po
outers, holes = [], []
for contour in find_contours(padded.astype(float), 0.5):
local = contour - 1
ring = _to_world(local)
ring = to_world(local)
if len(ring) < 4:
continue
poly = Polygon(ring).buffer(0)
@@ -128,16 +128,16 @@ def centerlines(labels: np.ndarray) -> list[LineString]:
(Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird
einmal umlaufen.
"""
graph = _skeleton_graph(skeletonize(road_mask(labels)))
graph = skeleton_graph(skeletonize(road_mask(labels)))
lines = []
for path in _skeleton_paths(graph):
line = LineString(_to_world(path)).simplify(1.5)
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:
def skeleton_graph(skeleton: np.ndarray) -> nx.Graph:
"""Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten."""
pixels = set(zip(*np.nonzero(skeleton)))
graph = nx.Graph()