Files
gods-eye-view/scripts/qa-label-readability.mjs
T
German Khokhlov ec7dcc944e test(qa): an engine-neutral layer probe
- src/app/qaProbe.js → window.__godsEyeView.layers: list() (with
  `available`), unavailable(), available(id), enable/disable(id) (a user
  toggle through the layer manager), objects(id) → [{ id, label, position,
  screen }], screenOf(id, objectId), selected(id), following(), movers(),
  framesDrawn() - all through the engine and the catalog's availability, the
  same on Cesium and Navara. Installed after the tools.
- src/app/layers/qaSources.js: `?qaFeed=flights:synthetic:10000,
  vessels:fixture` (dev server only, allowQaRegistration) replaces those
  sources with seeded synthetic answers served through the live sources'
  own parsers (same snapshot shape; loaded with import() only then). The
  pure generators moved from src/tooling/w1Feeds.js to src/bench/w1Feeds.js
  (the browser graph may not reach src/tooling); the W1 recordings
  re-export them.
- scripts/qa-w1-layers.mjs: the parity checklist's frame, --layer
  --engine=cesium|navara with the fixture feeds, JSON per run and
  --compare (objects on screen per pose, 5 known objects within 2 px).
  Filled: 1 view, 8 idle, 9 errors/no leftover mover sets after 10
  on/off; 2-7 report TODO for the layer tasks.
- qa-perf, qa-labels, qa-overlay-baseline and qa-label-readability take
  --engine= and use the engine (camera, frames, render requests, tiles) and
  the probe (layers, following, movers) instead of the viewer; checks whose
  layers an engine lacks are SKIPPED with the reason (Navara today), as are
  qa-perf's animated-style checks there (Cesium post stages).

Verdicts, Cesium, before → after: qa-perf 24/24 → 24/24, qa-labels 14/14 →
14/14, qa-overlay-baseline (datacenters, firms, detection-50) 2 measured +
1 skipped → the same. Navara: qa-perf 14 passed, 3 skipped; qa-labels 3/3,
1 skipped; qa-overlay-baseline 3 skipped; qa-w1-layers flights: Cesium 5/5
(10 000 synthetic aircraft), Navara skipped (no flights renderer yet).
2026-10-16 22:52:18 +04:00

437 lines
17 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 [--engine=cesium|navara]
*
* Engine-neutral: camera, tiles and layers go through the globe engine and the
* QA probe; an engine without the flights/satellites layers (Navara today)
* shoots the scenes without a contact field and says so.
*
* 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 ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const APP_URL = (() => {
const url = new URL(getOpt('--url', 'http://localhost:4244'));
url.searchParams.set('engine', ENGINE);
return url.href;
})();
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.
await puppeteer.executablePath().catch(() => 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 { engine } = window.__godsEyeView;
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 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?.layers && 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} … `);
const fieldShown = await page.evaluate(async (spec) => {
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
const Cartesian3 = Object.assign(
(x, y, z) => geo.cartesian3.create(x, y, z),
{ fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) },
);
engine.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 = engine.camera.pose();
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
const halfWidth = halfHeight * (camera.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 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;
const fieldLayers = Object.keys(assignment).filter(
(layerId) => assignment[layerId].length,
);
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',
});
return fieldLayers.every((layerId) => layers.available(layerId));
}, 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, engine: ENGINE, converged, fieldShown, ...stats });
console.log(
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
+ `${converged ? '' : ' (tiles did not fully settle)'}`
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
+ ` [${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);
});