Fix-Runde 2 (Ruling 17). Nachmessung am echten Mosaik zeigte: 143 der 190 aus Fix-Runde 1 markierten Brücken (137.288 von 208.744 m² Fläche) hatten unter 10 % Wasser in ihrer Umgebung, darunter die beiden größten Stücke mit 2,4 % und 0,1 % Wasser. Das waren breite Kreuzungs- und Parkflächen an Land: ihre Mitte liegt allein deshalb weiter als LAND_REACH_PX von straßenfreiem Land entfernt, weil dort ringsum nur Straße ist – kein Bezug zu Wasser. Ein größeres LAND_REACH_PX heilt das nicht (ab 16 kippt der bestehende Test). Straßenstücke jenseits von "Land" zählen jetzt nur noch als Brücke, wenn sie zusätzlich innerhalb WATER_REACH_PX von Wasser liegen; neue Konstante WATER_REACH_PX = 8, am echten Mosaik kalibriert (6-10 ein stabiles Plateau bei ~40 Brücken, ab 14 bricht es weg und reißt wieder trockene Flächen mit). Regressionstest zuerst rot gesehen (test_wide_road_area_on_land_is_not_a_bridge), bestehende Tests unverändert und weiterhin grün. 17/17 Tests grün. Auf dem echten Mosaik: 475 Straßenstücke, 40 Brücken, größtes Brückenstück 12.588 m² – deckt sich mit den unabhängig ermittelten Referenzwerten der Prüfung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
180 lines
8.2 KiB
Python
180 lines
8.2 KiB
Python
import networkx as nx
|
||
import numpy as np
|
||
from scipy import ndimage as ndi
|
||
from shapely.geometry import LineString, Polygon
|
||
from shapely.ops import unary_union
|
||
from skimage.measure import find_contours
|
||
from skimage.morphology import closing, disk, skeletonize
|
||
|
||
from .classify import CLASSES
|
||
from .coords import SLICE
|
||
|
||
ROAD_CLASSES = ("strasse", "weg", "spekulativ")
|
||
|
||
|
||
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]
|
||
|
||
|
||
def mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Polygon]:
|
||
padded = np.pad(mask, 1)
|
||
outers, holes = [], []
|
||
for contour in find_contours(padded.astype(float), 0.5):
|
||
local = contour - 1
|
||
ring = _to_world(local)
|
||
if len(ring) < 4:
|
||
continue
|
||
poly = Polygon(ring).buffer(0)
|
||
if poly.is_empty or poly.area < min_area or poly.geom_type != "Polygon":
|
||
continue
|
||
(outers if _signed_area(local) > 0 else holes).append(poly)
|
||
result = []
|
||
for outer in outers:
|
||
inner = [h.exterior.coords for h in holes if outer.contains(h)]
|
||
result.append(Polygon(outer.exterior.coords, inner).buffer(0).simplify(simplify))
|
||
return [p for p in result if p.geom_type == "Polygon" and p.area >= min_area]
|
||
|
||
|
||
def _signed_area(contour: np.ndarray) -> float:
|
||
"""Shoelace-Summe in Pixelkoordinaten (Zeile, Spalte).
|
||
|
||
find_contours liefert Außenkonturen und Löcher mit entgegengesetztem Umlaufsinn;
|
||
das Vorzeichen trennt sie zuverlässig. Eine Stichprobe der Maske am Mittelpunkt der
|
||
Kontur (naheliegende Alternative) versagt bei konzentrischen Formen wie einer Insel
|
||
mit Lagune: der Mittelpunkt der äußeren Kontur liegt dann selbst in der Lagune.
|
||
"""
|
||
row, col = contour[:, 0], contour[:, 1]
|
||
return float(0.5 * np.sum(col * np.roll(row, -1) - np.roll(col, -1) * row))
|
||
|
||
|
||
def land_polygons(labels: np.ndarray) -> list[Polygon]:
|
||
land = labels != CLASSES["wasser"]
|
||
land = ndi.binary_closing(ndi.binary_opening(land, disk(2)), disk(2))
|
||
return mask_polygons(land, min_area=50, simplify=1.0)
|
||
|
||
|
||
def road_mask(labels: np.ndarray) -> np.ndarray:
|
||
mask = np.zeros(labels.shape, dtype=bool)
|
||
for name in ROAD_CLASSES:
|
||
mask |= labels == CLASSES[name]
|
||
# Schließen überbrückt die eingezeichneten Straßennamen.
|
||
return closing(mask, disk(3))
|
||
|
||
|
||
# Wuchs der Landmaske in Pixeln (= Metern) für die Brückenprüfung. Am echten Mosaik
|
||
# gemessen: 3-5 px reichen nicht, weil Bordstein/Anti-Aliasing zwischen "land" und der
|
||
# angrenzenden Straße auf Karten mit echtem Rauschen mehr Lücke lassen als im
|
||
# künstlichen Test. Ist der Wuchs zu knapp, berührt eine ganz normale Küstenstraße
|
||
# nirgends "Land" und verkettet sich mit jeder anderen zu knappen Stelle zu einem
|
||
# Brückenstück, das (wie der ursprüngliche Fensteransatz) fast die ganze Karte
|
||
# überspannt. Ab 8 px reißt diese Kette am echten Mosaik; 10 px lässt zusätzlich Raum
|
||
# zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge.
|
||
LAND_REACH_PX = 10
|
||
|
||
# Reichweite in Pixeln (= Metern) für die "nahe am Wasser"-Prüfung: fern von Land allein
|
||
# macht noch keine Brücke (siehe unten). Am echten Mosaik gemessen (WATER_REACH_PX →
|
||
# Brücken gesamt / davon "trocken" mit < 10 % Wasser im Umkreis: 6→40/1, 8→40/1,
|
||
# 10→41/2, 14→72/17) ist 6 bis 10 ein stabiles Plateau, das ab 14 abbricht und wieder
|
||
# trockene Flächen als Brücke mitreißt; 8 liegt in dessen Mitte.
|
||
WATER_REACH_PX = 8
|
||
|
||
|
||
def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
|
||
"""Straßenflächen, aufgeteilt in Stücke auf Land, Brückenstücke über Wasser und
|
||
breite Flächen an Land, die nur zufällig weit von "Land" entfernt liegen.
|
||
|
||
"Land" ist hier die Geländeklasse ohne die Straße selbst, um LAND_REACH_PX nach
|
||
außen gewachsen. Ohne den Ausschluss der Straße wäre das zirkulär: land_polygons()
|
||
zählt "nicht Wasser" als Land, und eine Straße ist nie "wasser" – ein Damm quer
|
||
durchs Meer würde dann fast vollständig mit sich selbst überlappen und nie als
|
||
Brücke auffallen. Der Wuchs sorgt dafür, dass eine Straße, die direkt an echtem
|
||
Land beginnt, dort noch als "auf Land" erkannt wird, statt an ihrer eigenen Kante
|
||
ins Leere zu greifen.
|
||
|
||
"Fern von Land" reicht als Brückenkriterium allein nicht aus: eine breite Kreuzungs-
|
||
oder Parkfläche ist überall nur Straße, ihre Mitte liegt also zwangsläufig weiter als
|
||
LAND_REACH_PX von straßenfreiem Land entfernt, obwohl gar kein Wasser in der Nähe
|
||
ist. Ein Stück jenseits von "Land" zählt deshalb nur dann als Brücke, wenn es
|
||
zusätzlich innerhalb WATER_REACH_PX von Wasser liegt; der Rest – fern von Land, aber
|
||
auch fern von Wasser – bleibt "auf Land".
|
||
"""
|
||
solid_ground = ndi.binary_dilation(
|
||
(labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX)
|
||
)
|
||
land = unary_union(mask_polygons(solid_ground, min_area=50, simplify=1.0))
|
||
wet = unary_union(mask_polygons(
|
||
ndi.binary_dilation(labels == CLASSES["wasser"], disk(WATER_REACH_PX)),
|
||
min_area=50, simplify=1.0))
|
||
out = []
|
||
for poly in mask_polygons(road_mask(labels), min_area=20, simplify=0.8):
|
||
off = poly.difference(land)
|
||
for part, bridge in ((poly.intersection(land), False), # auf Land
|
||
(off.intersection(wet), True), # über Wasser: Brücke
|
||
(off.difference(wet), False)): # breite Fläche an Land
|
||
for piece in getattr(part, "geoms", [part]):
|
||
if piece.geom_type == "Polygon" and piece.area >= 20:
|
||
out.append((piece, bridge))
|
||
return out
|
||
|
||
|
||
def centerlines(labels: np.ndarray) -> list[LineString]:
|
||
"""Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen.
|
||
|
||
Eine Kontur um das ein Pixel breite Skelett liefert jede Linie doppelt (sie umrundet
|
||
das Band und läuft auf der anderen Seite zurück). Stattdessen wird das Skelett als
|
||
Pixelgraph gelesen: Skelettpixel sind Knoten, 8er-Nachbarschaft ist eine Kante. Je
|
||
Zusammenhangskomponente werden die Pfade zwischen Knoten mit Grad ungleich 2 verfolgt
|
||
(Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird
|
||
einmal umlaufen.
|
||
"""
|
||
graph = _skeleton_graph(skeletonize(road_mask(labels)))
|
||
lines = []
|
||
for path in _skeleton_paths(graph):
|
||
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:
|
||
"""Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten."""
|
||
pixels = set(zip(*np.nonzero(skeleton)))
|
||
graph = nx.Graph()
|
||
graph.add_nodes_from(pixels)
|
||
for r, c in pixels:
|
||
for dr in (-1, 0, 1):
|
||
for dc in (-1, 0, 1):
|
||
neighbour = (r + dr, c + dc)
|
||
if (dr or dc) and neighbour in pixels:
|
||
graph.add_edge((r, c), neighbour)
|
||
return graph
|
||
|
||
|
||
def _skeleton_paths(graph: nx.Graph) -> list[list[tuple[int, int]]]:
|
||
"""Pfade zwischen Enden/Kreuzungen (Grad ≠ 2) je Zusammenhangskomponente;
|
||
ein reiner Ring aus lauter Grad-2-Knoten wird einmal umlaufen."""
|
||
paths, seen = [], set()
|
||
for component in nx.connected_components(graph):
|
||
sub = graph.subgraph(component)
|
||
branch_nodes = [n for n in sub if sub.degree(n) != 2]
|
||
if not branch_nodes:
|
||
start = next(iter(component))
|
||
ring = list(nx.dfs_preorder_nodes(sub, start))
|
||
ring.append(start)
|
||
paths.append(ring)
|
||
continue
|
||
for start in branch_nodes:
|
||
for first in sub.neighbors(start):
|
||
if frozenset((start, first)) in seen:
|
||
continue
|
||
path, prev, current = [start, first], start, first
|
||
seen.add(frozenset((start, first)))
|
||
while sub.degree(current) == 2:
|
||
nxt = next(n for n in sub.neighbors(current) if n != prev)
|
||
seen.add(frozenset((current, nxt)))
|
||
path.append(nxt)
|
||
prev, current = current, nxt
|
||
paths.append(path)
|
||
return paths
|