From bc7d5a3cc7df6e30104f92b382c75600d6af5d99 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Thu, 16 Apr 2026 09:49:01 +0200 Subject: [PATCH] Add vision-LLM import pipeline for hex terrain classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + CLAUDE.md | 191 ++++++++++++++++ package-lock.json | 45 ++++ package.json | 6 +- pipeline/assemble-map.ts | 112 ++++++++++ pipeline/extract-submaps.ts | 224 +++++++++++++++++++ pipeline/import-from-image.ts | 394 ++++++++++++++++++++++++++++++++++ pipeline/import-from-json.ts | 94 ++++++++ 8 files changed, 1069 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 pipeline/assemble-map.ts create mode 100644 pipeline/extract-submaps.ts create mode 100644 pipeline/import-from-image.ts create mode 100644 pipeline/import-from-json.ts diff --git a/.gitignore b/.gitignore index 9aa02fc..1f50714 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist-server/ *.log tiles/ data/*.db + +# Pipeline artefakte — lokal erzeugte Karten, Tiles, Submaps +pipeline/source/ +pipeline/submaps/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d1a038 --- /dev/null +++ b/CLAUDE.md @@ -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//_.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//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 \ + --region 0,25,133,90 + +# 2. Claude klassifiziert in dieser Session +# (submaps//classifications.json wird schrittweise gefüllt) + +# 3. Ergebnisse importieren +npm run pipeline:import -- \ + pipeline/submaps//classifications.json +``` + +--- + +## Entwicklung + +```bash +npm run dev # Vite + Express parallel +npm run test # Vitest +npm run pipeline:assemble -- +npm run pipeline:extract -- [--region q0,r0,q1,r1] +npm run pipeline:import -- +``` diff --git a/package-lock.json b/package-lock.json index e067143..51d8a06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "hexifyer", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.89.0", "concurrently": "^9.2.1", "cors": "^2.8.6", "express": "^5.2.1", @@ -25,6 +26,33 @@ "vitest": "^3.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz", + "integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", @@ -2304,6 +2332,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -3067,6 +3107,11 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 1f07ed8..7200762 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "test": "vitest run", "test:watch": "vitest", "server": "tsx server/index.ts", - "pipeline:tiles": "tsx pipeline/generate-tiles.ts" + "pipeline:tiles": "tsx pipeline/generate-tiles.ts", + "pipeline:assemble": "tsx pipeline/assemble-map.ts", + "pipeline:extract": "tsx pipeline/extract-submaps.ts", + "pipeline:import": "tsx pipeline/import-from-json.ts" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -24,6 +27,7 @@ "vitest": "^3.0.0" }, "dependencies": { + "@anthropic-ai/sdk": "^0.89.0", "concurrently": "^9.2.1", "cors": "^2.8.6", "express": "^5.2.1", diff --git a/pipeline/assemble-map.ts b/pipeline/assemble-map.ts new file mode 100644 index 0000000..9928899 --- /dev/null +++ b/pipeline/assemble-map.ts @@ -0,0 +1,112 @@ +/** + * Reconstruct a full map image from a Leaflet tile pyramid (zoom level z). + * + * Usage: + * npx tsx pipeline/assemble-map.ts [zoom-level] + * + * The tiles-dir must contain subdirectories named by x-index, each containing + * y-index.jpg files (standard Leaflet tile layout: {z}/{x}/{y}.jpg). + * + * Example: + * npx tsx pipeline/assemble-map.ts pipeline/source/aventurien-tiles-z6 pipeline/source/aventurien.jpg + */ + +import sharp from 'sharp'; +import { readdirSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; + +const TILE_SIZE = 256; + +async function assembleTiles(tilesDir: string, outputPath: string) { + if (!existsSync(tilesDir)) { + console.error(`Tiles directory not found: ${tilesDir}`); + process.exit(1); + } + + // Discover grid dimensions from directory structure + const xDirs = readdirSync(tilesDir) + .map(Number) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b); + + if (xDirs.length === 0) { + console.error('No tile columns found in', tilesDir); + process.exit(1); + } + + const maxX = Math.max(...xDirs); + + // Count rows from first column + const firstColDir = join(tilesDir, String(xDirs[0])); + const yFiles = readdirSync(firstColDir) + .map(f => parseInt(f)) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b); + const maxY = Math.max(...yFiles); + + const tilesX = maxX + 1; + const tilesY = maxY + 1; + const totalWidth = tilesX * TILE_SIZE; + const totalHeight = tilesY * TILE_SIZE; + + console.log(`Grid: ${tilesX}×${tilesY} tiles → canvas ${totalWidth}×${totalHeight}px`); + + const composites: sharp.OverlayOptions[] = []; + let loaded = 0; + + for (const x of xDirs) { + const colDir = join(tilesDir, String(x)); + const yFiles = readdirSync(colDir) + .map(f => parseInt(f)) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b); + + for (const y of yFiles) { + const tilePath = join(colDir, `${y}.jpg`); + if (!existsSync(tilePath)) continue; + composites.push({ + input: tilePath, + left: x * TILE_SIZE, + top: y * TILE_SIZE, + }); + loaded++; + } + } + + console.log(`Compositing ${loaded} tiles...`); + + // Process in batches to avoid hitting sharp's composite limit + const BATCH = 200; + let canvas = sharp({ + create: { width: totalWidth, height: totalHeight, channels: 3, background: { r: 42, g: 85, b: 116 } }, + }).jpeg({ quality: 92 }); + + // sharp supports all composites in one call for reasonable counts + await sharp({ + create: { width: totalWidth, height: totalHeight, channels: 3, background: { r: 42, g: 85, b: 116 } }, + }) + .composite(composites) + .jpeg({ quality: 92 }) + .toFile(outputPath); + + console.log(`Assembled → ${outputPath}`); + + // Print map config hint + const actualW = tilesX * TILE_SIZE; + const actualH = tilesY * TILE_SIZE; + console.log(`\nMap config hint:`); + console.log(` imageSize: [${actualW}, ${actualH}]`); + console.log(` PIXELS_PER_MEILE: 8`); + console.log(` 10 Meilen/Hex → hexSize: 40 (~${Math.round(actualW/80)}×${Math.round(actualH/69)} hexes)`); + console.log(` 5 Meilen/Hex → hexSize: 20 (~${Math.round(actualW/40)}×${Math.round(actualH/35)} hexes)`); +} + +const [tilesDir, outputPath] = process.argv.slice(2); +if (!tilesDir || !outputPath) { + console.error('Usage: npx tsx pipeline/assemble-map.ts '); + process.exit(1); +} +assembleTiles(resolve(tilesDir), resolve(outputPath)).catch(err => { + console.error('Failed:', err); + process.exit(1); +}); diff --git a/pipeline/extract-submaps.ts b/pipeline/extract-submaps.ts new file mode 100644 index 0000000..ef21d8b --- /dev/null +++ b/pipeline/extract-submaps.ts @@ -0,0 +1,224 @@ +/** + * Phase 1: Extract hex submaps from a source image for Claude-vision classification. + * + * Usage: + * npx tsx pipeline/extract-submaps.ts [options] + * + * Options: + * --region Only process hexes within this axial bounding box + * + * Output: + * submaps//manifest.json Map config + processing order + * submaps//_.png Annotated submap per hex (center in red) + * + * The manifest lists hexes in processing order (q asc, r asc) so Claude can + * use left/top neighbours as context when classifying each hex. + */ + +import sharp from 'sharp'; +import { mkdirSync, writeFileSync, existsSync } from 'fs'; +import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { initDb, getDb } from '../server/db.js'; +import { axialToPixel, hexVertices } from '../core/coords.js'; +import { gridBoundsForImage } from '../core/hex-grid.js'; +import { HexEdge, EDGE_DIRECTIONS, ALL_EDGES, type AxialCoord, type PixelCoord } from '../core/types.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const PIXELS_PER_MEILE = 8; +const CROP_RADIUS_FACTOR = 2.8; + +// ─── DB helpers ────────────────────────────────────────────────────────────── + +interface MapConfig { + id: number; + image_width: number; + image_height: number; + hex_size: number; + origin_x: number; + origin_y: number; +} + +function loadMapConfig(mapId: number): MapConfig { + const db = getDb(); + const rows = db.exec( + 'SELECT id, image_width, image_height, hex_size, origin_x, origin_y FROM hex_maps WHERE id = ?', + [mapId], + ); + if (rows.length === 0 || rows[0].values.length === 0) { + throw new Error(`Map ${mapId} not found in DB`); + } + const [id, image_width, image_height, hex_size, origin_x, origin_y] = rows[0].values[0] as number[]; + return { id, image_width, image_height, hex_size, origin_x, origin_y }; +} + +// ─── Submap extraction ──────────────────────────────────────────────────────── + +function buildHexSvg( + center: AxialCoord, + hexSize: number, + origin: PixelCoord, + cropLeft: number, + cropTop: number, + cropW: number, + cropH: number, +): string { + const EDGE_NAME: Record = { + [HexEdge.NE]: 'NE', [HexEdge.E]: 'E', [HexEdge.SE]: 'SE', + [HexEdge.SW]: 'SW', [HexEdge.W]: 'W', [HexEdge.NW]: 'NW', + }; + + const hexCoords: Array<{ coord: AxialCoord; label?: string }> = [ + { coord: center }, + ...ALL_EDGES.map(e => { + const d = EDGE_DIRECTIONS[e]; + return { coord: { q: center.q + d.q, r: center.r + d.r }, label: EDGE_NAME[e] }; + }), + ]; + + const polys = hexCoords.map(({ coord, label }, i) => { + const px = axialToPixel(coord, hexSize, origin); + const verts = hexVertices(px.x - cropLeft, px.y - cropTop, hexSize); + const pts = verts.map(v => `${v.x.toFixed(1)},${v.y.toFixed(1)}`).join(' '); + const isCenter = i === 0; + + const poly = ``; + + if (!label) return poly; + + // Small direction label + const lx = (px.x - cropLeft).toFixed(1); + const ly = (px.y - cropTop).toFixed(1); + const text = `${label}`; + return poly + text; + }); + + return `\n` + + polys.join('\n') + '\n'; +} + +async function extractSubmap( + imagePath: string, + coord: AxialCoord, + hexSize: number, + origin: PixelCoord, + imageWidth: number, + imageHeight: number, + outputPath: string, +): Promise { + const px = axialToPixel(coord, hexSize, origin); + const r = Math.ceil(hexSize * CROP_RADIUS_FACTOR); + + const left = Math.max(0, Math.round(px.x - r)); + const top = Math.max(0, Math.round(px.y - r)); + const right = Math.min(imageWidth, Math.round(px.x + r)); + const bottom = Math.min(imageHeight, Math.round(px.y + r)); + const w = right - left; + const h = bottom - top; + + const svg = buildHexSvg(coord, hexSize, origin, left, top, w, h); + + await sharp(imagePath) + .extract({ left, top, width: w, height: h }) + .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) + .png({ compressionLevel: 6 }) + .toFile(outputPath); +} + +// ─── Processing order ───────────────────────────────────────────────────────── + +function sortCoords(coords: AxialCoord[]): AxialCoord[] { + return [...coords].sort((a, b) => a.q !== b.q ? a.q - b.q : a.r - b.r); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + const imagePath = args[0]; + const mapId = parseInt(args[1], 10); + + if (!imagePath || isNaN(mapId)) { + console.error('Usage: npx tsx pipeline/extract-submaps.ts [--region q0,r0,q1,r1]'); + process.exit(1); + } + + let regionFilter: { q0: number; r0: number; q1: number; r1: number } | null = null; + const regionIdx = args.indexOf('--region'); + if (regionIdx !== -1) { + const [q0, r0, q1, r1] = args[regionIdx + 1].split(',').map(Number); + regionFilter = { q0, r0, q1, r1 }; + console.log(`Region filter: q=[${q0},${q1}] r=[${r0},${r1}]`); + } + + if (!existsSync(imagePath)) { + console.error(`Image not found: ${imagePath}`); + process.exit(1); + } + + await initDb(); + const cfg = loadMapConfig(mapId); + const { image_width, image_height, hex_size, origin_x, origin_y } = cfg; + const origin: PixelCoord = { x: origin_x, y: origin_y }; + + const hexesPerMeile = (hex_size * 2) / PIXELS_PER_MEILE; + console.log(`Map ${mapId}: ${image_width}×${image_height}px, hexSize=${hex_size}px = ${hexesPerMeile} Meilen/Hex`); + + let { coords } = gridBoundsForImage(image_width, image_height, hex_size, origin); + + if (regionFilter) { + const { q0, r0, q1, r1 } = regionFilter; + coords = coords.filter(c => c.q >= q0 && c.q <= q1 && c.r >= r0 && c.r <= r1); + } + + const sorted = sortCoords(coords); + console.log(`Total hexes to extract: ${sorted.length}`); + + const outDir = join(ROOT, 'pipeline', 'submaps', String(mapId)); + mkdirSync(outDir, { recursive: true }); + + // Write manifest + const manifest = { + mapId, + imageWidth: image_width, + imageHeight: image_height, + hexSize: hex_size, + originX: origin_x, + originY: origin_y, + meilenPerHex: hexesPerMeile, + hexes: sorted.map(c => ({ q: c.q, r: c.r })), + }; + writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + console.log(`Manifest written: ${sorted.length} hexes`); + + let done = 0; + for (const coord of sorted) { + const filename = `${coord.q}_${coord.r}.png`; + const outPath = join(outDir, filename); + + // Skip if already extracted (resumable) + if (existsSync(outPath)) { + done++; + continue; + } + + await extractSubmap(imagePath, coord, hex_size, origin, image_width, image_height, outPath); + done++; + + if (done % 100 === 0 || done === sorted.length) { + process.stdout.write(` ${done}/${sorted.length}\r`); + } + } + + console.log(`\nExtracted ${done} submaps → ${outDir}`); + console.log(`\nNext step: Claude classifies submaps in this session.`); + console.log(`Then run: npx tsx pipeline/import-from-json.ts ${outDir}/classifications.json ${mapId}`); +} + +main().catch(err => { console.error('Fatal:', err); process.exit(1); }); diff --git a/pipeline/import-from-image.ts b/pipeline/import-from-image.ts new file mode 100644 index 0000000..e17f605 --- /dev/null +++ b/pipeline/import-from-image.ts @@ -0,0 +1,394 @@ +/** + * One-time hex map terrain import from a source image using Claude vision. + * + * Usage: + * npx tsx pipeline/import-from-image.ts [options] + * + * Options: + * --model haiku|sonnet Vision model (default: sonnet) + * --dry-run Classify without writing to DB + * --save-every Persist DB every N hexes (default: 50) + * + * Processing order: column-by-column (q ascending, r ascending within q), + * so NW and W neighbours are always already classified when a hex is reached. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import sharp from 'sharp'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { initDb, getDb, saveDb } from '../server/db.js'; +import { axialToPixel, hexVertices } from '../core/coords.js'; +import { gridBoundsForImage } from '../core/hex-grid.js'; +import { TERRAIN_TYPES } from '../core/terrain.js'; +import { HexEdge, EDGE_DIRECTIONS, ALL_EDGES, type AxialCoord, type PixelCoord } from '../core/types.js'; + +// ─── Model config ──────────────────────────────────────────────────────────── + +const MODELS = { + haiku: 'claude-haiku-4-5-20251001', + sonnet: 'claude-sonnet-4-6', +} as const; + +type ModelKey = keyof typeof MODELS; + +// ─── Edge mask helpers ─────────────────────────────────────────────────────── + +const EDGE_BIT: Record = { + [HexEdge.NE]: 1, + [HexEdge.E]: 2, + [HexEdge.SE]: 4, + [HexEdge.SW]: 8, + [HexEdge.W]: 16, + [HexEdge.NW]: 32, +}; + +const EDGE_NAME: Record = { + [HexEdge.NE]: 'NE', + [HexEdge.E]: 'E', + [HexEdge.SE]: 'SE', + [HexEdge.SW]: 'SW', + [HexEdge.W]: 'W', + [HexEdge.NW]: 'NW', +}; + +// ─── Submap extraction ─────────────────────────────────────────────────────── + +const CROP_RADIUS_FACTOR = 2.8; // multiplied by hexSize → radius of crop around center + +interface Crop { + left: number; + top: number; + width: number; + height: number; +} + +function computeCrop( + cx: number, + cy: number, + hexSize: number, + imageWidth: number, + imageHeight: number, +): Crop { + const r = Math.ceil(hexSize * CROP_RADIUS_FACTOR); + const left = Math.max(0, Math.round(cx - r)); + const top = Math.max(0, Math.round(cy - r)); + const right = Math.min(imageWidth, Math.round(cx + r)); + const bottom = Math.min(imageHeight, Math.round(cy + r)); + return { left, top, width: right - left, height: bottom - top }; +} + +/** Build SVG overlay: center hex in red, neighbours in grey. */ +function buildHexOverlaySvg( + center: AxialCoord, + hexSize: number, + origin: PixelCoord, + crop: Crop, +): string { + const hexCoords = [center, ...ALL_EDGES.map(e => { + const d = EDGE_DIRECTIONS[e]; + return { q: center.q + d.q, r: center.r + d.r }; + })]; + + const polys = hexCoords.map((coord, i) => { + const px = axialToPixel(coord, hexSize, origin); + const verts = hexVertices(px.x - crop.left, px.y - crop.top, hexSize); + const pts = verts.map(v => `${v.x.toFixed(1)},${v.y.toFixed(1)}`).join(' '); + const isCenter = i === 0; + return ``; + }); + + return `\n` + + polys.join('\n') + '\n'; +} + +async function extractSubmap( + imagePath: string, + center: AxialCoord, + hexSize: number, + origin: PixelCoord, + imageWidth: number, + imageHeight: number, +): Promise { + const px = axialToPixel(center, hexSize, origin); + const crop = computeCrop(px.x, px.y, hexSize, imageWidth, imageHeight); + const svg = buildHexOverlaySvg(center, hexSize, origin, crop); + + const buf = await sharp(imagePath) + .extract(crop) + .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) + .png() + .toBuffer(); + + return buf.toString('base64'); +} + +// ─── Neighbour context ─────────────────────────────────────────────────────── + +interface ClassifiedHex { + base: string; + features: Array<{ terrainId: string; edgeMask: number }>; +} + +function buildNeighbourContext( + center: AxialCoord, + classified: Map, +): string { + const lines: string[] = []; + for (const edge of ALL_EDGES) { + const d = EDGE_DIRECTIONS[edge]; + const key = `${center.q + d.q},${center.r + d.r}`; + const name = EDGE_NAME[edge]; + const result = classified.get(key); + if (result) { + let desc = result.base; + if (result.features.length > 0) { + const feats = result.features.map(f => { + const exitEdges = ALL_EDGES + .filter(e => f.edgeMask & EDGE_BIT[e]) + .map(e => EDGE_NAME[e]) + .join('+'); + return `${f.terrainId}(edges:${exitEdges})`; + }).join(', '); + desc += ` + ${feats}`; + } + lines.push(` ${name}: ${desc}`); + } else { + lines.push(` ${name}: unknown`); + } + } + return lines.join('\n'); +} + +// ─── Claude tool definition ────────────────────────────────────────────────── + +const AREA_IDS = TERRAIN_TYPES.filter(t => t.category === 'area').map(t => t.id); +const LINEAR_IDS = TERRAIN_TYPES.filter(t => t.category === 'linear').map(t => t.id); + +const classifyTool: Anthropic.Tool = { + name: 'classify_hex', + description: 'Classify the terrain of the center hex (red outline) in the map image.', + input_schema: { + type: 'object' as const, + properties: { + base: { + type: 'string', + enum: AREA_IDS, + description: 'Primary area terrain filling the center hex.', + }, + features: { + type: 'array', + items: { + type: 'object' as const, + properties: { + terrainId: { type: 'string', enum: LINEAR_IDS }, + edgeMask: { + type: 'integer', + minimum: 0, + maximum: 63, + description: '6-bit mask of edges the feature crosses. ' + + 'Bits: NE=1, E=2, SE=4, SW=8, W=16, NW=32.', + }, + }, + required: ['terrainId', 'edgeMask'], + }, + description: 'Linear features (rivers, roads, coastlines) passing through the hex. ' + + 'Omit if none visible.', + }, + reasoning: { + type: 'string', + description: 'One sentence explaining the classification.', + }, + }, + required: ['base', 'features', 'reasoning'], + }, +}; + +const SYSTEM_PROMPT = `You are a fantasy cartography expert classifying hexagonal map regions. +You will receive a cropped section of a hand-drawn or digitally painted fantasy map. +The CENTER hex is outlined in RED. Surrounding hexes are outlined in grey for context. + +Terrain types (area): +${TERRAIN_TYPES.filter(t => t.category === 'area') + .map(t => ` ${t.id}: ${t.name}`).join('\n')} + +Linear features (can overlay area terrain): +${TERRAIN_TYPES.filter(t => t.category === 'linear') + .map(t => ` ${t.id}: ${t.name} — use edgeMask to indicate which hex edges it crosses`).join('\n')} + +Edge mask bits: NE=1, E=2, SE=4, SW=8, W=16, NW=32. +A river entering from the W edge and exiting via the E edge → edgeMask = 18 (W|E = 16+2). + +Focus on the CENTER hex. Use neighbour context only to infer continuity of rivers/roads.`; + +// ─── API call ──────────────────────────────────────────────────────────────── + +async function classifyHex( + client: Anthropic, + model: string, + submapBase64: string, + neighborContext: string, +): Promise { + const userText = `Classify the center hex (red outline).\n\nNeighbour terrain:\n${neighborContext}`; + + const response = await client.messages.create({ + model, + max_tokens: 512, + system: SYSTEM_PROMPT, + tools: [classifyTool], + tool_choice: { type: 'any' }, + messages: [{ + role: 'user', + content: [ + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: submapBase64 }, + }, + { type: 'text', text: userText }, + ], + }], + }); + + const toolUse = response.content.find(b => b.type === 'tool_use'); + if (!toolUse || toolUse.type !== 'tool_use') { + throw new Error('No tool_use block in response'); + } + + const input = toolUse.input as { base: string; features: any[]; reasoning: string }; + return { base: input.base, features: input.features ?? [] }; +} + +// ─── DB helpers ───────────────────────────────────────────────────────────── + +interface MapConfig { + id: number; + image_width: number; + image_height: number; + hex_size: number; + origin_x: number; + origin_y: number; +} + +function loadMapConfig(mapId: number): MapConfig { + const db = getDb(); + const rows = db.exec('SELECT id, image_width, image_height, hex_size, origin_x, origin_y FROM hex_maps WHERE id = ?', [mapId]); + if (rows.length === 0 || rows[0].values.length === 0) { + throw new Error(`Map ${mapId} not found`); + } + const [id, image_width, image_height, hex_size, origin_x, origin_y] = rows[0].values[0] as number[]; + return { id, image_width, image_height, hex_size, origin_x, origin_y }; +} + +function writeHex(mapId: number, coord: AxialCoord, result: ClassifiedHex): void { + const db = getDb(); + db.run( + `INSERT INTO hexes (map_id, q, r, base_terrain, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(map_id, q, r) + DO UPDATE SET base_terrain = excluded.base_terrain, updated_at = datetime('now')`, + [mapId, coord.q, coord.r, result.base], + ); + const idRows = db.exec('SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?', [mapId, coord.q, coord.r]); + const hexId = idRows[0].values[0][0] as number; + db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]); + for (const f of result.features) { + if (f.edgeMask === 0) continue; + db.run('INSERT INTO hex_features (hex_id, terrain_id, edge_mask) VALUES (?, ?, ?)', [hexId, f.terrainId, f.edgeMask]); + } +} + +// ─── Processing order ──────────────────────────────────────────────────────── + +/** Sort axial coords: q ascending, then r ascending within each q column. */ +function sortCoords(coords: AxialCoord[]): AxialCoord[] { + return [...coords].sort((a, b) => a.q !== b.q ? a.q - b.q : a.r - b.r); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + + const imagePath = args[0]; + const mapId = parseInt(args[1], 10); + + if (!imagePath || isNaN(mapId)) { + console.error('Usage: npx tsx pipeline/import-from-image.ts [--model haiku|sonnet] [--dry-run] [--save-every n]'); + process.exit(1); + } + + const modelKey = (args.includes('--model') ? args[args.indexOf('--model') + 1] : 'sonnet') as ModelKey; + const dryRun = args.includes('--dry-run'); + const saveEvery = args.includes('--save-every') ? parseInt(args[args.indexOf('--save-every') + 1], 10) : 50; + + if (!(modelKey in MODELS)) { + console.error(`Unknown model "${modelKey}". Use: haiku or sonnet`); + process.exit(1); + } + + const model = MODELS[modelKey]; + console.log(`Model: ${modelKey} (${model})`); + console.log(`Dry run: ${dryRun}`); + + await initDb(); + const mapConfig = loadMapConfig(mapId); + const { image_width, image_height, hex_size, origin_x, origin_y } = mapConfig; + const origin: PixelCoord = { x: origin_x, y: origin_y }; + + console.log(`Map ${mapId}: ${image_width}×${image_height}px, hexSize=${hex_size}, origin=(${origin_x},${origin_y})`); + + const { coords } = gridBoundsForImage(image_width, image_height, hex_size, origin); + const sorted = sortCoords(coords); + console.log(`Total hexes: ${sorted.length}`); + + const client = new Anthropic(); + const classified = new Map(); + let done = 0; + let errors = 0; + + for (const coord of sorted) { + const key = `${coord.q},${coord.r}`; + done++; + + const neighborContext = buildNeighbourContext(coord, classified); + + let result: ClassifiedHex; + try { + const submap = await extractSubmap(imagePath, coord, hex_size, origin, image_width, image_height); + result = await classifyHex(client, model, submap, neighborContext); + } catch (err) { + console.error(` [${done}/${sorted.length}] (${coord.q},${coord.r}) ERROR: ${err}`); + errors++; + result = { base: 'plains', features: [] }; + } + + classified.set(key, result); + + const featureStr = result.features.length > 0 + ? ` + ${result.features.map(f => f.terrainId).join(', ')}` + : ''; + console.log(` [${done}/${sorted.length}] (${coord.q},${coord.r}) → ${result.base}${featureStr}`); + + if (!dryRun) { + writeHex(mapId, coord, result); + if (done % saveEvery === 0) { + saveDb(); + console.log(` [saved at ${done}]`); + } + } + } + + if (!dryRun) { + saveDb(); + console.log(`\nDone. ${done} hexes written, ${errors} errors.`); + } else { + console.log(`\nDry run complete. ${done} hexes classified (not written), ${errors} errors.`); + } +} + +main().catch(err => { + console.error('Fatal:', err); + process.exit(1); +}); diff --git a/pipeline/import-from-json.ts b/pipeline/import-from-json.ts new file mode 100644 index 0000000..342bc07 --- /dev/null +++ b/pipeline/import-from-json.ts @@ -0,0 +1,94 @@ +/** + * Phase 3: Import hex classifications from JSON into the DB. + * + * Usage: + * npx tsx pipeline/import-from-json.ts + * + * Input JSON format (array): + * [ + * { "q": 0, "r": 0, "base": "forest", "features": [] }, + * { "q": 1, "r": 0, "base": "plains", "features": [{ "terrainId": "river", "edgeMask": 18 }] }, + * ... + * ] + * + * Edge mask bits: NE=1, E=2, SE=4, SW=8, W=16, NW=32 + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { initDb, getDb, saveDb } from '../server/db.js'; + +interface HexClassification { + q: number; + r: number; + base: string; + features: Array<{ terrainId: string; edgeMask: number }>; +} + +async function main() { + const [jsonPath, mapIdStr] = process.argv.slice(2); + + if (!jsonPath || !mapIdStr) { + console.error('Usage: npx tsx pipeline/import-from-json.ts '); + process.exit(1); + } + + const mapId = parseInt(mapIdStr, 10); + const hexes: HexClassification[] = JSON.parse(readFileSync(resolve(jsonPath), 'utf-8')); + console.log(`Importing ${hexes.length} hexes into map ${mapId}...`); + + await initDb(); + const db = getDb(); + + // Verify map exists + const mapRows = db.exec('SELECT id FROM hex_maps WHERE id = ?', [mapId]); + if (mapRows.length === 0 || mapRows[0].values.length === 0) { + console.error(`Map ${mapId} not found`); + process.exit(1); + } + + db.run('BEGIN TRANSACTION'); + let written = 0; + let errors = 0; + + try { + for (const hex of hexes) { + // Upsert hex + db.run( + `INSERT INTO hexes (map_id, q, r, base_terrain, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(map_id, q, r) + DO UPDATE SET base_terrain = excluded.base_terrain, updated_at = datetime('now')`, + [mapId, hex.q, hex.r, hex.base], + ); + + const idRows = db.exec( + 'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?', + [mapId, hex.q, hex.r], + ); + const hexId = idRows[0].values[0][0] as number; + + db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]); + for (const f of (hex.features ?? [])) { + if (!f.edgeMask) continue; + db.run( + 'INSERT INTO hex_features (hex_id, terrain_id, edge_mask) VALUES (?, ?, ?)', + [hexId, f.terrainId, f.edgeMask], + ); + } + + written++; + } + + db.run("UPDATE hex_maps SET updated_at = datetime('now') WHERE id = ?", [mapId]); + db.run('COMMIT'); + saveDb(); + console.log(`Done: ${written} hexes written, ${errors} errors.`); + } catch (err) { + db.run('ROLLBACK'); + console.error('Transaction failed:', err); + process.exit(1); + } +} + +main().catch(err => { console.error('Fatal:', err); process.exit(1); });