feat(extract): Pipeline baut Straßen aus dem Graphen

run.py baut jetzt aus dem Straßengraphen (Tasks 3-11) statt aus den nachgezeichneten
Straßenrändern: Fahrbahn, Bordstein, Gehweg und Restflächen aus build_street(),
amerikanische Markierungen aus markings(). fit_to_carriageway() bringt Bodenplatte und
Beläge auf dasselbe Loch wie die Fahrbahn und hält Palmen dabei von ihr fern - nach
derselben Regel, mit der apply_street() sie schon von der Jefferson Avenue fernhält.

split_by_chunk_all() bekommt dafür eine parametrisierbare Splittergrenze: Ein Strich der
gestrichelten Linien ist 3 m x 10 cm = 0,3 m² und wäre an der bisherigen 0,5-m²-Grenze für
Flächen verschwunden. Markierungen laufen deshalb über den neuen Helfer chunk_markings()
mit eigener, kleinerer Grenze; alle anderen Aufrufer bleiben bei 0,5 m².

Am echten Netz gemessen: 1.537 Knoten, 1.790 Kanten, 830 Bordsteinstücke, 171
Restflächen, 22.394 Markierungen, 56 Palmen an der Fahrbahn entfernt. Fahrbahn-, Gehweg-
und Restflächen sowie die Bordsteinlänge treffen die vorab am echten Netz gemessenen
Erwartungswerte auf 0,03-0,3 % genau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 19:43:16 +02:00
1 parent 892558fc50
commit 2201be37bd
3 files changed
+181 -15

No files matched your search

