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

112
pipeline/assemble-map.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Reconstruct a full map image from a Leaflet tile pyramid (zoom level z).
*
* Usage:
* npx tsx pipeline/assemble-map.ts <tiles-dir> <output-image> [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 <tiles-dir> <output-image>');
process.exit(1);
}
assembleTiles(resolve(tilesDir), resolve(outputPath)).catch(err => {
console.error('Failed:', err);
process.exit(1);
});