feat(extract): Chunk-Zuordnung in Python und Beschriftungsliste lesen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8817e2849
commit
c9e0615a88
4 files changed
+299
-1
No files matched your search
@@ -0,0 +1,195 @@
|
||||
"""Kartenbeschriftungen der Vorlage entfernen, bevor die Pipeline Geometrie daraus macht.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md
|
||||
Koordinaten in annotations.toml sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt.
|
||||
|
||||
Wie in der Feinkalibrierung: ein Formfehler in der Datei bricht mit AnnotationError ab, damit kein
|
||||
Eintrag still verschwindet; ein formal richtiger Eintrag, der inhaltlich nichts trifft, wird
|
||||
gemeldet und übersprungen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from .coords import SLICE, chunk_for_world, chunk_name
|
||||
|
||||
KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region")
|
||||
MIN_BOX_SIDE = 2.0 # m
|
||||
|
||||
|
||||
class AnnotationError(ValueError):
|
||||
"""Formfehler in annotations.toml: der Lauf bricht ab."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Annotation:
|
||||
index: int # 1-basiert, wie in allen Meldungen
|
||||
name: str
|
||||
kind: str
|
||||
box: tuple[float, float, float, float] | None # x_min, y_min, x_max, y_max
|
||||
polygon: tuple[tuple[float, float], ...] | None
|
||||
reason: str = ""
|
||||
|
||||
@property
|
||||
def outline(self) -> Polygon:
|
||||
return Polygon(self.polygon) if self.polygon else box(*self.box)
|
||||
|
||||
@property
|
||||
def chunk(self) -> str:
|
||||
centre = self.outline.centroid
|
||||
return chunk_name(*chunk_for_world(centre.x, centre.y)) or "?"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Keep:
|
||||
index: int
|
||||
box: tuple[float, float, float, float]
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Grid:
|
||||
spacing: float
|
||||
width: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnnotationList:
|
||||
annotations: list[Annotation] = field(default_factory=list)
|
||||
keep: list[Keep] = field(default_factory=list)
|
||||
grid: Grid | None = None
|
||||
|
||||
|
||||
# --- Liste lesen und prüfen -------------------------------------------------------------------
|
||||
|
||||
def load_annotations(path: Path) -> AnnotationList:
|
||||
"""Liste aus der Datei; fehlt sie, gibt es keine Einträge und kein Gitternetz."""
|
||||
if not path.exists():
|
||||
return AnnotationList()
|
||||
return parse_annotations(path.read_text())
|
||||
|
||||
|
||||
def parse_annotations(text: str) -> AnnotationList:
|
||||
try:
|
||||
data = tomllib.loads(text)
|
||||
except tomllib.TOMLDecodeError as err:
|
||||
raise AnnotationError(f"annotations.toml ist kein gültiges TOML: {err}") from err
|
||||
unknown = sorted(set(data) - {"grid", "annotation", "keep"})
|
||||
if unknown:
|
||||
raise AnnotationError(f"annotations.toml: unbekannter Abschnitt {unknown[0]!r}, "
|
||||
"erwartet [grid], [[annotation]] oder [[keep]]")
|
||||
for section in ("annotation", "keep"):
|
||||
if not isinstance(data.get(section, []), list):
|
||||
raise AnnotationError(f"annotations.toml: {section} muss als [[{section}]] geschrieben sein")
|
||||
return AnnotationList(
|
||||
[_parse_annotation(i, entry) for i, entry in enumerate(data.get("annotation", []), start=1)],
|
||||
[_parse_keep(i, entry) for i, entry in enumerate(data.get("keep", []), start=1)],
|
||||
_parse_grid(data.get("grid")),
|
||||
)
|
||||
|
||||
|
||||
def _number(value) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _point(value) -> bool:
|
||||
return isinstance(value, list) and len(value) == 2 and all(_number(c) for c in value)
|
||||
|
||||
|
||||
def _inside_slice(x_min, y_min, x_max, y_max) -> bool:
|
||||
return x_min >= SLICE["x0"] and x_max <= SLICE["x1"] and y_min >= SLICE["y0"] and y_max <= SLICE["y1"]
|
||||
|
||||
|
||||
def _parse_box(value, fail):
|
||||
if not (isinstance(value, list) and len(value) == 2 and all(_point(p) for p in value)):
|
||||
fail(f"Feld 'box' muss zwei Ecken [[x, y], [x, y]] enthalten, ist {value!r}")
|
||||
(xa, ya), (xb, yb) = value
|
||||
x_min, x_max = sorted((float(xa), float(xb)))
|
||||
y_min, y_max = sorted((float(ya), float(yb)))
|
||||
if x_max - x_min < MIN_BOX_SIDE or y_max - y_min < MIN_BOX_SIDE:
|
||||
fail(f"Feld 'box' muss auf beiden Achsen mindestens {MIN_BOX_SIDE:g} m messen, ist {value!r}")
|
||||
if not _inside_slice(x_min, y_min, x_max, y_max):
|
||||
fail(f"Feld 'box' liegt außerhalb des Ausschnitts, ist {value!r}")
|
||||
return x_min, y_min, x_max, y_max
|
||||
|
||||
|
||||
def _parse_polygon(value, fail):
|
||||
if not (isinstance(value, list) and len(value) >= 3 and all(_point(p) for p in value)):
|
||||
fail(f"Feld 'polygon' muss mindestens 3 Punkte [x, y] enthalten, ist {value!r}")
|
||||
shape = Polygon(value)
|
||||
if not shape.is_valid or shape.area <= 0:
|
||||
fail(f"Feld 'polygon' muss ein Vieleck ohne sich schneidende Kanten sein, ist {value!r}")
|
||||
if not _inside_slice(*shape.bounds):
|
||||
fail(f"Feld 'polygon' liegt außerhalb des Ausschnitts, ist {value!r}")
|
||||
return tuple((float(x), float(y)) for x, y in value)
|
||||
|
||||
|
||||
def _parse_annotation(index: int, entry) -> Annotation:
|
||||
def fail(message: str):
|
||||
raise AnnotationError(f"Beschriftung {index}: {message}")
|
||||
|
||||
if not isinstance(entry, dict):
|
||||
fail("ist keine Tabelle")
|
||||
for key in entry:
|
||||
if key not in ("name", "kind", "box", "polygon", "reason"):
|
||||
fail(f"Feld {key!r} gibt es nicht")
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
fail("Feld 'name' fehlt oder ist leer")
|
||||
kind = entry.get("kind")
|
||||
if kind not in KINDS:
|
||||
fail(f"unbekanntes kind {kind!r}, erlaubt sind {', '.join(KINDS)}")
|
||||
reason = entry.get("reason", "")
|
||||
if not isinstance(reason, str):
|
||||
fail("reason muss Text sein")
|
||||
if kind == "region":
|
||||
if ("box" in entry) == ("polygon" in entry):
|
||||
fail("region braucht genau eines der Felder 'box' und 'polygon'")
|
||||
elif "polygon" in entry:
|
||||
fail(f"Feld 'polygon' gibt es nur bei region, nicht bei {kind}")
|
||||
elif "box" not in entry:
|
||||
fail("Feld 'box' fehlt")
|
||||
area = _parse_box(entry["box"], fail) if "box" in entry else None
|
||||
outline = _parse_polygon(entry["polygon"], fail) if "polygon" in entry else None
|
||||
return Annotation(index, name, kind, area, outline, reason)
|
||||
|
||||
|
||||
def _parse_keep(index: int, entry) -> Keep:
|
||||
def fail(message: str):
|
||||
raise AnnotationError(f"Ausnahme {index}: {message}")
|
||||
|
||||
if not isinstance(entry, dict):
|
||||
fail("ist keine Tabelle")
|
||||
for key in entry:
|
||||
if key not in ("box", "reason"):
|
||||
fail(f"Feld {key!r} gibt es nicht")
|
||||
if "box" not in entry:
|
||||
fail("Feld 'box' fehlt")
|
||||
reason = entry.get("reason")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
fail("Feld 'reason' fehlt oder ist leer")
|
||||
return Keep(index, _parse_box(entry["box"], fail), reason)
|
||||
|
||||
|
||||
def _parse_grid(value) -> Grid | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
def fail(message: str):
|
||||
raise AnnotationError(f"Gitternetz: {message}")
|
||||
|
||||
if not isinstance(value, dict):
|
||||
fail("muss als [grid] geschrieben sein")
|
||||
for key in value:
|
||||
if key not in ("spacing", "width"):
|
||||
fail(f"Feld {key!r} gibt es nicht")
|
||||
for key in ("spacing", "width"):
|
||||
if key not in value:
|
||||
fail(f"Feld {key!r} fehlt")
|
||||
if not (_number(value[key]) and value[key] > 0):
|
||||
fail(f"Feld {key!r} muss eine Zahl größer als 0 sein, ist {value[key]!r}")
|
||||
return Grid(float(value["spacing"]), float(value["width"]))
|
||||
@@ -53,3 +53,8 @@ def parse_chunk_name(name: str) -> tuple[int, int] | None:
|
||||
if not match or int(match.group(2)) > CHUNKS_PER_SIDE:
|
||||
return None
|
||||
return CHUNK_COLUMNS.index(match.group(1).upper()), int(match.group(2)) - 1
|
||||
|
||||
|
||||
def chunk_for_world(x: float, y: float) -> tuple[int, int]:
|
||||
"""Spiegel von chunkForWorld in src/world/coords.js: i nach Osten, j von der Nordkante nach Süden."""
|
||||
return int((x - SLICE["x0"]) // CHUNK_SIZE), int((SLICE["y1"] - y) // CHUNK_SIZE)
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
|
||||
from leonida.annotations import KINDS, AnnotationError, load_annotations, parse_annotations
|
||||
|
||||
|
||||
def test_missing_file_and_comment_only_file_mean_no_entries_and_no_grid(tmp_path):
|
||||
assert load_annotations(tmp_path / "fehlt.toml").annotations == []
|
||||
(tmp_path / "leer.toml").write_text("# nur Kommentare\n")
|
||||
parsed = load_annotations(tmp_path / "leer.toml")
|
||||
assert parsed.annotations == [] and parsed.keep == [] and parsed.grid is None
|
||||
|
||||
|
||||
def test_all_spec_kinds_are_known():
|
||||
assert set(KINDS) == {"text_white", "text_red", "marker", "symbol", "street_name", "region"}
|
||||
|
||||
|
||||
def test_entries_parse_with_normalised_boxes_and_chunk_names():
|
||||
parsed = parse_annotations("""
|
||||
[grid]
|
||||
spacing = 500
|
||||
width = 6
|
||||
|
||||
[[annotation]]
|
||||
name = "VICE BEACH (MIAMI BEACH)"
|
||||
kind = "text_white"
|
||||
box = [[1900, 705], [1350, 870]]
|
||||
|
||||
[[annotation]]
|
||||
name = "Rest am Marker S2/32"
|
||||
kind = "region"
|
||||
polygon = [[1905, 640], [1925, 640], [1925, 615], [1905, 615]]
|
||||
reason = "vom Nutzer markiert"
|
||||
|
||||
[[keep]]
|
||||
box = [[-856, 194], [-794, -1]]
|
||||
reason = "knallrotes Gebäude, echt"
|
||||
""")
|
||||
first, second = parsed.annotations
|
||||
assert (first.index, first.kind, first.box) == (1, "text_white", (1350.0, 705.0, 1900.0, 870.0))
|
||||
assert first.chunk == "K7"
|
||||
assert second.polygon[0] == (1905.0, 640.0) and second.box is None and second.reason == "vom Nutzer markiert"
|
||||
assert second.chunk == "L8"
|
||||
assert parsed.keep[0].box == (-856.0, -1.0, -794.0, 194.0)
|
||||
assert (parsed.grid.spacing, parsed.grid.width) == (500.0, 6.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body, message", [
|
||||
('name = "X"\nkind = "banner"\nbox = [[0, 0], [10, 10]]', "unbekanntes kind 'banner'"),
|
||||
('kind = "marker"\nbox = [[0, 0], [10, 10]]', "Feld 'name' fehlt oder ist leer"),
|
||||
('name = "X"\nkind = "marker"\nbox = [[0, 0], [10, 10]]\nhight = 3', "Feld 'hight' gibt es nicht"),
|
||||
('name = "X"\nkind = "marker"', "Feld 'box' fehlt"),
|
||||
('name = "X"\nkind = "marker"\nbox = [1, 2]', "Feld 'box' muss zwei Ecken"),
|
||||
('name = "X"\nkind = "marker"\nbox = [[0, 0], [1, 5]]', "mindestens 2 m"),
|
||||
('name = "X"\nkind = "marker"\nbox = [[1900, 0], [2100, 50]]', "außerhalb des Ausschnitts"),
|
||||
('name = "X"\nkind = "marker"\npolygon = [[0, 0], [10, 0], [10, 10]]', "Feld 'polygon' gibt es nur bei region"),
|
||||
('name = "X"\nkind = "region"\nbox = [[0, 0], [10, 10]]\npolygon = [[0, 0], [10, 0], [10, 10]]', "genau eines der Felder"),
|
||||
('name = "X"\nkind = "region"\npolygon = [[0, 0], [10, 10], [10, 0], [0, 10]]', "ohne sich schneidende Kanten"),
|
||||
('name = "X"\nkind = "region"\npolygon = [[0, 0], [10, 10]]', "mindestens 3 Punkte"),
|
||||
('name = "X"\nkind = "marker"\nbox = [[0, 0], [10, 10]]\nreason = 7', "reason muss Text sein"),
|
||||
])
|
||||
def test_form_errors_stop_with_entry_number_and_field(body, message):
|
||||
text = ('[[annotation]]\nname = "OK"\nkind = "marker"\nbox = [[0, 0], [10, 10]]\n\n'
|
||||
f'[[annotation]]\n{body}\n')
|
||||
with pytest.raises(AnnotationError, match="Beschriftung 2") as err:
|
||||
parse_annotations(text)
|
||||
assert message in str(err.value)
|
||||
|
||||
|
||||
def test_keep_needs_a_reason():
|
||||
with pytest.raises(AnnotationError, match="Ausnahme 1: Feld 'reason' fehlt oder ist leer"):
|
||||
parse_annotations("[[keep]]\nbox = [[0, 0], [10, 10]]\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body, message", [
|
||||
("spacing = 0\nwidth = 6", "Gitternetz: Feld 'spacing' muss eine Zahl größer als 0 sein"),
|
||||
("spacing = 500", "Gitternetz: Feld 'width' fehlt"),
|
||||
("spacing = 500\nwidth = 6\ncolour = 1", "Gitternetz: Feld 'colour' gibt es nicht"),
|
||||
])
|
||||
def test_grid_form_errors(body, message):
|
||||
with pytest.raises(AnnotationError, match=message):
|
||||
parse_annotations(f"[grid]\n{body}\n")
|
||||
|
||||
|
||||
def test_broken_toml_and_unknown_sections_are_form_errors():
|
||||
with pytest.raises(AnnotationError, match="kein gültiges TOML"):
|
||||
parse_annotations("[[annotation]\n")
|
||||
with pytest.raises(AnnotationError, match="unbekannter Abschnitt 'label'"):
|
||||
parse_annotations('[[label]]\nname = "X"\n')
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from leonida.coords import SLICE, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world
|
||||
from leonida.coords import SLICE, chunk_for_world, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world
|
||||
|
||||
# Derselbe Beispielsatz wie tests/coords.test.js — JavaScript und Python dürfen nicht auseinanderlaufen.
|
||||
NAMES = json.loads((Path(__file__).resolve().parents[3] / "tests/fixtures/chunk-names.json").read_text())
|
||||
@@ -48,3 +48,13 @@ def test_chunk_names_round_trip_for_all_144_chunks():
|
||||
def test_chunk_names_reject_invalid_index_types():
|
||||
for i, j in NAMES["invalidIndices"]:
|
||||
assert chunk_name(i, j) is None
|
||||
|
||||
|
||||
def test_chunk_for_world_matches_javascript():
|
||||
# Dieselben Beispiele wie tests/coords.test.js: Ecken des Ausschnitts, der Downtown-Bezugspunkt aus
|
||||
# data/districts.json in B9 und der höchste Turm, das Southeast Financial Center, in C12.
|
||||
assert chunk_for_world(-1000, 2500) == (0, 0)
|
||||
assert chunk_for_world(1999, -499) == (11, 11)
|
||||
assert chunk_for_world(-511, 254) == (1, 8)
|
||||
assert chunk_name(*chunk_for_world(-511, 254)) == "B9"
|
||||
assert chunk_name(*chunk_for_world(-461.31, -460.1)) == "C12"
|
||||
Reference in new issue
Block a user