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:
1 parent
ad5d0468db
commit
26aafbbbd5
2 files changed
+306
-2
No files matched your search
@@ -7,12 +7,14 @@ Ein Formfehler in der Parkdatei bricht mit ParkError ab, wie bei annotations.tom
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.geometry import LineString, Polygon
|
||||
from shapely.ops import unary_union
|
||||
|
||||
|
||||
class ParkError(ValueError):
|
||||
@@ -250,3 +252,166 @@ class ParkFit:
|
||||
"""Das echte Parkviereck in lokalen Metern."""
|
||||
(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)])
|
||||
|
||||
|
||||
# --- 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
|
||||
@@ -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 == []
|
||||
Reference in new issue
Block a user