From 3fe3cd5c45c80ea0984ffacd2d82a135295dff11 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 00:36:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Stra=C3=9Fennetz=20als=20Graph?= =?UTF-8?q?=20aus=20dem=20Skelett?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/extract/leonida/inpaint.py | 4 +- tools/extract/leonida/roads.py | 115 ++++++++++++++++++++++++ tools/extract/leonida/vectors.py | 10 +-- tools/extract/tests/test_roads_graph.py | 60 +++++++++++++ 4 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 tools/extract/leonida/roads.py create mode 100644 tools/extract/tests/test_roads_graph.py diff --git a/tools/extract/leonida/inpaint.py b/tools/extract/leonida/inpaint.py index d11fa76..f31f2e8 100644 --- a/tools/extract/leonida/inpaint.py +++ b/tools/extract/leonida/inpaint.py @@ -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) diff --git a/tools/extract/leonida/roads.py b/tools/extract/leonida/roads.py new file mode 100644 index 0000000..254f4d9 --- /dev/null +++ b/tools/extract/leonida/roads.py @@ -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 diff --git a/tools/extract/leonida/vectors.py b/tools/extract/leonida/vectors.py index c2375f1..1aab2d4 100644 --- a/tools/extract/leonida/vectors.py +++ b/tools/extract/leonida/vectors.py @@ -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() diff --git a/tools/extract/tests/test_roads_graph.py b/tools/extract/tests/test_roads_graph.py new file mode 100644 index 0000000..bdd436c --- /dev/null +++ b/tools/extract/tests/test_roads_graph.py @@ -0,0 +1,60 @@ +"""Der Graph, aus dem die Straßen entstehen. Gegenstück zum Entwurf, Abschnitt "Das Netz".""" +from __future__ import annotations + +import numpy as np + +from leonida.coords import SLICE +from leonida.roads import JUNCTION_RADIUS, graph_from_skeleton + + +def skeleton(pixels: list[tuple[int, int]], shape=(120, 120)) -> np.ndarray: + skel = np.zeros(shape, dtype=bool) + for r, c in pixels: + skel[r, c] = True + return skel + + +def kreuz(arm: int = 40) -> np.ndarray: + """Ein Pluszeichen: vier Arme von 40 px an einem Mittelpunkt.""" + pixels = [(60, c) for c in range(60 - arm, 60 + arm + 1)] + pixels += [(r, 60) for r in range(60 - arm, 60 + arm + 1)] + return skeleton(pixels) + + +def test_ein_kreuz_gibt_einen_knoten_und_vier_kanten(): + graph = graph_from_skeleton(kreuz()) + junctions = [i for i in range(len(graph.nodes)) if graph.degree(i) >= 3] + assert len(junctions) == 1 + assert graph.degree(junctions[0]) == 4 + assert len(graph.edges) == 4 + + +def test_kanten_beginnen_und_enden_am_knotenpunkt(): + graph = graph_from_skeleton(kreuz()) + mitte = graph.nodes[next(i for i in range(len(graph.nodes)) if graph.degree(i) >= 3)].point + for edge in graph.edges: + enden = {edge.points[0], edge.points[-1]} + assert mitte in enden, "jede Kante des Kreuzes hängt am Mittelknoten" + + +def test_zwei_kreuzungen_naeher_als_der_radius_werden_ein_knoten(): + # Zwei Abzweige im Abstand von 4 m — unter JUNCTION_RADIUS, also ein Knoten. + pixels = [(60, c) for c in range(20, 100)] + pixels += [(r, 58) for r in range(40, 60)] + pixels += [(r, 62) for r in range(60, 80)] + graph = graph_from_skeleton(skeleton(pixels)) + assert 4 < JUNCTION_RADIUS + assert len([i for i in range(len(graph.nodes)) if graph.degree(i) >= 3]) == 1 + + +def test_gerade_strecke_ohne_abzweig_gibt_eine_kante_mit_zwei_enden(): + graph = graph_from_skeleton(skeleton([(60, c) for c in range(20, 100)])) + assert len(graph.edges) == 1 + assert [graph.degree(i) for i in range(len(graph.nodes))] == [1, 1] + + +def test_punkte_liegen_in_weltkoordinaten(): + graph = graph_from_skeleton(skeleton([(60, c) for c in range(20, 100)])) + xs = [x for x, _ in graph.edges[0].points] + assert min(xs) == SLICE["x0"] + 20 + assert max(xs) == SLICE["x0"] + 99