Files
hexifyer/pipeline/assemble-map.ts
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

113 lines
3.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});