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>
113 lines
3.4 KiB
TypeScript
113 lines
3.4 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|