"""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 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 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: cache = Path("cache") download_tiles(cache) mosaic = load_mosaic(cache) labels, unknown_share = classify(mosaic, report_unknown=True) labels = drop_map_labels(labels) removal = remove_annotations(mosaic, labels, load_annotations(Path("data/annotations.toml"))) labels = removal.labels labels = drop_map_labels(labels) # Inselreste, die erst das Entfernen im Wasser hinterlässt print(removal.report()) if unknown_share > 0.05: raise SystemExit(f"{unknown_share:.1%} der Pixel unbekannt. Palette kalibrieren, nicht die Grenze anheben.") shares = class_shares(labels) for name, (lo, hi) in TOLERANCE.items(): if not lo <= shares[name] <= hi: raise SystemExit(f"Klassenanteil {name}={shares[name]:.3f} außerhalb {lo}..{hi}. Palette prüfen.") land = land_polygons(labels) roads = road_polygons(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) surfaces = [(p, "sand") for p in sand] + [(p, "park") for p in parks] + [(p, "urban") for p in urban] road_shapes = [p for p, _ in roads] raw = building_polygons(labels) # Nur "spekulativ" (rot) ist mit road_polygons() ueberhaupt doppelt vergeben: die # ROAD_CLASSES in vectors.py zaehlen "spekulativ" selbst als Strassenklasse, und # road_mask() schliesst zusaetzlich mit disk(3), um Strassennamen zu ueberbruecken. # Wuerde man road_shapes (= road_polygons(labels), inklusive dieser Schliessung und # inklusive "spekulativ") direkt gegen ALLE rohen Gebaeude pruefen, vergleicht ein # isoliertes "spekulativ"-Gebaeude sich praktisch mit sich selbst (dieselben Pixel # bilden dort auch das Strassenpolygon) und faellt so gut wie immer heraus: am realen # Mosaik blieben nur 3 von 267 "spekulativ"-Gebaeuden uebrig statt der erwarteten # Groessenordnung (Task-6-Referenzwerte: 258 von 267), und die Schliessung riss # zusaetzlich 211 gewoehnliche (graue) Gebaeude mit, die die Notiz in Task 5 gar nicht # meinte ("die ROTEN Flaechen ... werden abgezogen"). Deshalb hier ein eigenes, # unzweideutiges Strassenmass nur aus "strasse"/"weg" (ohne "spekulativ", ohne die # Namens-Schliessung) und die Pruefung nur auf spekulative Kandidaten begrenzt; graue # Gebaeude durchlaufen ungeprueft. Ergebnis am realen Mosaik: 2.348 Gebaeude, davon 263 # spekulativ (Referenz: 2.352 bzw. 258) — Wahrzeichenhoehen (68, 31-248 m) und # Palmenzahl (2.133) treffen die Referenz exakt. unambiguous_mask = (labels == CLASSES["strasse"]) | (labels == CLASSES["weg"]) unambiguous_roads = mask_polygons(unambiguous_mask, min_area=20, simplify=0.8) buildings = [(p, s) for p, s in raw if not s or not any(p.intersection(r).area > 0.5 * p.area for r in unambiguous_roads)] landmarks = json.loads(Path("data/skyline.json").read_text())["landmarks"] districts = json.loads(Path("data/districts.json").read_text()) 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") 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, 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)], 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)], )) write_debug_png(Path("debug"), mosaic, payloads) write_chunks(OUT, payloads, { "slice": "vice-a", "source_commit": COMMIT, "seed": SEED, "chunk_size": 250, "chunks": [p["chunk"] for p in payloads], "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). " "Bordstein und Gehweg: Just8 (Sketchfab, CC BY 4.0).", }) print(render_map(OUT, OUT), "->", OUT) if __name__ == "__main__": main()