/** * 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 */ 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 '); 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(); 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); });