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>
200 lines
11 KiB
Python
200 lines
11 KiB
Python
"""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()
|