Add vision-LLM import pipeline for hex terrain classification

Adds a 3-phase pipeline to populate a HexMap from a source image using
Claude's vision capabilities instead of naive pixel-color matching:

Phase 1 (extract-submaps): crops annotated submap PNGs per hex,
  including center + 6 neighbours with SVG overlay.
Phase 2: Claude classifies submaps in a Code session, writing
  classifications.json — resumable across sessions.
Phase 3 (import-from-json): reads classifications.json into the DB.

Also adds assemble-map.ts to reconstruct a full image from a Leaflet
tile pyramid (used to recover the Aventurien source map from kiepenkerl).

Adds CLAUDE.md documenting the approach, scale constants
(PIXELS_PER_MEILE=8, hexSize=40 = 10 Meilen/Hex), and the
Nord/Mitte/Süd region split for the Aventurien map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-16 09:49:01 +02:00
parent 9a61647b4a
commit bc7d5a3cc7
8 changed files with 1069 additions and 1 deletions

191
CLAUDE.md Normal file
View File

@@ -0,0 +1,191 @@
# Hexifyer — CLAUDE.md
## Projektübersicht
Hexifyer ist ein Hex-Grid-Overlay-Tool für Fantasy-Karten.
Eine Leaflet-Karte (Kacheln aus einem Quellbild) wird mit einem bearbeitbaren Hex-Raster überlagert.
Jedes Hex speichert Terrain-Typ (Basis + lineare Features wie Flüsse, Straßen).
**Stack:** Vanilla TypeScript · Vite · Leaflet · Express · sql.js (SQLite WASM)
---
## Architektur
```
core/ — Pure Logik: Koordinaten, Terrain-Typen, HexMap-Datenstruktur
server/ — Express API + sql.js DB (hex_maps, hexes, hex_features)
src/ — Frontend: Leaflet-Karte, SVG-Renderer, UI-Komponenten
pipeline/ — CLI-Scripts für Daten-Import und Karten-Aufbereitung
tests/ — Vitest-Tests für core/
```
---
## Hex-Koordinatensystem
**Flat-top Axial-Koordinaten** (q, r).
```typescript
// Pixel → Axial
x = origin.x + size * (3/2) * q
y = origin.y + size * (3/2 * q + 3 * r)
```
Axiale Koordinaten sind im Pixelraum **geschert** — konstante r-Linien
sind diagonal. Das ist relevant für die Regionsaufteilung (siehe unten).
---
## Pipeline: Hex-Map aus Quellbild generieren
### Warum nicht pixelbasiert?
Der naive Ansatz — Pixelfarbe des Hex-Mittelpunkts → nächste Terrain-Farbe —
scheitert in der Praxis:
- **Visuell ähnliche Terrains:** Hügel, Wiesen und Waldränder haben oft
überlappende Farbwerte. Eine Wiese im Abendlicht sieht farblich wie
ein Wald aus; ein kahler Hügel wie Gebirge.
- **Kein Kontextwissen:** Ob ein Pixel zu einem Hügel oder einer Ebene
gehört, ist oft nur aus dem Umfeld erkennbar — der Schattenwurf,
die umgebenden Höhenlinien, angrenzende Gewässer.
- **Lineare Features nicht ableitbar:** Ob durch ein Hex ein Fluss oder
eine Straße führt — und vor allem: durch welche Kanten — ist aus
einem Einzelpixel nicht bestimmbar.
### Submap-Ansatz mit Vision-LLM
Für jedes zu klassifizierende Hex wird ein **Submap-Crop** aus dem
Quellbild erzeugt:
```
Zentrum-Hex (rot umrandet) + alle 6 Nachbar-Hexes (grau)
→ ca. 220×220 px bei hexSize=40
→ annotiertes PNG: pipeline/submaps/<map-id>/<q>_<r>.png
```
Das Vision-LLM (Claude) bekommt:
1. Das annotierte Bild — für visuelle Mustererkennung (Texturen, Formen, Schatten)
2. Bereits klassifizierte Nachbarn als Text-Kontext — für Feature-Kontinuität
```
Bereits klassifizierte Nachbarn:
NE: forest
E: plains + river(edges: W+E) ← Fluss kommt von W, geht nach E
SE: unknown
SW: plains
...
```
Dadurch kann das Modell z.B. erkennen: *„der Fluss, der beim östlichen
Nachbar via W-Kante eintritt, muss hier via E-Kante den Hex verlassen"* —
selbst wenn die Farbe im Bild alleine das nicht eindeutig zeigt.
### Verarbeitungsreihenfolge
Hexes werden **spaltenweise** verarbeitet (q aufsteigend, r aufsteigend
innerhalb jedes q). Damit sind beim Erreichen von Hex (q, r) die
Nachbarn **NW** und **W** immer bereits klassifiziert.
### Drei-Phasen-Pipeline
```
Phase 1 — Extract (pipeline/extract-submaps.ts)
Quellbild → annotierte PNG-Submaps + manifest.json
Kein API-Key nötig.
Phase 2 — Classify (Claude Code Session)
Claude liest die PNGs mit dem Read-Tool,
klassifiziert in Batches, schreibt pipeline/submaps/<id>/classifications.json.
Kann über mehrere Sessions fortgesetzt werden.
Phase 3 — Import (pipeline/import-from-json.ts)
classifications.json → SQLite-DB (hexes + hex_features)
Kein API-Key nötig.
```
**Alternative:** `pipeline/import-from-image.ts` kombiniert alle drei Phasen
in einem Script, benötigt aber einen eigenen `ANTHROPIC_API_KEY`.
---
## Aventurien-Karte (DSA 4.1, 2016)
### Quellbild
`pipeline/source/aventurien-8000x12000.jpg` — aus Tile-Pyramide
des kiepenkerl-Projekts (git.davoryn.de/calic/kiepenkerl) rekonstruiert.
Nicht im Repo (`.gitignore`), lokal erzeugen:
```bash
# Tiles holen (zoom 6, 1504 Tiles)
docker cp kiepenkerl:/app/dist-server/public/tiles/6 pipeline/source/aventurien-tiles-z6
# Bild zusammensetzen
npm run pipeline:assemble -- pipeline/source/aventurien-tiles-z6 pipeline/source/aventurien.jpg
# Auf exakte 8000×12000 cropppen
node -e "import('sharp').then(({default:s})=>s('pipeline/source/aventurien.jpg').extract({left:0,top:0,width:8000,height:12000}).jpeg({quality:92}).toFile('pipeline/source/aventurien-8000x12000.jpg').then(i=>console.log(i)))"
```
### Skala
`PIXELS_PER_MEILE = 8` (aus kiepenkerl `src/engine/route-calc.ts`)
| Skala | `hexSize` | Hexes (8000×12000) |
|---|---|---|
| 10 Meilen/Hex | **40 px** | ~17.800 |
| 5 Meilen/Hex | 20 px | ~71.000 |
Standard für diese Karte: **10 Meilen/Hex, hexSize=40**.
### Regionsaufteilung (axiale r-Koordinate)
Axiale Grenzen schneiden durch den Pixelraum **diagonal** (r-Isolinien
sind im Bild von links-oben nach rechts-unten geneigt). Das ist korrekt
und erwünscht — Grenzen sind konsistent und erweiterbar.
| Region | r-Bereich | Inhalt |
|---|---|---|
| **Nord** | r < 25 | Ifirns Ozean, Thorwal, Gjalskerland |
| **Mitte** | 25 ≤ r ≤ 90 | Mittelreich, Horasreich, Bornland, Raschtulswall, Khom |
| **Süd** | r > 90 | Al'Anfa, Südmeer-Inseln |
Jede Session kann eine Region hinzufügen, ohne die anderen zu berühren.
### Map-Eintrag in der DB
```bash
# Einmalig anlegen (z.B. über API beim laufenden Server, oder direkt):
curl -X POST http://localhost:3001/api/maps \
-H 'Content-Type: application/json' \
-d '{"name":"Aventurien 10M/Hex","image_width":8000,"image_height":12000,
"hex_size":40,"origin_x":0,"origin_y":0,"min_zoom":0,"max_zoom":6}'
```
### Workflow (Mitte-Region)
```bash
# 1. Submaps extrahieren (Mitte: r=25..90)
npm run pipeline:extract -- \
pipeline/source/aventurien-8000x12000.jpg <map-id> \
--region 0,25,133,90
# 2. Claude klassifiziert in dieser Session
# (submaps/<map-id>/classifications.json wird schrittweise gefüllt)
# 3. Ergebnisse importieren
npm run pipeline:import -- \
pipeline/submaps/<map-id>/classifications.json <map-id>
```
---
## Entwicklung
```bash
npm run dev # Vite + Express parallel
npm run test # Vitest
npm run pipeline:assemble -- <tiles-dir> <output.jpg>
npm run pipeline:extract -- <image> <map-id> [--region q0,r0,q1,r1]
npm run pipeline:import -- <classifications.json> <map-id>
```