docs: Task 6b für die Kartenbeschriftung, Löcher in Task 7

Die Sichtprüfung nach Task 6 hat gezeigt, dass die Beschriftungen der
Originalkarte zu Geometrie werden — "VICE CITY (MIAMI)" steht als 1.600 m
breiter Schriftzug in der Bucht. Task 6b entfernt sie, bevor Task 7 daraus
Meshes baut. Der Brief nennt drei gemessene Sackgassen (Buntheit, Farbton,
Zusammenhang nur der Straßenmaske) und den Weg, der trägt: auf Regionenebene
über alle Nicht-Wasser-Klassen, wo die Buchstaben eigene Inseln ohne jedes
natürliche Merkmal bilden.

extrudePolygons und flatPolygons in Task 7 verarbeiten jetzt Ringlisten mit
Löchern statt einzelner Ringe, passend zu Ruling 34.

overview.py rendert den ganzen Ausschnitt aus den Chunk-Dateien; damit ist
der Beschriftungsfehler überhaupt erst aufgefallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-12 17:19:28 +02:00
1 parent ce90ea1d5f
commit 062a9af2fd
2 files changed
+302 -37

No files matched your search

@@ -1510,6 +1510,168 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### Task 7: Geometrie-Baustein in JavaScript
**Files:**
@@ -1518,7 +1680,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
**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]);
}
}