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:
224
pipeline/extract-submaps.ts
Normal file
224
pipeline/extract-submaps.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Phase 1: Extract hex submaps from a source image for Claude-vision classification.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx pipeline/extract-submaps.ts <image> <map-id> [options]
|
||||
*
|
||||
* Options:
|
||||
* --region <q0,r0,q1,r1> Only process hexes within this axial bounding box
|
||||
*
|
||||
* Output:
|
||||
* submaps/<map-id>/manifest.json Map config + processing order
|
||||
* submaps/<map-id>/<q>_<r>.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, string> = {
|
||||
[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 = `<polygon points="${pts}" fill="${isCenter ? 'rgba(255,60,60,0.10)' : 'none'}" `
|
||||
+ `stroke="${isCenter ? '#ff3c3c' : '#ffffff'}" `
|
||||
+ `stroke-width="${isCenter ? 2.5 : 1.2}" stroke-opacity="${isCenter ? 1 : 0.6}"/>`;
|
||||
|
||||
if (!label) return poly;
|
||||
|
||||
// Small direction label
|
||||
const lx = (px.x - cropLeft).toFixed(1);
|
||||
const ly = (px.y - cropTop).toFixed(1);
|
||||
const text = `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" `
|
||||
+ `font-family="monospace" font-size="${Math.max(8, hexSize * 0.35)}" fill="white" opacity="0.7">${label}</text>`;
|
||||
return poly + text;
|
||||
});
|
||||
|
||||
return `<svg width="${cropW}" height="${cropH}" xmlns="http://www.w3.org/2000/svg">\n`
|
||||
+ polys.join('\n') + '\n</svg>';
|
||||
}
|
||||
|
||||
async function extractSubmap(
|
||||
imagePath: string,
|
||||
coord: AxialCoord,
|
||||
hexSize: number,
|
||||
origin: PixelCoord,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
outputPath: string,
|
||||
): Promise<void> {
|
||||
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 <image> <map-id> [--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); });
|
||||
Reference in New Issue
Block a user