Files
hexifyer/pipeline/extract-submaps.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

225 lines
8.1 KiB
TypeScript
Raw 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.
/**
* 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); });