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>
This commit is contained in:
Axel Meyer
2026-04-16 10:07:09 +02:00
parent bc7d5a3cc7
commit 12200735bf
7 changed files with 9207 additions and 5 deletions

View File

@@ -0,0 +1,136 @@
/**
* Auto-classify ocean and off-map hexes from the source image.
* Writes to classifications.json (appends / merges with existing entries).
*
* Ocean detection heuristic:
* - Pixel is blue-dominant (Aventurien ocean ≈ #2a5574, border ≈ #2a5574)
* - OR pixel is very light (white map border / outside image)
* - OR hex center is outside image bounds
*
* Everything else is left for manual (vision) classification.
*
* Usage:
* npx tsx pipeline/auto-classify-ocean.ts <map-id>
*/
import sharp from 'sharp';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { axialToPixel } from '../core/coords.js';
import type { PixelCoord } from '../core/types.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
function isOcean(r: number, g: number, b: number): boolean {
// Off-map / white border
if (r > 200 && g > 200 && b > 200) return true;
// Black map border / decoration lines
if (r < 50 && g < 50 && b < 50) return true;
// Very dark (near-black) border elements
if (Math.max(r, g, b) < 60) return true;
// Blue-dominant ocean (Aventurien sea ≈ rgb(42,85,116) and similar)
if (b > 80 && b > r * 1.3 && b >= g) return true;
// Bright cyan/turquoise ocean variants with text overlays
if (b > 100 && g > 100 && r < 100) return true;
// Dark border padding (rgb(42,85,116) added by tile generator)
if (b > 90 && r < 70 && g < 110) return true;
return false;
}
async function main() {
const mapId = parseInt(process.argv[2], 10);
if (isNaN(mapId)) {
console.error('Usage: npx tsx pipeline/auto-classify-ocean.ts <map-id>');
process.exit(1);
}
const submapDir = join(ROOT, 'pipeline', 'submaps', String(mapId));
const manifestPath = join(submapDir, 'manifest.json');
const classPath = join(submapDir, 'classifications.json');
const imagePath = join(ROOT, 'pipeline', 'source', 'aventurien-8000x12000.jpg');
if (!existsSync(manifestPath)) {
console.error('No manifest found. Run extract-submaps first.');
process.exit(1);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const { hexes, hexSize, originX, originY, imageWidth, imageHeight } = manifest;
const origin: PixelCoord = { x: originX, y: originY };
// Load existing classifications
const existing = new Map<string, object>();
if (existsSync(classPath)) {
const arr: Array<{ q: number; r: number }> = JSON.parse(readFileSync(classPath, 'utf-8'));
for (const entry of arr) existing.set(`${entry.q},${entry.r}`, entry);
}
console.log(`Manifest: ${hexes.length} hexes. Already classified: ${existing.size}`);
console.log(`Loading source image...`);
// Load full image as raw RGB buffer for fast pixel sampling
const { data, info } = await sharp(imagePath)
.raw()
.toBuffer({ resolveWithObject: true });
function samplePixel(px: number, py: number): [number, number, number] | null {
const x = Math.round(px);
const y = Math.round(py);
if (x < 0 || x >= info.width || y < 0 || y >= info.height) return null;
const idx = (y * info.width + x) * info.channels;
return [data[idx], data[idx + 1], data[idx + 2]];
}
let autoOcean = 0;
let skipped = 0;
let needsManual = 0;
const results: object[] = [...existing.values()];
for (const { q, r } of hexes) {
const key = `${q},${r}`;
if (existing.has(key)) { skipped++; continue; }
const px = axialToPixel({ q, r }, hexSize, origin);
// Sample center + 6 inner points at 50% radius for robustness
const SQRT3 = Math.sqrt(3);
const innerR = hexSize * 0.5;
const samplePoints = [
{ x: px.x, y: px.y },
...Array.from({ length: 6 }, (_, i) => ({
x: px.x + innerR * Math.cos(Math.PI / 3 * i),
y: px.y + innerR * Math.sin(Math.PI / 3 * i),
})),
];
let oceanVotes = 0;
let totalSampled = 0;
for (const pt of samplePoints) {
const pixel = samplePixel(pt.x, pt.y);
if (pixel === null) { oceanVotes++; totalSampled++; continue; }
totalSampled++;
if (isOcean(pixel[0], pixel[1], pixel[2])) oceanVotes++;
}
// Ocean if majority of sampled points look like ocean
const isOceanHex = oceanVotes >= Math.ceil(totalSampled * 0.6);
if (isOceanHex) {
results.push({ q, r, base: 'ocean', features: [] });
autoOcean++;
} else {
needsManual++;
}
}
writeFileSync(classPath, JSON.stringify(results, null, 0) + '\n');
console.log(`\nAuto-classified: ${autoOcean} ocean hexes`);
console.log(`Skipped (already done): ${skipped}`);
console.log(`Remaining for manual classification: ${needsManual}`);
console.log(`\nclassifications.json: ${results.length} entries total`);
}
main().catch(err => { console.error(err); process.exit(1); });