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