+8 -4
View File
@@ -130,16 +130,20 @@ def chunk_box_of(i: int, j: int) -> Polygon:
return chunk_box(i, j) return chunk_box(i, j)
def split_by_chunk_all(polys, i: int, j: int): def split_by_chunk_all(polys, i: int, j: int, min_area: float = 0.5):
"""Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen.""" """Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen.
min_area verwirft die Splitter, die beim Zuschneiden an der Chunkgrenze entstehen. 0,5 m² passt
für Flächen; Markierungen sind kleiner und brauchen eine eigene Grenze (run.py, chunk_markings).
"""
cell = chunk_box(i, j) cell = chunk_box(i, j)
for poly in polys: for poly in polys:
part = poly.intersection(cell) part = poly.intersection(cell)
if not part.is_empty and part.area > 0.5: if not part.is_empty and part.area > min_area:
if part.geom_type == "Polygon": if part.geom_type == "Polygon":
yield part yield part
else: else:
yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > 0.5) yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > min_area)
def in_chunk(point, i: int, j: int) -> bool: def in_chunk(point, i: int, j: int) -> bool:
+100 -11
View File
@@ -1,22 +1,83 @@
"""Erzeugt aus den Kacheln die Chunk-Dateien. Aufruf: uv run python run.py""" """Erzeugt aus den Kacheln die Chunk-Dateien. Aufruf: uv run python run.py"""
import json import json
from collections import Counter
from dataclasses import replace
from pathlib import Path from pathlib import Path
from scipy import ndimage as ndi
from shapely.geometry import LineString, Point
from shapely.ops import unary_union
from leonida.annotations import load_annotations, mark_generated, remove_annotations from leonida.annotations import load_annotations, mark_generated, remove_annotations
from leonida.buildings import building_polygons from leonida.buildings import building_polygons
from leonida.classify import CLASSES, class_shares, classify, drop_map_labels from leonida.classify import CLASSES, class_shares, classify, drop_map_labels
from leonida.emit import (chunk_box_of, chunk_payload, in_chunk, split_by_chunk_all, from leonida.emit import (chunk_box_of, chunk_payload, in_chunk, split_by_chunk_all,
write_chunks, write_debug_png) write_chunks, write_debug_png)
from leonida.heights import assign_heights, palm_points from leonida.heights import PALM_KERB, assign_heights, palm_points
from leonida.marking import markings
from leonida.park import World, apply_flamingo_park from leonida.park import World, apply_flamingo_park
from leonida.roads import road_graph
from leonida.section import SECTION
from leonida.street import build_street, punch
from leonida.tiles import COMMIT, download_tiles, load_mosaic from leonida.tiles import COMMIT, download_tiles, load_mosaic
from leonida.vectors import centerlines, land_polygons, mask_polygons, road_polygons from leonida.vectors import land_polygons, mask_polygons, road_mask, road_polygons
from overview import render_map from overview import render_map
SEED = 20260912 SEED = 20260912
OUT = Path(__file__).resolve().parents[2] / "public" / "data" / "vice-a" OUT = Path(__file__).resolve().parents[2] / "public" / "data" / "vice-a"
PARK = Path("data/flamingo-park.toml") PARK = Path("data/flamingo-park.toml")
TOLERANCE = {"wasser": (0.30, 0.36), "strasse": (0.10, 0.14), "gebaeude": (0.09, 0.13)} TOLERANCE = {"wasser": (0.30, 0.36), "strasse": (0.10, 0.14), "gebaeude": (0.09, 0.13)}
MARKING_MIN_AREA = 0.001 # m² — ein Strich ist 3 m × 10 cm = 0,3 m², die Flächengrenze 0,5 m² verwürfe ihn
def _pieces(geometry) -> list:
return [g for g in getattr(geometry, "geoms", [geometry]) if not g.is_empty]
def _clip_lines(line, cell) -> list:
"""Der Anteil einer Linie in diesem Chunk, in Stücken."""
part = line.intersection(cell)
return [g for g in _pieces(part) if g.geom_type == "LineString" and g.length > 0.5]
def fit_to_carriageway(world: World, carriage) -> World:
"""Die Welt an die gebaute Fahrbahn anpassen (Entwurf, Ablauf Schritte 3 und 7).
Bodenplatte und Beläge bekommen dasselbe Loch — fehlt es in einer Schicht, liegt dort ein Belag
über der Mulde oder die Platte schließt sie. Palmen näher als PALM_KERB an der Fahrbahn fallen
weg, nach derselben Regel, mit der apply_street() sie von der Jefferson Avenue fernhält:
palm_points() hält nur Abstand zum gemessenen Korridor, und die Fahrbahn ragt an Kurven und
Kreuzungen darüber hinaus.
"""
return replace(world,
land=punch(world.land, carriage),
surfaces=[(part, kind) for poly, kind in world.surfaces
for part in punch([poly], carriage)],
palms=[p for p in world.palms if carriage.distance(Point(p)) >= PALM_KERB])
def chunk_markings(marks, i: int, j: int) -> list:
"""Die Markierungen in Chunk (i, j) — an der Chunkgrenze geteilt, nicht verworfen."""
return [(part, art) for poly, art in marks
for part in split_by_chunk_all([poly], i, j, min_area=MARKING_MIN_AREA)]
def _classified(street, graph) -> list:
"""Jede Fahrbahnfläche mit der Klasse der breitesten Kante, deren Mittelpunkt in ihr liegt.
Vorsicht beim Lesen des Felds: carriageway_area() verschmilzt das Netz zu wenigen
zusammenhängenden Teilen, und ein Teil trägt dann die breiteste Klasse aller Straßen darin. Am
echten Netz sind das 5 von 213 Teilen, aber 91 % der Fahrbahnfläche tragen "boulevard". Über
die einzelne Straße sagt das Feld also wenig — die Klasse je Straße steht in edges. Heute liest
es kein Verbraucher; Browser und Minimap färben die Fahrbahn einheitlich.
"""
mitten = [(e.klass, LineString(e.points).interpolate(0.5, normalized=True)) for e in graph.edges]
out = []
for poly in _pieces(street.carriageway):
treffer = [klass for klass, mitte in mitten if poly.contains(mitte)]
klass = max(treffer, key=lambda k: SECTION[k].carriageway) if treffer else "wohnstrasse"
out.append((poly, klass))
return out
def main() -> None: def main() -> None:
@@ -38,7 +99,12 @@ def main() -> None:
land = land_polygons(labels) land = land_polygons(labels)
roads = road_polygons(labels) roads = road_polygons(labels)
lines = centerlines(labels) # Der Graph ersetzt die nachgezeichneten Straßenränder (Entwurf Teil 2). Die
# Distanztransformation misst an jedem Straßenpixel den Abstand zum nächsten Nicht-Straßen-
# pixel; doppelt genommen ist das die Korridorbreite an dieser Stelle.
distance = ndi.distance_transform_edt(road_mask(labels))
graph = road_graph(labels, distance)
print(f"Straßennetz: {len(graph.nodes)} Knoten, {len(graph.edges)} Kanten")
sand = mask_polygons(labels == CLASSES["sand"], 50, 1.0) sand = mask_polygons(labels == CLASSES["sand"], 50, 1.0)
parks = mask_polygons(labels == CLASSES["gruen"], 50, 1.0) parks = mask_polygons(labels == CLASSES["gruen"], 50, 1.0)
urban = mask_polygons(labels == CLASSES["stadt"], 80, 1.5) urban = mask_polygons(labels == CLASSES["stadt"], 80, 1.5)
@@ -72,25 +138,47 @@ def main() -> None:
heights = assign_heights([p for p, _ in buildings], landmarks, districts, SEED) heights = assign_heights([p for p, _ in buildings], landmarks, districts, SEED)
marked = mark_generated([p for p, _ in buildings], heights, removal.generated) marked = mark_generated([p for p, _ in buildings], heights, removal.generated)
print(f"Gebäude: {len(buildings)}, davon {marked} unter entfernten Beschriftungen ergänzt") print(f"Gebäude: {len(buildings)}, davon {marked} unter entfernten Beschriftungen ergänzt")
palms = palm_points(sand, parks, lines, [p for p, _ in buildings], road_shapes, edge_lines = [LineString(e.points) for e in graph.edges]
land, SEED) palms = palm_points(sand, parks, edge_lines, [p for p, _ in buildings], road_shapes, land, SEED)
world = World(land=land, surfaces=surfaces, roads=roads, centerlines=lines, world = World(land=land, surfaces=surfaces, roads=roads, graph=graph,
buildings=[(p, h, s) for (p, s), h in zip(buildings, heights)], palms=palms) buildings=[(p, h, s) for (p, s), h in zip(buildings, heights)], palms=palms)
world, messages = apply_flamingo_park(world, PARK, SEED) world, messages = apply_flamingo_park(world, PARK, SEED)
for message in messages: for message in messages:
print(message) print(message)
corridor = unary_union([p for p, bridge in world.roads if not bridge])
bridge_polys = [p for p, bridge in world.roads if bridge]
street = build_street(world.graph, corridor, bridge_polys)
marks = markings(world.graph)
palmen = len(world.palms)
world = fit_to_carriageway(world, street.carriageway)
arten = dict(sorted(Counter(art for _, art in marks).items()))
print(f"Straßen: {len(street.kerb)} Bordsteinstücke, {len(street.lots)} Restflächen, "
f"{len(marks)} Markierungen {arten}, {palmen - len(world.palms)} Palmen an der Fahrbahn entfernt")
classified = _classified(street, world.graph) # einmal, nicht je Chunk: 5,8 s am echten Netz
payloads = [] payloads = []
for j in range(12): for j in range(12):
for i in range(12): for i in range(12):
payloads.append(chunk_payload( payloads.append(chunk_payload(
i, j, i, j,
land=list(split_by_chunk_all(world.land, i, j)), land=list(split_by_chunk_all(world.land, i, j)),
surfaces=[(part, kind) for (p, kind) in world.surfaces for part in split_by_chunk_all([p], i, j)], surfaces=[(part, kind) for (p, kind) in world.surfaces
roads=[(part, b) for (p, b) in world.roads for part in split_by_chunk_all([p], i, j)], for part in split_by_chunk_all([p], i, j)],
centerlines=[ln for ln in world.centerlines if ln.intersects(chunk_box_of(i, j))], carriageway=[(part, klass) for poly, klass in classified
buildings=[(p, h, s) for (p, h, s) in world.buildings if p.intersects(chunk_box_of(i, j))], for part in split_by_chunk_all([poly], i, j)],
curb=[part for line in street.kerb
for part in _clip_lines(line, chunk_box_of(i, j))],
sidewalk=list(split_by_chunk_all(_pieces(street.sidewalk), i, j)),
markings=chunk_markings(marks, i, j),
lots=list(split_by_chunk_all(street.lots, i, j)),
bridges=list(split_by_chunk_all(bridge_polys, i, j)),
edges=[(part, e.klass, e.width, SECTION[e.klass].carriageway)
for e in world.graph.edges
for part in _clip_lines(LineString(e.points), chunk_box_of(i, j))],
buildings=[(p, h, s) for (p, h, s) in world.buildings
if p.intersects(chunk_box_of(i, j))],
palms=[pt for pt in world.palms if in_chunk(pt, i, j)], palms=[pt for pt in world.palms if in_chunk(pt, i, j)],
paths=[part for p in world.paths for part in split_by_chunk_all([p], i, j)], paths=[part for p in world.paths for part in split_by_chunk_all([p], i, j)],
)) ))
@@ -101,7 +189,8 @@ def main() -> None:
"class_shares": shares, "class_shares": shares,
"attribution": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. " "attribution": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. "
"Wahrzeichen: rolux/gtamaplib-vc (Unlicense). " "Wahrzeichen: rolux/gtamaplib-vc (Unlicense). "
"Flamingo Park: © OpenStreetMap-Mitwirkende (ODbL).", "Flamingo Park: © OpenStreetMap-Mitwirkende (ODbL). "
"Bordstein und Gehweg: Just8 (Sketchfab, CC BY 4.0).",
}) })
print(render_map(OUT, OUT), "->", OUT) print(render_map(OUT, OUT), "->", OUT)
@@ -0,0 +1,73 @@
"""Die Reihenfolge, in der die Straßen entstehen — ohne den vollen Lauf."""
from __future__ import annotations
from shapely.geometry import box
from shapely.ops import unary_union
from leonida.emit import chunk_box_of
from leonida.park import World
from leonida.roads import Edge, Graph, Node
from leonida.street import build_street, punch
from run import chunk_markings, fit_to_carriageway
def _gerade_wohnstrasse():
"""100 m Wohnstraße entlang y = 0: Fahrbahn 7 m breit, also von y = -3,5 bis 3,5."""
graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")])
return graph, build_street(graph, box(0, -6, 100, 6), bridges=[])
def _welt(graph, *, surfaces=(), palms=()):
return World(land=[box(-20, -50, 120, 50)], surfaces=list(surfaces), roads=[], graph=graph,
buildings=[], palms=list(palms))
def test_boden_und_belag_bekommen_dasselbe_loch():
"""Vergisst run.py eine der beiden Schichten, liegt dort ein Belag über der Mulde oder die
Bodenplatte schließt sie."""
graph, street = _gerade_wohnstrasse()
platte = box(-20, -50, 120, 50)
welt = _welt(graph, surfaces=[(box(-20, -50, 50, 50), "urban"), (box(50, -50, 120, 50), "park")])
out = fit_to_carriageway(welt, street.carriageway)
boden = unary_union(out.land)
assert boden.intersection(street.carriageway).area < 0.01, "die Fahrbahn liegt im Loch"
loch = street.carriageway.intersection(platte).area
assert abs(platte.area - boden.area - loch) < 0.01, "das Loch ist nicht größer als die Fahrbahn"
for kind in ("urban", "park"):
belag = unary_union([poly for poly, k in out.surfaces if k == kind])
assert belag.intersection(street.carriageway).area < 0.01, f"kein Belag {kind} über der Mulde"
assert abs(unary_union([poly for poly, _ in out.surfaces]).area - boden.area) < 0.01, "dasselbe Loch"
def test_gehweg_liegt_nicht_im_loch():
graph, street = _gerade_wohnstrasse()
land = unary_union(punch([box(-20, -50, 120, 50)], street.carriageway))
assert street.sidewalk.difference(land).area < 0.5, "der Gehweg liegt auf der Bodenplatte"
def test_keine_palme_auf_oder_dicht_an_der_fahrbahn():
"""Entwurf, Ablauf Schritt 7: Palmen werden nach dem Straßenbau neu gesetzt. Am echten Netz
standen ohne diesen Schritt 24 Palmen auf der Fahrbahn und 32 weitere näher als PALM_KERB."""
graph, street = _gerade_wohnstrasse()
welt = _welt(graph, palms=[(50.0, 0.0), (50.0, 4.5), (50.0, 5.5)])
out = fit_to_carriageway(welt, street.carriageway)
assert out.palms == [(50.0, 5.5)], "auf der Fahrbahn und 1 m daneben weg, 2 m daneben bleibt"
def test_striche_ueberleben_den_zuschnitt():
"""Ein Strich der gestrichelten Mitte ist 3 m × 10 cm = 0,3 m². Mit der Splittergrenze der
Flächen (0,5 m²) fiel am echten Netz jeder einzelne der 4.363 Striche weg."""
zelle = chunk_box_of(0, 0)
x, y = zelle.centroid.x, zelle.centroid.y
stuecke = chunk_markings([(box(x, y, x + 3.0, y + 0.1), "gelb_gestrichelt")], 0, 0)
assert [art for _, art in stuecke] == ["gelb_gestrichelt"]
assert abs(stuecke[0][0].area - 0.3) < 1e-6
def test_strich_ueber_der_chunkgrenze_wird_geteilt_nicht_verworfen():
zelle = chunk_box_of(0, 0)
grenze, y = zelle.bounds[2], zelle.centroid.y # rechte Kante von Chunk (0, 0)
strich = [(box(grenze - 1.0, y, grenze + 2.0, y + 0.1), "weiss_gestrichelt")]
assert [round(poly.area, 6) for poly, _ in chunk_markings(strich, 0, 0)] == [0.1]
assert [round(poly.area, 6) for poly, _ in chunk_markings(strich, 1, 0)] == [0.2]