#!/usr/bin/env node /** * qa-label-readability.mjs — before/after evidence capture for ambient label * legibility over bright ground. * * Run: * node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after * * Every scene pins its own camera pose AND substitutes a deterministic * synthetic contact field for the live pollers, so a `before` run and an * `after` run produce pixel-comparable frames: same contacts, same callsigns, * same placements, same imagery. The only difference between the two tags is * the code under test. * * Scenes cover the cases the readability spec calls out: * bright-* — sunlit aerial imagery, one shot per visual style * dark-* — dark water/terrain, where a plate must NOT turn into a box * sats-* — space-tier contacts over the lit Earth disc * horizon-* — cockpit pose at the horizon, contacts against SKY, where the * plate must feather back toward the bare-text look * band-* — the horizon crossing the middle of the label field, so the * feather can be judged for smoothness rather than popping * * Each shot also reports a luminance histogram of the label band, so the * "did the plate actually darken anything" question has a number behind it and * not only an eyeball. */ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, fallback) => { const index = argv.indexOf(name); return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback; }; const APP_URL = getOpt('--url', 'http://localhost:4244'); const TAG = getOpt('--tag', 'after'); /** Comma-separated scene-id prefixes. Empty runs the whole set. */ const SCENE_FILTER = getOpt('--scenes', '') .split(',') .map((token) => token.trim()) .filter(Boolean); const SHOT_DIR = path.resolve(getOpt('--out', 'qa-shots/labels')); const SETTLE_MAX_MS = 40_000; const SETTLE_MIN_MS = 4_000; /** * Scenes are (camera pose × visual style). `field` describes the synthetic * contact cloud dropped around the camera target. */ /** Sunlit airport apron — the brightest ground the product routinely flies over. */ const APRON = { lon: -97.6664, lat: 30.1975, height: 1100, heading: 0.35, pitch: -0.85 }; /** * Cockpit pose: cruise altitude, level, looking out at the horizon. From 10 km * the ellipsoid silhouette sits ~3.2° below the local horizontal, so a level * camera puts the horizon line just under frame centre and everything the * `horizon-*` field lands on is open sky. */ const COCKPIT = { lon: -98.35, lat: 30.05, height: 10_000, heading: 1.42, pitch: 0 }; /** Contacts biased into the upper frame: every one of them backs onto sky. */ const SKY_FIELD = { kind: 'air', count: 30, near: 40_000, far: 120_000, spanY: 0.9, biasY: 0.3, }; const SCENES = [ { id: 'bright-normal', style: 'normal', note: 'sunlit apron concrete — the failure case from the field report', camera: APRON, field: { kind: 'air', count: 30, near: 700, far: 2600 }, }, { id: 'bright-retro', style: 'retro', note: 'CRT amber over the same sunlit ground', camera: APRON, field: { kind: 'air', count: 30, near: 700, far: 2600 }, }, { id: 'bright-surveillance', style: 'surveillance', note: 'NVG green over the same sunlit ground', camera: APRON, field: { kind: 'air', count: 30, near: 700, far: 2600 }, }, { id: 'bright-thermal', style: 'thermal', note: 'FLIR over the same sunlit ground', camera: APRON, field: { kind: 'air', count: 30, near: 700, far: 2600 }, }, { id: 'dark-normal', style: 'normal', note: 'dark water — a plate here must stay a whisper, not a box', camera: { lon: 139.05, lat: 34.45, height: 9000, heading: 0.2, pitch: -0.62 }, field: { kind: 'air', count: 30, near: 6000, far: 26_000 }, }, { id: 'sats-normal', style: 'normal', note: 'space tier over the lit Earth disc', camera: { lon: -60, lat: 20, height: 21_000_000, heading: 0, pitch: -Math.PI / 2 }, field: { kind: 'sat', count: 30, near: 14_000_000, far: 20_000_000 }, }, { id: 'horizon-normal', style: 'normal', note: 'cockpit at the horizon — the sky backdrop the plate must feather off', camera: COCKPIT, field: SKY_FIELD, }, { id: 'horizon-retro', style: 'retro', note: 'CRT amber against the same sky', camera: COCKPIT, field: SKY_FIELD, }, { id: 'horizon-surveillance', style: 'surveillance', note: 'NVG green against the same sky', camera: COCKPIT, field: SKY_FIELD, }, { id: 'horizon-thermal', style: 'thermal', note: 'FLIR against the same sky', camera: COCKPIT, field: SKY_FIELD, }, { id: 'band-normal', style: 'normal', note: 'horizon crossing the label field — feather smoothness, both sides in one frame', camera: COCKPIT, // Shallower depths keep the low contacts above ground rather than inside it, // so the frame carries genuine terrain-backed AND sky-backed labels. field: { kind: 'air', count: 30, near: 15_000, far: 60_000, spanY: 1.0, biasY: -0.1, }, // The band scene's contacts sit lower in the frame than the default crop. crop: { x: 0.16, y: 0.28, w: 0.52, h: 0.54 }, }, ]; const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, // Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath // the harnesses and its software-GL behaviour shifts across majors. (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), ].filter(Boolean); function findChrome() { for (const candidate of CHROME_CANDIDATES) { try { if (fs.existsSync(candidate)) return candidate; } catch { /* fall through */ } } return null; } /** Decode a base64 PNG inside the page and return luminance statistics. */ async function measureShot(page, base64) { return page.evaluate(async (b64) => { const image = new Image(); await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = `data:image/png;base64,${b64}`; }); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0); // Sample the central band only: the vignette and the fixed chrome rails // would otherwise dominate a whole-frame histogram. const x0 = Math.round(image.width * 0.2); const x1 = Math.round(image.width * 0.8); const y0 = Math.round(image.height * 0.15); const y1 = Math.round(image.height * 0.8); const data = ctx.getImageData(x0, y0, x1 - x0, y1 - y0).data; const values = []; for (let i = 0; i < data.length; i += 4) { values.push(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114); } values.sort((a, b) => a - b); const at = (q) => Math.round(values[Math.min(values.length - 1, Math.floor(values.length * q))]); return { p05: at(0.05), p25: at(0.25), p50: at(0.5), p95: at(0.95), samples: values.length }; }, base64); } /** Wait until consecutive frames stop changing, so tiles are fully resolved. */ async function settle(page) { const started = Date.now(); let previous = null; while (Date.now() - started < SETTLE_MAX_MS) { await new Promise((resolve) => setTimeout(resolve, 1500)); const state = await page.evaluate(() => { const { viewer, tileset } = window.__godsEyeView; return { tilesLoaded: tileset?.tilesLoaded !== false, pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0, }; }); const settled = state.tilesLoaded && state.pending === 0; if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true; previous = settled; } return false; } async function main() { console.log('\nLabel readability evidence'); console.log(` App URL : ${APP_URL}`); console.log(` Tag : ${TAG}`); console.log(` Output : ${SHOT_DIR}\n`); try { const response = await fetch(APP_URL); if (!response.ok) throw new Error(`HTTP ${response.status}`); } catch (error) { console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`); process.exit(2); } fs.mkdirSync(SHOT_DIR, { recursive: true }); const executablePath = findChrome(); const browser = await puppeteer.launch({ headless: 'new', ...(executablePath ? { executablePath } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800', ], }); const results = []; try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); page.on('pageerror', (error) => console.warn(` ! pageerror: ${error.message}`)); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager, { timeout: 60_000, polling: 100 }, ); // flyToAustin arrives ~500 ms after init; let it start and land before any // scene sets its own pose, or the arrival overwrites the first camera. await new Promise((resolve) => setTimeout(resolve, 2500)); const selected = SCENE_FILTER.length ? SCENES.filter((scene) => SCENE_FILTER.some((prefix) => scene.id.startsWith(prefix))) : SCENES; if (selected.length === 0) throw new Error(`--scenes matched nothing: ${SCENE_FILTER}`); for (const scene of selected) { process.stdout.write(` ${scene.id} … `); await page.evaluate((spec) => { const { viewer, dataManager, styleManager } = window.__godsEyeView; viewer.camera.cancelFlight(); const Cartesian3 = viewer.camera.position.constructor; viewer.camera.setView({ destination: Cartesian3.fromDegrees( spec.camera.lon, spec.camera.lat, spec.camera.height, ), orientation: { heading: spec.camera.heading, pitch: spec.camera.pitch, roll: 0, }, }); // Contacts are placed by SCREEN position rather than by geography: a // pitched camera looks well ahead of its own ground point, so a cloud // centred on the camera coordinate mostly lands behind the frustum and // the frame comes back with two labels instead of a field. Building // straight off the camera basis guarantees every contact lands inside // the keyhole, at a known depth, in both the before and after runs. const camera = viewer.camera; const frustum = camera.frustum; const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2); const halfWidth = halfHeight * (frustum.aspectRatio || 1.6); const basis = { px: camera.position.x, py: camera.position.y, pz: camera.position.z, dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z, ux: camera.up.x, uy: camera.up.y, uz: camera.up.z, rx: camera.right.x, ry: camera.right.y, rz: camera.right.z, }; // A 6x5 jittered lattice over the central 76% of the frame. Deterministic // by construction — no RNG anywhere in the harness. const pointAt = (ndcX, ndcY, distance) => { const sx = ndcX * halfWidth * distance; const sy = ndcY * halfHeight * distance; return new Cartesian3( basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy, basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy, basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy, ); }; const build = (prefix) => { const rows = []; const cols = 6; const isSat = spec.field.kind === 'sat'; for (let i = 0; i < spec.field.count; i++) { const col = i % cols; const row = Math.floor(i / cols); const jitter = ((i * 37) % 11) / 11 - 0.5; // spanY/biasY let a scene aim the lattice at sky, at ground, or // straight across the horizon line without moving the camera. const spanX = spec.field.spanX ?? 1.52; const spanY = spec.field.spanY ?? 1.34; const biasY = spec.field.biasY ?? 0; const ndcX = ((col + 0.5) / cols - 0.5) * spanX + jitter * 0.06; const ndcY = ((row + 0.5) / Math.ceil(spec.field.count / cols) - 0.5) * spanY + biasY + jitter * 0.05; const depth = spec.field.near + (spec.field.far - spec.field.near) * (((i * 53) % 17) / 17); const flightLevel = 20 + ((i * 29) % 380); rows.push({ sourceId: `${prefix}-${String(i).padStart(3, '0')}`, id: isSat ? `SAT-${String(1000 + i)}` : `JA${String(20000 + i * 7)}`, metric: isSat ? 'LEO' : `FL${String(flightLevel).padStart(3, '0')}`, position: pointAt(ndcX, ndcY, depth), type: isSat ? 'SAT' : 'AIR', tier: isSat ? 'space' : (i % 6 === 0 ? 'military' : 'civil'), }); } return rows; }; const assignment = spec.field.kind === 'sat' ? { satellites: build('s'), flights: [], military: [] } : { flights: build('f'), military: [], satellites: [] }; window.__LABEL_EVIDENCE = assignment; for (const layerId of Object.keys(assignment)) { const entry = dataManager.layers.get(layerId); if (entry?.module) { entry.module.getDetectableObjects = () => window.__LABEL_EVIDENCE[layerId]; } } styleManager.setStyle(spec.style, { applyPreset: false }); // Set detection AFTER the style: military presets auto-arm Panoptic at // 0% density, which would otherwise leave the frame without callouts. styleManager.setDetection({ enabled: true, densityPct: 100, allocationStrategy: 'elastic', }); }, scene); const converged = await settle(page); const base64 = await page.screenshot({ encoding: 'base64' }); fs.writeFileSync( path.join(SHOT_DIR, `${TAG}-${scene.id}.png`), Buffer.from(base64, 'base64'), ); // A 2x blow-up of the label field. Ambient callouts are 10px mono, and // the taste call this evidence exists for cannot be made at 1:1. const crop = await page.evaluate(async ({ b64, box }) => { const image = new Image(); await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = `data:image/png;base64,${b64}`; }); const sx = Math.round(image.width * box.x); const sy = Math.round(image.height * box.y); const sw = Math.round(image.width * box.w); const sh = Math.round(image.height * box.h); const canvas = document.createElement('canvas'); canvas.width = sw * 2; canvas.height = sh * 2; const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = false; ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw * 2, sh * 2); return canvas.toDataURL('image/png').split(',')[1]; }, { b64: base64, box: scene.crop ?? { x: 0.16, y: 0.14, w: 0.52, h: 0.5 } }); fs.writeFileSync( path.join(SHOT_DIR, `${TAG}-${scene.id}-crop.png`), Buffer.from(crop, 'base64'), ); const stats = await measureShot(page, base64); const diagnostics = await page.evaluate( () => window.__godsEyeView.styleManager.getDetectionState?.() || {}, ); results.push({ scene: scene.id, converged, ...stats }); console.log( `p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}` + `${converged ? '' : ' (tiles did not fully settle)'}` + ` [${diagnostics.detectionMode || '?'}]`, ); } } finally { await browser.close(); } const summary = path.join(SHOT_DIR, `${TAG}-summary.json`); fs.writeFileSync(summary, `${JSON.stringify(results, null, 2)}\n`); console.log(`\n ${results.length} shots → ${SHOT_DIR}`); console.log(` summary → ${summary}\n`); } main().catch((error) => { console.error(error); process.exit(1); });