feat(extract): Parkanlage aus OpenStreetMap mit Wegen, Flächen und Gebäuden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-16 07:03:52 +02:00
1 parent ad5d0468db
commit 26aafbbbd5
2 files changed
+306 -2

No files matched your search

+167 -2
View File
@@ -7,12 +7,14 @@ Ein Formfehler in der Parkdatei bricht mit ParkError ab, wie bei annotations.tom
""" """
from __future__ import annotations from __future__ import annotations
import json
import math import math
import tomllib import tomllib
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from shapely.geometry import Polygon from shapely.geometry import LineString, Polygon
from shapely.ops import unary_union
class ParkError(ValueError): class ParkError(ValueError):
@@ -250,3 +252,166 @@ class ParkFit:
"""Das echte Parkviereck in lokalen Metern.""" """Das echte Parkviereck in lokalen Metern."""
(ox, oy), (ux, uy), (vx, vy) = self.origin, self.real_u, self.real_v (ox, oy), (ux, uy), (vx, vy) = self.origin, self.real_u, self.real_v
return Polygon([(ox, oy), (ox + ux, oy + uy), (ox + ux + vx, oy + uy + vy), (ox + vx, oy + vy)]) return Polygon([(ox, oy), (ox + ux, oy + uy), (ox + ux + vx, oy + uy + vy), (ox + vx, oy + vy)])
# --- Anlage aus OpenStreetMap ------------------------------------------------------------------
FIELD_SPORTS = {"baseball", "soccer"}
COURT_SPORTS = {"tennis", "basketball", "racquet"}
PATH_HIGHWAYS = {"footway", "path", "pedestrian", "steps", "cycleway"}
MAIN_PATHS = {"Flamingo Park Path (13th St)", "Flamingo Park Path (Jefferson Ave)"}
SURFACE_ORDER = ("field", "court", "pool", "parking", "sand")
EDGE_REACH = 5.0 # m: so nah am echten West- oder Ostrand endende Wege laufen bis zur Straßenkante
EXTEND_MAX = 90.0 # m: der Rasenstreifen im Osten ist 37 m breit, schräg ankommende Wege brauchen bis zu doppelt so viel
@dataclass
class ParkLayout:
lawn: Polygon # das ganze Parkpolygon im Spiel
surfaces: list[tuple[Polygon, str]] # Felder, Plätze, Pool, Parkplatz, Spielplatz, ohne Rasen
paths: list[Polygon]
service: list[Polygon]
buildings: list[tuple[Polygon, float]]
keepouts: list[Polygon]
warnings: list[str] = field(default_factory=list)
def load_elements(path: Path) -> list[dict]:
return json.loads(path.read_text(encoding="utf-8"))["elements"]
def keepout_polygon(keepout: Keepout) -> Polygon:
"""Außenmaße eines Orts in Weltkoordinaten, gedreht wie modelToWorld in tests/placesSite.test.js."""
r = math.radians(keepout.rotation)
cos, sin = math.cos(r), math.sin(r)
min_x, max_x, min_z, max_z = keepout.bounds
ax, ay = keepout.at
corners = [(min_x, min_z), (max_x, min_z), (max_x, max_z), (min_x, max_z)]
return Polygon([(ax + x * cos + z * sin, ay + x * sin - z * cos) for x, z in corners])
def building_height(tags: dict, default: float) -> float | None:
"""Höhe aus OSM, sonst default; None für Dächer ohne Wände (height = 0)."""
raw = tags.get("height")
if raw is None:
return default
try:
height = float(str(raw).split()[0])
except (ValueError, IndexError):
return default
return None if height <= 0 else height
def build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout:
real_quad = fit.real_quad()
keepouts = [keepout_polygon(k) for k in config.keepouts]
blocked = unary_union(keepouts)
surfaces = {kind: [] for kind in SURFACE_ORDER}
footways, service, buildings = [], [], []
for element in elements:
tags, coords = element["tags"], element["geometry"]
highway = tags.get("highway")
if highway in PATH_HIGHWAYS or highway == "service":
if len(coords) < 2:
continue
width = (config.service_width if highway == "service"
else config.main_width if tags.get("name") in MAIN_PATHS else config.path_width)
real_line = LineString([local_metres(lat, lon) for lat, lon in coords])
for piece in _pieces(real_line.intersection(real_quad)):
if piece.geom_type != "LineString" or piece.length == 0:
continue
game = _extend(fit, piece, [fit.local_to_game(x, y) for x, y in piece.coords])
(service if highway == "service" else footways).append(LineString(game).buffer(width / 2))
continue
if len(coords) < 4:
continue
real_poly = Polygon([local_metres(lat, lon) for lat, lon in coords])
if not real_poly.is_valid or not real_quad.contains(real_poly.centroid):
continue
game_poly = Polygon([fit.local_to_game(x, y) for x, y in real_poly.exterior.coords])
if "building" in tags:
height = building_height(tags, config.default_height)
if height is not None and not game_poly.intersects(blocked):
buildings.append((game_poly, height))
continue
kind = _surface_kind(tags)
if kind is not None:
surfaces[kind].extend(_polygons(game_poly.difference(blocked)))
warnings, paths = [], []
if footways:
union = unary_union(footways)
warnings = [f"Parkweg schneidet die Sperrfläche {k.name}"
for k, polygon in zip(config.keepouts, keepouts) if union.intersects(polygon)]
paths = _polygons(union.difference(blocked))
return ParkLayout(
lawn=fit.game_quad,
surfaces=[(poly, kind) for kind in SURFACE_ORDER for poly in surfaces[kind]],
paths=paths,
service=_polygons(unary_union(service)) if service else [],
buildings=buildings,
keepouts=keepouts,
warnings=warnings,
)
def _surface_kind(tags: dict) -> str | None:
sports = set(tags.get("sport", "").split(";"))
leisure = tags.get("leisure")
if leisure == "pitch":
return "field" if sports & FIELD_SPORTS else "court" if sports & COURT_SPORTS else None
if leisure == "sports_centre":
return "court"
if leisure == "swimming_pool":
return "pool"
if leisure == "playground":
return "sand"
if tags.get("amenity") == "parking":
return "parking"
return None
def _extend(fit: ParkFit, real_line: LineString, coords: list) -> list:
"""Enden höchstens EDGE_REACH vor dem echten West- oder Ostrand laufen nach außen bis zur Straßenkante."""
coords = list(coords)
ux, uy = fit.game_u[0] / fit.game_width, fit.game_u[1] / fit.game_width
for end, inner in ((0, 1), (-1, -2)):
if len(coords) < 2:
return coords
s, _ = fit.park_coords(*real_line.coords[end])
west, east = s * fit.real_width, (1 - s) * fit.real_width
side = -1 if west <= EDGE_REACH else 1 if east <= EDGE_REACH else 0
if side == 0:
continue
(x0, y0), (x1, y1) = coords[inner], coords[end]
length = math.hypot(x1 - x0, y1 - y0)
if length == 0:
continue
dx, dy = (x1 - x0) / length, (y1 - y0) / length
if (dx * ux + dy * uy) * side < 0.5: # läuft längs der Kante statt auf sie zu
continue
ray = LineString([(x1, y1), (x1 + dx * EXTEND_MAX, y1 + dy * EXTEND_MAX)])
hits = _points(ray.intersection(fit.game_quad.exterior))
if hits:
coords[end] = min(hits, key=lambda p: math.hypot(p[0] - x1, p[1] - y1))
return coords
def _pieces(geometry) -> list:
if geometry.is_empty:
return []
return list(getattr(geometry, "geoms", [geometry]))
def _polygons(geometry, min_area: float = 1.0) -> list[Polygon]:
return [g for g in _pieces(geometry) if g.geom_type == "Polygon" and g.area >= min_area]
def _points(geometry) -> list[tuple[float, float]]:
out = []
for g in _pieces(geometry):
if g.geom_type == "Point":
out.append((g.x, g.y))
elif g.geom_type in ("LineString", "LinearRing"):
out.extend(g.coords)
return out
+139
View File
@@ -0,0 +1,139 @@
from types import SimpleNamespace
import pytest
from shapely.geometry import Point
from leonida.park import (M_PER_DEG_LAT, M_PER_DEG_LON, REF_LAT, REF_LON, Keepout, ParkFit, build_layout,
building_height, keepout_polygon)
def latlon(x, y):
return REF_LAT + y / M_PER_DEG_LAT, REF_LON + x / M_PER_DEG_LON
def config(keepouts=()):
"""Echter Park 100 × 200 m, Spielpark 200 × 400 m ab (1000 | 1000), Anlage 50 m vom Westrand:
Welt-x = 1050 + x, Welt-y = 1000 + 2 · y."""
return SimpleNamespace(
real={"sw": latlon(0, 0), "se": latlon(100, 0), "nw": latlon(0, 200)},
game={"sw": (1000.0, 1000.0), "se": (1200.0, 1000.0), "ne": (1200.0, 1400.0), "nw": (1000.0, 1400.0)},
scale_u=1.0, offset_u=50.0, keepouts=tuple(keepouts),
main_width=6.0, path_width=3.0, service_width=6.0, default_height=4.0,
)
def way(tags, *points, id=1):
return {"id": id, "tags": tags, "geometry": [list(latlon(x, y)) for x, y in points]}
def square(tags, x, y, size=10, id=1):
return way(tags, (x, y), (x + size, y), (x + size, y + size), (x, y + size), (x, y), id=id)
def layout(elements, keepouts=()):
cfg = config(keepouts)
return build_layout(elements, cfg, ParkFit(cfg))
def test_footway_is_three_metres_wide_and_a_named_main_path_six():
(plain,) = layout([way({"highway": "footway"}, (20, 100), (80, 100))]).paths
assert plain.contains(Point(1100, 1201.4)) and not plain.contains(Point(1100, 1201.6))
(main,) = layout([way({"highway": "footway", "name": "Flamingo Park Path (13th St)"}, (20, 100), (80, 100))]).paths
assert main.contains(Point(1100, 1202.9)) and not main.contains(Point(1100, 1203.1))
def test_crossing_paths_become_one_surface():
result = layout([way({"highway": "footway"}, (20, 100), (80, 100), id=1),
way({"highway": "path"}, (50, 60), (50, 140), id=2)])
assert len(result.paths) == 1
def test_path_is_cut_at_the_real_park_edge():
(path,) = layout([way({"highway": "footway"}, (50, 150), (50, 260))]).paths
assert path.bounds[3] <= 1400 + 1.5 + 1e-6
def test_path_ending_near_the_west_edge_runs_on_to_the_road():
(path,) = layout([way({"highway": "footway"}, (40, 100), (3, 100))]).paths
assert path.contains(Point(1001, 1200))
def test_path_along_the_edge_is_not_stretched():
(path,) = layout([way({"highway": "footway"}, (3, 50), (3, 150))]).paths
assert not path.contains(Point(1010, 1200))
assert path.bounds[1] == pytest.approx(1100 - 1.5)
def test_path_ending_far_from_the_edge_stays_as_it_is():
(path,) = layout([way({"highway": "footway"}, (40, 100), (20, 100))]).paths
assert path.bounds[0] == pytest.approx(1070 - 1.5)
def test_surfaces_are_sorted_by_kind_and_dog_parks_stay_lawn():
elements = [
square({"leisure": "playground"}, 10, 10, id=1),
square({"leisure": "pitch", "sport": "tennis"}, 30, 10, id=2),
square({"leisure": "pitch", "sport": "baseball"}, 50, 10, id=3),
square({"amenity": "parking"}, 70, 10, id=4),
square({"leisure": "swimming_pool"}, 10, 40, id=5),
square({"leisure": "sports_centre"}, 30, 40, id=6),
square({"leisure": "pitch", "sport": "soccer;american_football"}, 50, 40, id=7),
square({"leisure": "dog_park"}, 70, 40, id=8),
]
assert [kind for _, kind in layout(elements).surfaces] == ["field", "field", "court", "court", "pool", "parking", "sand"]
def test_features_centred_outside_the_real_park_are_left_out():
assert layout([square({"leisure": "pitch", "sport": "soccer"}, 40, 195, size=20)]).surfaces == []
def test_lawn_is_the_whole_game_park():
assert layout([]).lawn.area == pytest.approx(200 * 400)
def test_buildings_take_their_height_or_the_default_and_roofs_without_walls_are_dropped():
result = layout([
square({"building": "yes", "height": "8.5"}, 10, 10, id=1),
square({"building": "yes"}, 30, 10, id=2),
square({"building": "yes", "height": "0.0"}, 50, 10, id=3),
square({"building": "yes", "leisure": "sports_centre"}, 70, 10, id=4),
])
assert sorted(height for _, height in result.buildings) == [4.0, 4.0, 8.5]
assert result.surfaces == []
def test_building_height_reads_plain_numbers_and_units():
assert building_height({"height": "10.4"}, 4.0) == 10.4
assert building_height({"height": "12 m"}, 4.0) == 12.0
assert building_height({"height": "hoch"}, 4.0) == 4.0
assert building_height({}, 4.0) == 4.0
assert building_height({"height": "0"}, 4.0) is None
def test_keepout_polygon_turns_like_places_js():
plain = keepout_polygon(Keepout("ort", (100.0, 200.0), 0.0, (-10.0, 20.0, -5.0, 8.0)))
assert plain.bounds == pytest.approx((90.0, 192.0, 120.0, 205.0))
turned = keepout_polygon(Keepout("ort", (0.0, 0.0), 90.0, (0.0, 10.0, -1.0, 1.0)))
assert turned.bounds == pytest.approx((-1.0, 0.0, 1.0, 10.0)) # +x zeigt nach 90° nach Norden
def test_keepouts_cut_fields_and_paths_keep_buildings_out_and_warn():
keepout = Keepout("ort", (1100.0, 1100.0), 0.0, (-10.0, 10.0, -10.0, 10.0)) # Welt x 1090–1110, y 1090–1110
result = layout([
square({"leisure": "pitch", "sport": "baseball"}, 40, 40, size=20, id=1), # Welt x 1090–1110, y 1080–1120
way({"highway": "footway"}, (50, 30), (50, 70), id=2), # Welt x 1100, y 1060–1140
square({"building": "yes"}, 55, 48, size=4, id=3), # Welt x 1105–1109, y 1096–1104
], keepouts=[keepout])
hole = keepout_polygon(keepout)
assert all(poly.intersection(hole).area < 1e-6 for poly, _ in result.surfaces)
assert sum(poly.area for poly, _ in result.surfaces) == pytest.approx(20 * 40 - 20 * 20)
assert all(poly.intersection(hole).area < 1e-6 for poly in result.paths)
assert result.buildings == []
assert result.warnings == ["Parkweg schneidet die Sperrfläche ort"]
def test_service_ways_become_six_metre_service_surfaces():
result = layout([way({"highway": "service"}, (20, 20), (80, 20))])
(service,) = result.service
assert service.contains(Point(1100, 1042.9)) and not service.contains(Point(1100, 1043.1))
assert result.paths == []