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:
1 parent
505b1969ec
commit
3fe3cd5c45
4 files changed
+182
-7
No files matched your search
@@ -19,7 +19,7 @@ from skimage.draw import polygon as fill_polygon
|
|||||||
from skimage.morphology import skeletonize
|
from skimage.morphology import skeletonize
|
||||||
|
|
||||||
from .classify import CLASSES
|
from .classify import CLASSES
|
||||||
from .vectors import _skeleton_graph
|
from .vectors import skeleton_graph
|
||||||
|
|
||||||
ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt
|
ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt
|
||||||
AREA_CLASSES = ("wasser", "land", "gruen", "sand", "stadt")
|
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
|
# 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.
|
# abgeschnittenen Straße um ihre Halbbreite und erreicht die 10-m-Zone nie.
|
||||||
skeleton = skeletonize(visible | M) & ~M
|
skeleton = skeletonize(visible | M) & ~M
|
||||||
graph = _skeleton_graph(skeleton)
|
graph = skeleton_graph(skeleton)
|
||||||
width = ndi.distance_transform_edt(visible)
|
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)]
|
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)
|
pairs, single = _pair_ends(ends, M | any_road)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,7 +12,7 @@ from .coords import SLICE
|
|||||||
ROAD_CLASSES = ("strasse", "weg", "spekulativ")
|
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."""
|
"""Konturpunkte (Zeile, Spalte) → Weltkoordinaten in Metern."""
|
||||||
return [(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in contour]
|
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 = [], []
|
outers, holes = [], []
|
||||||
for contour in find_contours(padded.astype(float), 0.5):
|
for contour in find_contours(padded.astype(float), 0.5):
|
||||||
local = contour - 1
|
local = contour - 1
|
||||||
ring = _to_world(local)
|
ring = to_world(local)
|
||||||
if len(ring) < 4:
|
if len(ring) < 4:
|
||||||
continue
|
continue
|
||||||
poly = Polygon(ring).buffer(0)
|
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
|
(Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird
|
||||||
einmal umlaufen.
|
einmal umlaufen.
|
||||||
"""
|
"""
|
||||||
graph = _skeleton_graph(skeletonize(road_mask(labels)))
|
graph = skeleton_graph(skeletonize(road_mask(labels)))
|
||||||
lines = []
|
lines = []
|
||||||
for path in _skeleton_paths(graph):
|
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:
|
if line.length >= 15:
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
return lines
|
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."""
|
"""Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten."""
|
||||||
pixels = set(zip(*np.nonzero(skeleton)))
|
pixels = set(zip(*np.nonzero(skeleton)))
|
||||||
graph = nx.Graph()
|
graph = nx.Graph()
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user