From c9e0615a88b2fe30fe2ef205afd454c16a39bada Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 18:27:33 +0200 Subject: [PATCH] feat(extract): Chunk-Zuordnung in Python und Beschriftungsliste lesen Co-Authored-By: Claude Opus 5 --- tools/extract/leonida/annotations.py | 195 +++++++++++++++++++ tools/extract/leonida/coords.py | 5 + tools/extract/tests/test_annotations_load.py | 88 +++++++++ tools/extract/tests/test_coords.py | 12 +- 4 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 tools/extract/leonida/annotations.py create mode 100644 tools/extract/tests/test_annotations_load.py diff --git a/tools/extract/leonida/annotations.py b/tools/extract/leonida/annotations.py new file mode 100644 index 0000000..5b4552f --- /dev/null +++ b/tools/extract/leonida/annotations.py @@ -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"])) diff --git a/tools/extract/leonida/coords.py b/tools/extract/leonida/coords.py index 4b94b26..44ab81f 100644 --- a/tools/extract/leonida/coords.py +++ b/tools/extract/leonida/coords.py @@ -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) diff --git a/tools/extract/tests/test_annotations_load.py b/tools/extract/tests/test_annotations_load.py new file mode 100644 index 0000000..f0db586 --- /dev/null +++ b/tools/extract/tests/test_annotations_load.py @@ -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') diff --git a/tools/extract/tests/test_coords.py b/tools/extract/tests/test_coords.py index 5455994..df9b4ef 100644 --- a/tools/extract/tests/test_coords.py +++ b/tools/extract/tests/test_coords.py @@ -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"