Files
hexifyer/pipeline/extract-submaps.ts
Axel Meyer 12200735bf Add per-hex metadata system for scale-invariant classification
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>
2026-04-16 10:07:09 +02:00

256 lines
9.3 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';
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); });