Files
gta6/tools/extract/find_annotations.py
T

196 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Beschriftungen der Vorlage finden, auf Vollständigkeit prüfen und begutachten.
cd tools/extract && uv run python find_annotations.py --propose # Listenentwurf + Kontaktbogen
cd tools/extract && uv run python find_annotations.py --check # Status 1, wenn eine Fundstelle fehlt
cd tools/extract && uv run python find_annotations.py --review # Prüfbilder je Eintrag
Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md, Abschnitt „Werkzeug find_annotations.py“.
Die Suche nutzt die Farbregeln der Pipeline, für Marker zusätzlich einen Kontrast von mindestens
MARKER_CONTRAST: der hellblaue Küstenverlauf und die grünen Uferstreifen erfüllen die reine
Farbregel, sind aber weich verlaufen; Markerschrift hat harte Kanten.
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy import ndimage as ndi
from shapely.geometry import Point, box
from skimage.color import rgb2lab
from leonida.annotations import annotation_mask, candidates, load_annotations
from leonida.coords import SLICE, chunk_for_world, chunk_name
from leonida.tiles import load_mosaic
ANNOTATIONS = Path("data/annotations.toml")
MAP = Path("../../public/data/vice-a/map.png")
DEBUG = Path("debug/annotations")
SCAN_KINDS = ("text_white", "text_red", "marker")
SCAN_MIN_PX = {"text_white": 40, "text_red": 40, "marker": 25}
SCAN_GROW = 8 # Pixel: benachbarte Treffer einer Art gehören zu einer Fundstelle
SCAN_TILE = 500 # Pixel: Kachelgröße der Suche, damit der Speicher klein bleibt
MARKER_CONTRAST = 12.0 # Spannweite der CIELAB-Helligkeit im 3x3-Umfeld
PROPOSE_PAD = 10 # m Luft um vorgeschlagene Rahmen
REVIEW_PAD = 20 # m Rand um die Prüfbilder
REVIEW_SCALE = 2
@dataclass(frozen=True)
class Spot:
kind: str
rows: tuple[int, int] # halboffen, Pixel der Vorlage
cols: tuple[int, int]
@property
def centre(self) -> tuple[float, float]:
"""Weltkoordinate der Rahmenmitte; Pixelmitte (r, c) = Welt (x0 + c, y1 − r)."""
return (SLICE["x0"] + (self.cols[0] + self.cols[1] - 1) / 2,
SLICE["y1"] - (self.rows[0] + self.rows[1] - 1) / 2)
@property
def chunk(self) -> str:
return chunk_name(*chunk_for_world(*self.centre)) or "?"
def scan_mask(rgb: np.ndarray, kind: str) -> np.ndarray:
"""Suchpixel einer Art auf der ganzen Vorlage, kachelweise."""
h, w = rgb.shape[:2]
out = np.zeros((h, w), dtype=bool)
for r in range(0, h, SCAN_TILE):
for c in range(0, w, SCAN_TILE):
tile = rgb[r:r + SCAN_TILE, c:c + SCAN_TILE]
hit = candidates(tile, kind)
if kind == "marker":
light = rgb2lab(tile.astype(np.float32) / 255.0)[..., 0]
hit &= (ndi.maximum_filter(light, 3) - ndi.minimum_filter(light, 3)) >= MARKER_CONTRAST
out[r:r + SCAN_TILE, c:c + SCAN_TILE] = hit
return out
def scan(rgb: np.ndarray) -> list[Spot]:
spots = []
for kind in SCAN_KINDS:
hit = scan_mask(rgb, kind)
groups, _ = ndi.label(ndi.binary_dilation(hit, iterations=SCAN_GROW))
for index, sl in enumerate(ndi.find_objects(groups), start=1):
inside = hit[sl] & (groups[sl] == index)
if inside.sum() < SCAN_MIN_PX[kind]:
continue
rows, cols = np.nonzero(inside)
spots.append(Spot(kind, (sl[0].start + int(rows.min()), sl[0].start + int(rows.max()) + 1),
(sl[1].start + int(cols.min()), sl[1].start + int(cols.max()) + 1)))
return sorted(spots, key=lambda s: (s.rows[0] // 250, s.cols[0], s.kind))
def uncovered(spots: list[Spot], annotations) -> list[Spot]:
"""Fundstellen, deren Mitte in keinem annotation- und keinem keep-Rahmen liegt."""
areas = [a.outline for a in annotations.annotations] + [box(*k.box) for k in annotations.keep]
return [s for s in spots if not any(area.covers(Point(*s.centre)) for area in areas)]
def propose_toml(spots: list[Spot]) -> str:
lines = ["# Vorschlag aus find_annotations.py --propose. Namen und Arten von Hand prüfen,",
"# dann nach data/annotations.toml übernehmen.", ""]
for n, s in enumerate(spots, start=1):
x0 = max(SLICE["x0"], SLICE["x0"] + s.cols[0] - PROPOSE_PAD)
x1 = min(SLICE["x1"], SLICE["x0"] + s.cols[1] - 1 + PROPOSE_PAD)
y0 = max(SLICE["y0"], SLICE["y1"] - (s.rows[1] - 1) - PROPOSE_PAD)
y1 = min(SLICE["y1"], SLICE["y1"] - s.rows[0] + PROPOSE_PAD)
lines += ["[[annotation]]", f'name = "#{n} {s.chunk}"', f'kind = "{s.kind}"',
f"box = [[{x0}, {y1}], [{x1}, {y0}]]", ""]
return "\n".join(lines)
def contact_sheet(rgb: np.ndarray, spots: list[Spot], labels: list[str]) -> Image.Image:
tiles = []
font = ImageFont.load_default()
for spot, label in zip(spots, labels):
r0, r1 = max(0, spot.rows[0] - 14), min(rgb.shape[0], spot.rows[1] + 14)
c0, c1 = max(0, spot.cols[0] - 14), min(rgb.shape[1], spot.cols[1] + 14)
image = Image.fromarray(rgb[r0:r1, c0:c1])
scale = max(0.5, min(3.0, 360 / image.width, 200 / image.height))
image = image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale))))
tile = Image.new("RGB", (380, 240), (30, 30, 30))
tile.paste(image, ((380 - image.width) // 2, 34 + max(0, (206 - image.height) // 2)))
ImageDraw.Draw(tile).text((6, 8), label, fill=(255, 220, 0), font=font)
tiles.append(tile)
columns = 5
rows = max(1, (len(tiles) + columns - 1) // columns)
sheet = Image.new("RGB", (columns * 384, rows * 244))
for k, tile in enumerate(tiles):
sheet.paste(tile, ((k % columns) * 384, (k // columns) * 244))
return sheet
def review(rgb: np.ndarray, annotations, picture: Image.Image, out_dir: Path) -> list[Path]:
"""Je Eintrag links die Vorlage mit Maskenumriss, rechts das Ergebnis aus map.png."""
out_dir.mkdir(parents=True, exist_ok=True)
written, thumbs = [], []
for a in annotations.annotations:
found = annotation_mask(rgb, a)
r0, r1 = max(0, found.rows.start - REVIEW_PAD), min(rgb.shape[0], found.rows.stop + REVIEW_PAD)
c0, c1 = max(0, found.cols.start - REVIEW_PAD), min(rgb.shape[1], found.cols.stop + REVIEW_PAD)
mask = np.zeros((r1 - r0, c1 - c0), dtype=bool)
mask[found.rows.start - r0:found.rows.stop - r0, found.cols.start - c0:found.cols.stop - c0] = found.mask
left = rgb[r0:r1, c0:c1].copy()
left[mask & ~ndi.binary_erosion(mask)] = (255, 0, 255)
size = ((c1 - c0) * REVIEW_SCALE, (r1 - r0) * REVIEW_SCALE)
left_image = Image.fromarray(left).resize(size, Image.NEAREST)
right_image = picture.crop((c0, r0, c1, r1)).resize(size, Image.NEAREST)
page = Image.new("RGB", (size[0] * 2 + 10, size[1] + 24), (20, 20, 20))
page.paste(left_image, (0, 24))
page.paste(right_image, (size[0] + 10, 24))
status = "" if found.applied else " — übersprungen"
ImageDraw.Draw(page).text((6, 6), f"#{a.index} {a.name} · {a.chunk} · {a.kind}{status}", fill=(255, 220, 0))
slug = "".join(ch if ch.isalnum() else "-" for ch in a.name)[:40]
path = out_dir / f"{a.index:03d}-{slug}.png"
page.save(path)
written.append(path)
thumbs.append(page)
if thumbs:
width = 900
scaled = [t.resize((width, max(1, int(t.height * width / t.width)))) for t in thumbs]
sheet = Image.new("RGB", (width, sum(t.height + 6 for t in scaled)), (0, 0, 0))
y = 0
for t in scaled:
sheet.paste(t, (0, y))
y += t.height + 6
sheet.save(out_dir / "uebersicht.png")
return written
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--propose", action="store_true")
mode.add_argument("--check", action="store_true")
mode.add_argument("--review", action="store_true")
args = parser.parse_args(argv)
rgb = load_mosaic(Path("cache"))
if args.propose:
spots = scan(rgb)
DEBUG.mkdir(parents=True, exist_ok=True)
(DEBUG / "vorschlag.toml").write_text(propose_toml(spots))
contact_sheet(rgb, spots, [f"#{n} {s.chunk} {s.kind}" for n, s in enumerate(spots, 1)]).save(DEBUG / "vorschlag.png")
print(f"{len(spots)} Fundstellen -> {DEBUG / 'vorschlag.toml'}, {DEBUG / 'vorschlag.png'}")
return 0
annotations = load_annotations(ANNOTATIONS)
if args.check:
missing = uncovered(scan(rgb), annotations)
for s in missing:
x, y = s.centre
print(f"fehlt: {s.kind} · {s.chunk} · x {x:.0f} / y {y:.0f}")
print(f"{len(missing)} Fundstellen ohne Eintrag")
return 1 if missing else 0
written = review(rgb, annotations, Image.open(MAP).convert("RGB"), DEBUG)
print(f"{len(written)} Prüfbilder -> {DEBUG}")
return 0
if __name__ == "__main__":
sys.exit(main())