Each hex now gets a meta/<q>_<r>.json with stable ID, pixel center coordinates, pixel bounds, labels, notes, and classification status. The pixelCenter acts as a scale-independent anchor: when switching from 10 Meilen/Hex to 5 Meilen/Hex, pixelToAxial(meta.pixelCenter, newSize) maps coarse hexes to fine hexes without re-running classification. Adds: - pipeline/build-hexmeta.ts: creates/updates metadata + exports data/hexmeta-<map-id>.jsonl (committed, survives git clones) - pipeline/auto-classify-ocean.ts: pixel-based ocean auto-detection - pipeline/create-map.ts: one-off DB map entry creation - extract-submaps.ts: writes meta/<q>_<r>.json during extraction - data/hexmeta-1.jsonl: 8844 hex metadata entries for Aventurien map 1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
9.3 KiB
TypeScript
256 lines
9.3 KiB
TypeScript
/**
|
||
* 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';
|
||
import type { HexMeta } from './build-hexmeta.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 (with stable IDs, starting at 1)
|
||
const manifest = {
|
||
mapId,
|
||
imageWidth: image_width,
|
||
imageHeight: image_height,
|
||
hexSize: hex_size,
|
||
originX: origin_x,
|
||
originY: origin_y,
|
||
meilenPerHex: hexesPerMeile,
|
||
hexes: sorted.map((c, i) => ({ id: i + 1, q: c.q, r: c.r })),
|
||
};
|
||
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||
console.log(`Manifest written: ${sorted.length} hexes`);
|
||
|
||
// Create meta directory for per-hex attribute files
|
||
const metaDir = join(outDir, 'meta');
|
||
mkdirSync(metaDir, { recursive: true });
|
||
|
||
let done = 0;
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
const coord = sorted[i];
|
||
const filename = `${coord.q}_${coord.r}.png`;
|
||
const outPath = join(outDir, filename);
|
||
const metaPath = join(metaDir, `${coord.q}_${coord.r}.json`);
|
||
|
||
// Create meta file if not present (never overwrite existing — preserves labels/notes)
|
||
if (!existsSync(metaPath)) {
|
||
const px = axialToPixel(coord, hex_size, origin);
|
||
const r = Math.ceil(hex_size * CROP_RADIUS_FACTOR);
|
||
const meta: HexMeta = {
|
||
id: i + 1,
|
||
q: coord.q, r: coord.r,
|
||
mapId,
|
||
hexSizePx: hex_size,
|
||
meilenPerHex: hexesPerMeile,
|
||
pixelCenter: { x: Math.round(px.x), y: Math.round(px.y) },
|
||
pixelBounds: {
|
||
left: Math.max(0, Math.round(px.x - r)),
|
||
top: Math.max(0, Math.round(px.y - r)),
|
||
right: Math.min(image_width, Math.round(px.x + r)),
|
||
bottom: Math.min(image_height, Math.round(px.y + r)),
|
||
},
|
||
labels: [],
|
||
notes: '',
|
||
classification: null,
|
||
};
|
||
writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
||
}
|
||
|
||
// Skip PNG 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); });
|