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:
112
pipeline/assemble-map.ts
Normal file
112
pipeline/assemble-map.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user