data-centre-nameplates / tests /nameplates-bench.mjs
constructelligence's picture
Update data-centre nameplates to v2: 31 fields, 55 equipment types, extraction benchmark
2843cff verified
Raw History Blame Contribute Delete
9.95 kB
// Nameplate OCR benchmark: drawn plates of several equipment types, each put
// through the degradations real phone photos have (small, blurred, noisy,
// low-contrast, glare, tilted, keystoned, dark anodised plate, JPEG, plate
// small in a cluttered frame, sideways), OCR'd by the app's own pipeline and
// scored field by field against the plate text. Not part of `npm test` (it
// takes minutes); run when changing OCR or parsing:
//
// PW_CHANNEL=chrome node tests/nameplates-bench.mjs [--only=glare,blur] [--json=out.json]
//
// Plates use fictional makers. The truth for each plate is parseNameplate()
// of its clean text, so this measures what OCR loses, not parser coverage
// (tests/nameplate-model.test.mjs covers that).
import { createServer } from 'vite';
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';
import { parseNameplate, foldCode } from '../nameplate-model.js';
export const BENCH_PLATES = [
['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-480', 'SERIAL NO: NL26A01937',
'INPUT: 480Y/277 VAC 3 PH 60 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1002 A', 'MFG DATE: 03/2026'],
['HARBOR ENGINE WORKS INC', 'DIESEL GENERATOR SET', 'MODEL: HG-3000-MV', 'S/N: HEW0098812',
'13.8 KV 3 PHASE 60 HZ', '3750 KVA 3000 EKW 0.8 PF', 'RATED CURRENT 157 A', 'BUILD DATE 2026-01'],
['COLDLINE SYSTEMS', 'AIR-COOLED CHILLER', 'MODEL NO. AC-1200-R5', 'SERIAL NO. CL5517320',
'460 V 3 PH 60 HZ', 'REFRIGERANT R-513A', 'MCA 612 MOCP 800', 'MFG DATE 11/2025'],
['VOLTARC TRANSFORMER CORP', 'DRY TYPE TRANSFORMER', 'CAT. NO. VT-2500-HV', 'SERIAL VT2511047',
'PRIMARY 13800 V DELTA', 'SECONDARY 480Y/277 V', '2500 KVA 3 PH 60 HZ', 'MFG DATE 08/2025'],
['NORTHLINE POWER SYSTEMS', 'POWER DISTRIBUTION UNIT', 'MODEL NO: NPD-300-415', 'SERIAL NO: NL26B00411',
'INPUT 480 V 3 PH 60 HZ', 'OUTPUT 415Y/240 V', '300 KVA', 'MFG DATE: 02/2026'],
['COLDLINE SYSTEMS', 'COMPUTER ROOM AIR HANDLER', 'MODEL: CW-150-480', 'SERIAL: CL6600172',
'480 V 3 PH 60 HZ', 'FLA 38.5', 'MCA 45 MOCP 60', 'MFG DATE: 01/2026'],
['IRONSIDE SWITCHGEAR CORP', 'SWITCHBOARD', 'MODEL NO: IS-4000-MDP', 'SERIAL NO: IS26C01188',
'480Y/277 V 3 PH 60 HZ', '1000 KVA MAIN BUS', 'SCCR 65 KAIC NEMA 3R', 'MFG DATE: 05/2026'],
['POLARIS THERMAL', 'COOLING TOWER', 'MODEL NO: PT-CT-900', 'SERIAL NO: PT26D00417',
'460 V 3 PH 60 HZ', 'MCA 86 MOCP 110', 'FLOW 2400 GPM', 'MFG DATE: 07/2026'],
['COLDLINE SYSTEMS', 'WATER-COOLED CHILLER', 'MODEL NO. WC-900-R5', 'SERIAL NO. CL5529901',
'460 V 3 PH 60 HZ', 'REFRIGERANT R-1234ZE CHARGE 620 LBS', 'CAPACITY 500 TONS', 'AIRFLOW 24000 CFM'],
['EXIDE', 'BATTERY CABINET', 'MODEL NO. VR-48', 'SERIAL NO. EX26A00445',
'480 V 3 PH 60 HZ', 'VRLA 48 CELLS 500 AH', 'LISTED UL 1989', 'WEIGHT 1250 LBS'],
['GRUNDFOS', 'END SUCTION PUMP', 'MODEL NO. CR-64-2', 'SERIAL NO. GF26C00912',
'460 V 3 PH 60 HZ', 'FLA 42 SPEED 1750 RPM', 'WEIGHT 320 KG', 'IEC 60034'],
];
// Each degradation runs in the page against a canvas holding the clean plate.
export const DEGRADATIONS = {
clean: '',
small: 'c = resize(c, 640);',
blur: "c = filt(c, 'blur(2.2px)');",
noise: 'c = noise(c, 28);',
lowContrast: 'c = contrast(c, 0.28);',
glare: 'c = glare(c, 0.8);',
tilt: 'c = rotate(c, 7);',
keystone: 'c = keystone(c, 0.3);',
darkPlate: "c = filt(c, 'invert(1)');",
jpeg: 'c = await jpeg(resize(c, 900), 0.3);',
inScene: 'c = scene(c);',
sideways: 'c = rotate(c, 90);',
phone: "c = await jpeg(noise(glare(filt(rotate(scene(c), 4), 'blur(1.4px)'), 0.55), 14), 0.6);",
};
const HELPERS = `
const mk = (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; };
const resize = (s, w) => { const c = mk(w, Math.round(s.height * w / s.width)); c.getContext('2d').drawImage(s, 0, 0, c.width, c.height); return c; };
const filt = (s, f) => { const c = mk(s.width, s.height); const g = c.getContext('2d'); g.filter = f; g.drawImage(s, 0, 0); return c; };
let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const gauss = () => Math.sqrt(-2 * Math.log(rnd() + 1e-9)) * Math.cos(2 * Math.PI * rnd());
const pixels = (s, fn) => { const c = mk(s.width, s.height); const g = c.getContext('2d'); g.drawImage(s, 0, 0);
const im = g.getImageData(0, 0, c.width, c.height); fn(im.data, c.width, c.height); g.putImageData(im, 0, 0); return c; };
const noise = (s, sd) => pixels(s, d => { for (let i = 0; i < d.length; i += 4) { const n = gauss() * sd; d[i] += n; d[i + 1] += n; d[i + 2] += n; } });
const contrast = (s, k) => pixels(s, d => { for (let i = 0; i < d.length; i += 4) for (let j = 0; j < 3; j++) d[i + j] = 150 + (d[i + j] - 150) * k; });
const glare = (s, a) => { const c = filt(s, 'none'); const g = c.getContext('2d');
const r = g.createRadialGradient(c.width * 0.62, c.height * 0.45, 0, c.width * 0.62, c.height * 0.45, c.width * 0.33);
r.addColorStop(0, 'rgba(255,255,250,' + a + ')'); r.addColorStop(1, 'rgba(255,255,250,0)'); g.fillStyle = r; g.fillRect(0, 0, c.width, c.height); return c; };
const rotate = (s, deg) => { const t = deg * Math.PI / 180, w = Math.abs(s.width * Math.cos(t)) + Math.abs(s.height * Math.sin(t)), h = Math.abs(s.width * Math.sin(t)) + Math.abs(s.height * Math.cos(t));
const c = mk(Math.round(w), Math.round(h)); const g = c.getContext('2d'); g.fillStyle = '#4b5057'; g.fillRect(0, 0, c.width, c.height);
g.translate(w / 2, h / 2); g.rotate(t); g.drawImage(s, -s.width / 2, -s.height / 2); return c; };
const keystone = (s, k) => { const c = mk(s.width, s.height); const g = c.getContext('2d'); g.fillStyle = '#4b5057'; g.fillRect(0, 0, c.width, c.height);
for (let y = 0; y < s.height; y++) { const z = 1 - k * (1 - y / s.height); const w = s.width * z; g.drawImage(s, 0, y, s.width, 1, (s.width - w) / 2, y, w, 1); } return c; };
const jpeg = async (s, q) => { const img = new Image(); img.src = s.toDataURL('image/jpeg', q); await img.decode(); const c = mk(s.width, s.height); c.getContext('2d').drawImage(img, 0, 0); return c; };
const scene = s => { const c = mk(2400, 1800); const g = c.getContext('2d');
g.fillStyle = '#7d8a83'; g.fillRect(0, 0, c.width, c.height); // cabinet paint
g.fillStyle = '#6c7871'; for (let x = 0; x < c.width; x += 300) g.fillRect(x, 0, 8, c.height); // panel seams
g.fillStyle = '#f2c200'; g.fillRect(1650, 1280, 560, 300); g.fillStyle = '#111'; g.font = 'bold 54px Arial';
g.fillText('DANGER', 1800, 1370); g.font = 'bold 34px Arial'; g.fillText('HIGH VOLTAGE 480 V', 1700, 1450); g.fillText('ARC FLASH HAZARD', 1720, 1510);
g.drawImage(s, 360, 260, 1260, Math.round(1260 * s.height / s.width)); return c; };
`;
const score = (truth, got) => {
const keys = Object.keys(truth.fields);
const ok = keys.filter(k => {
const a = truth.fields[k].value, b = got.fields?.[k]?.value;
if (b == null) return false;
return ['model', 'serial'].includes(k) ? foldCode(a) === foldCode(b) : String(a).toUpperCase() === String(b).toUpperCase();
});
return { ok: ok.length, total: keys.length, missed: keys.filter(k => !ok.includes(k)) };
};
async function main() {
const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
const only = args.only ? args.only.split(',') : Object.keys(DEGRADATIONS);
const server = await createServer({ server: { port: 0, host: '127.0.0.1' }, logLevel: 'error' });
await server.listen();
const browser = await chromium.launch(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {});
const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage();
page.on('pageerror', e => console.error('pageerror', e.message));
await page.goto(server.resolvedUrls.local[0] + 'nameplates.html');
await page.evaluate(() => window.__nameplates.getWorker());
const results = {};
let sumOk = 0, sumTotal = 0, sumMs = 0, runs = 0;
for (const name of only) {
let ok = 0, total = 0, ms = 0;
const misses = {};
for (const [i, lines] of BENCH_PLATES.entries()) {
const truth = parseNameplate(lines.join('\n'));
const out = await page.evaluate(`(async () => {
${HELPERS}
let c = window.__nameplates.samplePlate(${JSON.stringify(lines)});
${DEGRADATIONS[name]}
c = window.__nameplates.fitForOcr(c, c.width, c.height);
const t0 = performance.now();
const r = await window.__nameplates.ocrImage(c);
return { fields: Object.fromEntries(Object.entries(r.fields).map(([k, f]) => [k, { value: f.value }])), ms: performance.now() - t0 };
})()`);
const s = score(truth, out);
ok += s.ok; total += s.total; ms += out.ms;
for (const k of s.missed) misses[k] = (misses[k] || 0) + 1;
if (args.verbose && s.missed.length) console.log(` ${name} #${i}: missed ${s.missed.map(k => `${k}=${JSON.stringify(out.fields[k]?.value ?? null)} (want ${JSON.stringify(truth.fields[k].value)})`).join(', ')}`);
}
results[name] = { acc: ok / total, ok, total, msPerPlate: Math.round(ms / BENCH_PLATES.length), misses };
sumOk += ok; sumTotal += total; sumMs += ms; runs += BENCH_PLATES.length;
console.log(`${name.padEnd(12)} ${(100 * ok / total).toFixed(1).padStart(5)}% ${String(Math.round(ms / BENCH_PLATES.length)).padStart(5)} ms/plate missed: ${Object.entries(misses).map(([k, n]) => `${k}×${n}`).join(' ') || '—'}`);
}
console.log(`${'OVERALL'.padEnd(12)} ${(100 * sumOk / sumTotal).toFixed(1).padStart(5)}% ${String(Math.round(sumMs / runs)).padStart(5)} ms/plate`);
if (args.json) writeFileSync(args.json, JSON.stringify({ overall: sumOk / sumTotal, msPerPlate: sumMs / runs, results }, null, 2));
await browser.close();
await server.close();
}
if (import.meta.url === `file://${process.argv[1]}`) main();