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>
116 lines
4.6 KiB
Python
116 lines
4.6 KiB
Python
"""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
|