diff --git a/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md b/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md index 8ba3bd2..12e086f 100644 --- a/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md +++ b/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md @@ -1510,6 +1510,168 @@ Co-Authored-By: Claude Opus 5 " ``` --- +### Task 6b: Kartenbeschriftung aus der Klassifikation entfernen + +**Warum diese Aufgabe existiert:** Die Sichtprüfung der Gesamtansicht nach Task 6 hat gezeigt, +dass die Beschriftungen der Originalkarte zu Geometrie werden. "VICE CITY (MIAMI)" steht als +rund 1.600 m breiter Schriftzug aus Straßenfläche mitten in der Bucht, die roten Ortsnamen +(STARFISH ISLAND, CATALAN BAY, PRAWN ISLAND) werden zu Brücken und spekulativen Gebäuden. +Task 7 extrudiert genau diese Daten zu Meshes; ohne diese Aufgabe stehen die Buchstaben als +Mauern in der Welt. Die 195 auffällig langen, dünnen "Gebäude" aus der Messung zu Task 5 +(eines 586 m lang bei nur 1.576 m² Fläche, eines 384 m lang bei 8 m Breite) sind dieselbe +Ursache. + +**Files:** +- Modify: `tools/extract/leonida/classify.py` +- Test: `tools/extract/tests/test_classify.py` + +**Interfaces:** +- Consumes: `CLASSES`, `classify(rgb)` aus Task 3 +- Produces: `drop_map_labels(labels: np.ndarray) -> np.ndarray` — gibt ein neues Label-Bild + zurück, in dem als Beschriftung erkannte Bereiche auf `CLASSES["wasser"]` stehen. + `run.py` ruft es unmittelbar nach `classify` auf. + +**Was schon ausprobiert und widerlegt ist.** Diese drei Wege sind gemessen und führen nicht +zum Ziel; probier sie nicht noch einmal: + +1. *Buntheit.* Die als `strasse` klassifizierten Schriftpixel über Wasser haben Medianfarbe + (47, 79, 102), Buntheit p50 49 gegen p50 0 bei echten Straßen. Eine Schwelle von 30 + entfernt aber nur 74 % der verdächtigen Pixel und dabei 5,8 % echter Straßenpixel. +2. *Farbton.* Die Palette trennt sich sauber (Wasser 201°, Gras 92°, Sand 53°, Stadt und + Straße und Gebäude neutral), aber die Regel erfasst nur 5.551 von 64.125 Nicht-Wasser- + Pixeln im Schriftzug: die hellen Buchstabenkörper sind farblich identisch mit echten + Stadtpixeln. Farbe allein kann das nicht lösen. +3. *Zusammenhang nur der Straßenmaske.* Scheitert daran, dass die hellen Teile der Buchstaben + als `stadt` klassifiziert werden und damit selbst "Land" um sich herum erzeugen. + +**Der Weg, der trägt.** Auf Regionenebene über **alle** Nicht-Wasser-Klassen gemeinsam. +Gemessen am echten Mosaik: `~wasser` zerfällt in 52 Komponenten. Die Hauptlandmasse hat +5.852.263 px; die Buchstaben bilden eigene Komponenten von 4.876 bis 14.760 px, und ihr +Anteil natürlicher Merkmale (`gruen`, `sand`, `land`) ist 0,0 %. Echte Inseln haben Grün oder +Sand, Buchstaben nicht. Die Regel "nicht die Hauptlandmasse, höchstens 2 % natürliche +Merkmale" entfernt 106.800 px (1,77 % aller Nicht-Wasser-Pixel) und darin 63.199 von 64.125 +Pixeln des Schriftzugs, also 98,6 %. Das Ergebnis ist zwischen den Größenschwellen 20.000 und +150.000 px identisch — die Trennung ist also nicht auf einen Schwellenwert gefittet. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +An `tools/extract/tests/test_classify.py` anfügen: + +```python +from leonida.classify import drop_map_labels + +def test_letter_shaped_island_becomes_water(): + """Ein Buchstabe im Wasser ist Beschriftung, keine Stadt: keine natuerlichen Merkmale.""" + labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8) + labels[40:60, 40:120] = CLASSES["stadt"] # Querbalken + labels[40:120, 40:60] = CLASSES["strasse"] # Laengsbalken -> zusammen ein "L" + out = drop_map_labels(labels) + assert (out == CLASSES["wasser"]).all() + +def test_real_island_survives(): + """Eine Insel mit Gruen und Sand bleibt, auch wenn sie klein und vom Festland getrennt ist.""" + labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8) + labels[40:120, 40:120] = CLASSES["stadt"] + labels[50:70, 50:70] = CLASSES["gruen"] + labels[100:115, 45:115] = CLASSES["sand"] + out = drop_map_labels(labels) + assert (out[40:120, 40:120] != CLASSES["wasser"]).all() + +def test_mainland_is_never_dropped(): + """Die groesste Nicht-Wasser-Komponente bleibt immer, auch voellig ohne Gruen und Sand.""" + labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8) + labels[10:190, 10:100] = CLASSES["stadt"] + out = drop_map_labels(labels) + assert (out[10:190, 10:100] == CLASSES["stadt"]).all() +``` + +- [ ] **Step 2: Test laufen lassen, er muss scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_classify.py -v` +Expected: FAIL, `ImportError: cannot import name 'drop_map_labels'` + +- [ ] **Step 3: `drop_map_labels` in `leonida/classify.py` ergänzen** + +```python +import numpy as np +from scipy import ndimage as ndi + +NATURAL_CLASSES = ("gruen", "sand", "land") +LABEL_MAX_NATURAL = 0.02 # Anteil natuerlicher Merkmale, ab dem eine Flaeche als echt gilt + + +def drop_map_labels(labels: np.ndarray) -> np.ndarray: + """Beschriftungen der Originalkarte auf Wasser zuruecksetzen. + + Die Schriftzuege der Karte werden pixelweise als stadt/strasse/gebaeude klassifiziert und + sind farblich von echten Flaechen nicht zu unterscheiden. Trennbar sind sie erst als + Region: ein Schriftzug im Wasser bildet eine eigene, vom Festland getrennte Nicht-Wasser- + Komponente ohne jedes natuerliche Merkmal, waehrend eine echte Insel Gruen oder Sand + traegt. Die groesste Komponente ist immer das Festland und bleibt unangetastet. + """ + solid = labels != CLASSES["wasser"] + if not solid.any(): + return labels + components, count = ndi.label(solid, structure=np.ones((3, 3))) + sizes = ndi.sum(solid, components, range(1, count + 1)) + mainland = int(np.argmax(sizes)) + 1 + natural = np.zeros(labels.shape, dtype=bool) + for name in NATURAL_CLASSES: + natural |= labels == CLASSES[name] + shares = ndi.mean(natural.astype(np.float32), components, range(1, count + 1)) + drop = np.zeros(count + 1, dtype=bool) + for index in range(1, count + 1): + drop[index] = index != mainland and shares[index - 1] <= LABEL_MAX_NATURAL + out = labels.copy() + out[drop[components]] = CLASSES["wasser"] + return out +``` + +- [ ] **Step 4: Test laufen lassen, er muss bestehen** + +Run: `cd tools/extract && uv run pytest tests/test_classify.py -v` +Expected: PASS + +- [ ] **Step 5: In `run.py` einhängen** + +In `main()` direkt nach `labels = classify(mosaic)` einfügen: + +```python + labels = drop_map_labels(labels) +``` + +Der Import kommt zu der bestehenden Zeile aus `leonida.classify` dazu. Die Prüfung der +Klassenanteile steht danach und misst damit die bereinigten Anteile — das ist gewollt. + +- [ ] **Step 6: Durchlauf und Sichtprüfung** + +Run: `cd tools/extract && uv run python run.py && uv run python overview.py` +Expected: 144 Chunks, und in `debug/overview.png` steht kein Schriftzug mehr in der Bucht. + +Die Sichtprüfung ist hier die eigentliche Abnahme, nicht die Unit-Tests. Sieh dir das Bild an +und beschreib, was du siehst: ob "VICE CITY (MIAMI)" verschwunden ist, ob die roten Ortsnamen +weg sind, und vor allem, ob dabei etwas Echtes mitverschwunden ist — kleine Inseln, Molen, +Hafenanlagen. Eine vollständig gepflasterte kleine Insel ohne Grün wäre der Fall, in dem die +Regel zu viel nimmt. Wenn du so etwas findest, melde es mit Position und Größe, statt die +Schwelle zu verschieben. + +Beschriftungen, die auf dem Festland liegen (etwa "BOHEMIA (DESIGN DISTRICT)"), gehören zur +Hauptlandmasse und bleiben bestehen. Das ist bewusst so: sie sind klein und stehen zwischen +echten Gebäuden, statt als 1.600-m-Schriftzug im Wasser. Melde im Bericht, wie störend sie im +Bild noch wirken. + +- [ ] **Step 7: Commit** + +```bash +git add tools/extract/leonida/classify.py tools/extract/tests/test_classify.py \ + tools/extract/run.py public/data/vice-a +git commit -m "fix(extract): Kartenbeschriftung nicht mehr als Geometrie ausgeben + +Co-Authored-By: Claude Opus 5 " +``` + +--- + ### Task 7: Geometrie-Baustein in JavaScript **Files:** @@ -1518,7 +1680,7 @@ Co-Authored-By: Claude Opus 5 " **Interfaces:** - Consumes: Chunk-JSON aus Task 6 -- Produces: `buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings}` (je eine `BufferGeometry` oder `null`), `extrudePolygons(rings, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(rings, {y, uvScale}) -> BufferGeometry` +- Produces: `buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings}` (je eine `BufferGeometry` oder `null`), `extrudePolygons(polygons, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(polygons, {y, uvScale}) -> BufferGeometry` — je Fläche eine Liste von Ringen, Außenring zuerst Reines JavaScript, nur `three` als Abhängigkeit, kein Zugriff auf `document` oder `window` — der Baustein muss in Node laufen. @@ -1532,10 +1694,13 @@ import { Box3, BufferGeometry } from "three"; import { buildChunkGeometry, extrudePolygons, flatPolygons } from "../src/world/geometry.js"; const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]]; +const PLOT = [SQUARE]; // Fläche ohne Loch: nur der Außenring +const COURTYARD = [[[0, 0], [30, 0], [30, 30], [0, 30]], + [[10, 10], [20, 10], [20, 20], [10, 20]]]; // Block mit Innenhof describe("geometry", () => { it("extrudiert ein Quadrat zu Wänden und Dach", () => { - const geo = extrudePolygons([SQUARE], { base: 0, top: 20, uvScale: 4 }); + const geo = extrudePolygons([PLOT], { base: 0, top: 20, uvScale: 4 }); // 4 Wände à 2 Dreiecke + Dach aus 2 Dreiecken expect(geo.getIndex().count / 3).toBe(10); const box = new Box3().setFromBufferAttribute(geo.getAttribute("position")); @@ -1544,15 +1709,22 @@ describe("geometry", () => { }); it("richtet Dachflächen nach oben aus", () => { - const geo = extrudePolygons([SQUARE], { base: 0, top: 5, uvScale: 4 }); + const geo = extrudePolygons([PLOT], { base: 0, top: 5, uvScale: 4 }); geo.computeVertexNormals(); const n = geo.getAttribute("normal"); const up = [...Array(n.count).keys()].filter((i) => n.getY(i) > 0.9); expect(up.length).toBeGreaterThan(0); }); + it("lässt den Innenhof eines Blocks offen", () => { + // Außenring 30x30, Loch 10x10: 8 Wände à 2 Dreiecke, das Dach ist ein Ring aus + // 8 Dreiecken. Ohne Lochbehandlung wäre der Hof zugedeckt und das Dach hätte 2. + const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 }); + expect(geo.getIndex().count / 3).toBe(24); + }); + it("legt flache Flächen auf die gewünschte Höhe", () => { - const geo = flatPolygons([SQUARE], { y: 0.03, uvScale: 8 }); + const geo = flatPolygons([PLOT], { y: 0.03, uvScale: 8 }); const box = new Box3().setFromBufferAttribute(geo.getAttribute("position")); expect(box.min.y).toBeCloseTo(0.03); expect(box.max.y).toBeCloseTo(0.03); @@ -1561,11 +1733,11 @@ describe("geometry", () => { it("baut alle Teile eines Chunks", () => { const chunk = { chunk: [0, 0], origin: [0, 0], - land: [SQUARE], - surfaces: [{ poly: SQUARE, kind: "sand" }, { poly: SQUARE, kind: "park" }], - roads: [{ poly: SQUARE, bridge: false }, { poly: SQUARE, bridge: true }], + land: [PLOT], + surfaces: [{ poly: PLOT, kind: "sand" }, { poly: PLOT, kind: "park" }], + roads: [{ poly: PLOT, bridge: false }, { poly: PLOT, bridge: true }], centerlines: [], palms: [], - buildings: [{ poly: SQUARE, height: 12, source: "district", speculative: false }], + buildings: [{ poly: PLOT, height: 12, source: "district", speculative: false }], }; const out = buildChunkGeometry(chunk); for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) { @@ -1576,12 +1748,12 @@ describe("geometry", () => { it("gibt Gebäuden eine Farbe je Haus", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [], - buildings: [{ poly: SQUARE, height: 9, source: "district", speculative: false }] }; + buildings: [{ poly: PLOT, height: 9, source: "district", speculative: false }] }; expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy(); }); it("hält alle Koordinaten im Chunk", () => { - const chunk = { chunk: [0, 0], origin: [0, 0], land: [SQUARE], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] }; + const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] }; const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position")); expect(box.min.x).toBeGreaterThanOrEqual(-0.01); expect(box.max.x).toBeLessThanOrEqual(250.01); @@ -1606,8 +1778,8 @@ const BRIDGE_THICKNESS = 0.6; export const SURFACE_KINDS = ["sand", "park", "urban"]; /** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */ -function buildingTint(ring) { - const [x, z] = ring[0]; +function buildingTint(poly) { + const [x, z] = poly[0][0]; const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1; return [0.82 + 0.18 * h, 0.80 + 0.20 * ((h * 3) % 1), 0.78 + 0.22 * ((h * 7) % 1)]; } @@ -1630,44 +1802,57 @@ function toGeometry(pos, idx, uv, color) { return geo; } -export function flatPolygons(rings, { y = 0, uvScale = 8 } = {}) { +export function flatPolygons(polygons, { y = 0, uvScale = 8 } = {}) { const pos = [], idx = [], uv = []; - for (const raw of rings) { - const ring = clockwise(raw); + for (const raw of polygons) { + const outer = clockwise(raw[0]); + const holes = raw.slice(1).map((hole) => clockwise(hole).reverse()); const base = pos.length / 3; - for (const [x, z] of ring) { - pos.push(x, y, z); - uv.push(x / uvScale, z / uvScale); + for (const ring of [outer, ...holes]) { + for (const [x, z] of ring) { + pos.push(x, y, z); + uv.push(x / uvScale, z / uvScale); + } } - for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) { + const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z })); + for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) { idx.push(base + tri[0], base + tri[2], base + tri[1]); // nach oben gedreht } } return pos.length ? toGeometry(pos, idx, uv) : null; } -export function extrudePolygons(rings, { base = 0, top = 10, uvScale = 4, tint = null } = {}) { +export function extrudePolygons(polygons, { base = 0, top = 10, uvScale = 4, tint = null } = {}) { const pos = [], idx = [], uv = [], color = []; - for (const raw of rings) { - const ring = clockwise(raw); - const n = ring.length; - for (let i = 0; i < n; i++) { - const [x0, z0] = ring[i]; - const [x1, z1] = ring[(i + 1) % n]; - const len = Math.hypot(x1 - x0, z1 - z0); - const b = pos.length / 3; - pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0); - uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale); - if (tint) for (let k = 0; k < 4; k++) color.push(...tint); - idx.push(b, b + 1, b + 2, b, b + 2, b + 3); + for (const raw of polygons) { + // Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher. + // triangulateShape erwartet die Löcher gegenläufig zum Außenring. + const outer = clockwise(raw[0]); + const holes = raw.slice(1).map((hole) => clockwise(hole).reverse()); + for (const ring of [outer, ...holes]) { + const n = ring.length; + for (let i = 0; i < n; i++) { + const [x0, z0] = ring[i]; + const [x1, z1] = ring[(i + 1) % n]; + const len = Math.hypot(x1 - x0, z1 - z0); + const b = pos.length / 3; + pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0); + uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale); + if (tint) for (let k = 0; k < 4; k++) color.push(...tint); + idx.push(b, b + 1, b + 2, b, b + 2, b + 3); + } } + // Deckfläche: erst alle Punkte in derselben Reihenfolge, die triangulateShape indiziert. const b = pos.length / 3; - for (const [x, z] of ring) { - pos.push(x, top, z); - uv.push(x / uvScale, z / uvScale); - if (tint) color.push(...tint); + for (const ring of [outer, ...holes]) { + for (const [x, z] of ring) { + pos.push(x, top, z); + uv.push(x / uvScale, z / uvScale); + if (tint) color.push(...tint); + } } - for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) { + const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z })); + for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) { idx.push(b + tri[0], b + tri[2], b + tri[1]); } } diff --git a/tools/extract/overview.py b/tools/extract/overview.py new file mode 100644 index 0000000..7f6e0d0 --- /dev/null +++ b/tools/extract/overview.py @@ -0,0 +1,80 @@ +"""Gesamtansicht des Ausschnitts aus den fertigen Chunk-Dateien. + +Zeichnet ausschließlich das, was wirklich ausgeliefert wird — kein Kartenbild darunter. +Damit fallen Fehler auf, die kein Unit-Test sieht: gespiegelte Achsen, versetzte Chunks, +Beschriftungen der Originalkarte, die als Geometrie durchgerutscht sind. + + cd tools/extract && uv run python overview.py +""" +import json +from pathlib import Path + +from PIL import Image, ImageDraw + +from leonida.coords import SLICE + +OUT = Path("../../public/data/vice-a") +DST = Path("debug/overview.png") +SCALE = 0.5 # Pixel je Meter +WATER = (36, 72, 104) +LAND = (150, 162, 120) +SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172)} +ROAD = (74, 74, 78) +BRIDGE = (196, 92, 92) +PALM = (40, 190, 70) +TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt + + +def _to_px(ring, ox, oy): + """Chunk-lokale three.js-Koordinaten (x, z) -> Bildpixel.""" + return [((x + ox - SLICE["x0"]) * SCALE, (SLICE["y1"] - (oy - z)) * SCALE) for x, z in ring] + + +def _draw(draw, rings, ox, oy, fill, outline=None): + """Außenring füllen, Löcher wieder mit Wasserfarbe ausstanzen.""" + draw.polygon(_to_px(rings[0], ox, oy), fill=fill, outline=outline) + for hole in rings[1:]: + draw.polygon(_to_px(hole, ox, oy), fill=WATER) + + +def render(out_dir: Path, dst: Path) -> dict: + size = int((SLICE["x1"] - SLICE["x0"]) * SCALE) + img = Image.new("RGB", (size, size), WATER) + draw = ImageDraw.Draw(img) + chunks = [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))] + stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0} + + # Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird. + for chunk in chunks: + ox, oy = chunk["origin"][0], -chunk["origin"][1] + for rings in chunk["land"]: + _draw(draw, rings, ox, oy, LAND) + stats["land"] += 1 + for chunk in chunks: + ox, oy = chunk["origin"][0], -chunk["origin"][1] + for surface in chunk["surfaces"]: + _draw(draw, surface["poly"], ox, oy, SURFACE[surface["kind"]]) + for chunk in chunks: + ox, oy = chunk["origin"][0], -chunk["origin"][1] + for road in chunk["roads"]: + _draw(draw, road["poly"], ox, oy, BRIDGE if road["bridge"] else ROAD) + stats["bruecken" if road["bridge"] else "strassen"] += 1 + for chunk in chunks: + ox, oy = chunk["origin"][0], -chunk["origin"][1] + for building in chunk["buildings"]: + t = min(1.0, building["height"] / TALL) + shade = (int(70 + 170 * t), int(70 + 120 * t), int(80 + 60 * t)) + _draw(draw, building["poly"], ox, oy, shade, outline=(30, 30, 30)) + stats["gebaeude"] += 1 + for x, z in chunk["palms"]: + px, py = _to_px([(x, z)], ox, oy)[0] + draw.ellipse([px - 1.6, py - 1.6, px + 1.6, py + 1.6], fill=PALM) + stats["palmen"] += 1 + + dst.parent.mkdir(parents=True, exist_ok=True) + img.save(dst) + return stats + + +if __name__ == "__main__": + print(render(OUT, DST), "->", DST)