diff --git a/tools/extract/leonida/emit.py b/tools/extract/leonida/emit.py index 2b26b9d..a1838af 100644 --- a/tools/extract/leonida/emit.py +++ b/tools/extract/leonida/emit.py @@ -130,16 +130,20 @@ def chunk_box_of(i: int, j: int) -> Polygon: return chunk_box(i, j) -def split_by_chunk_all(polys, i: int, j: int): - """Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen.""" +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. + + 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) for poly in polys: 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": yield part 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: diff --git a/tools/extract/run.py b/tools/extract/run.py index 41cbdb5..659f79f 100644 --- a/tools/extract/run.py +++ b/tools/extract/run.py @@ -1,22 +1,83 @@ """Erzeugt aus den Kacheln die Chunk-Dateien. Aufruf: uv run python run.py""" import json +from collections import Counter +from dataclasses import replace 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.buildings import building_polygons 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, 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.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.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 SEED = 20260912 OUT = Path(__file__).resolve().parents[2] / "public" / "data" / "vice-a" PARK = Path("data/flamingo-park.toml") 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: @@ -38,7 +99,12 @@ def main() -> None: land = land_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) parks = mask_polygons(labels == CLASSES["gruen"], 50, 1.0) 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) marked = mark_generated([p for p, _ in buildings], heights, removal.generated) 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, - land, SEED) + edge_lines = [LineString(e.points) for e in graph.edges] + 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) world, messages = apply_flamingo_park(world, PARK, SEED) for message in messages: 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 = [] for j in range(12): for i in range(12): payloads.append(chunk_payload( 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)], - roads=[(part, b) for (p, b) in world.roads 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))], - buildings=[(p, h, s) for (p, h, s) in world.buildings if p.intersects(chunk_box_of(i, j))], + surfaces=[(part, kind) for (p, kind) in world.surfaces + for part in split_by_chunk_all([p], i, j)], + carriageway=[(part, klass) for poly, klass in classified + 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)], 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, "attribution": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. " "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) diff --git a/tools/extract/tests/test_pipeline_roads.py b/tools/extract/tests/test_pipeline_roads.py new file mode 100644 index 0000000..2b84a6d --- /dev/null +++ b/tools/extract/tests/test_pipeline_roads.py @@ -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]