Files
hexifyer/CLAUDE.md
Axel Meyer bc7d5a3cc7 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>
2026-04-16 09:49:01 +02:00

6.1 KiB
Raw Blame History

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).

// 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:

# 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

# 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)

# 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

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>