196 lines
7.2 KiB
Python
196 lines
7.2 KiB
Python
"""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"]))
|