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 .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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user