/** * Phase 1: Extract hex submaps from a source image for Claude-vision classification. * * Usage: * npx tsx pipeline/extract-submaps.ts [options] * * Options: * --region Only process hexes within this axial bounding box * * Output: * submaps//manifest.json Map config + processing order * submaps//_.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.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 = ``; if (!label) return poly; // Small direction label const lx = (px.x - cropLeft).toFixed(1); const ly = (px.y - cropTop).toFixed(1); const text = `${label}`; return poly + text; }); return `\n` + polys.join('\n') + '\n'; } async function extractSubmap( imagePath: string, coord: AxialCoord, hexSize: number, origin: PixelCoord, imageWidth: number, imageHeight: number, outputPath: string, ): Promise { 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 [--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); });