gods-eye-view/scripts/qa-label-readability.mjs

423 lines
16 KiB
JavaScript
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.

#!/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);
});