Image-to-Text
English
custom
ocr
document-ai
information-extraction
key-information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
mechanical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
server-rack
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
Eval Results (legacy)
File size: 9,951 Bytes
2843cff 5e2d1eb 2843cff 5e2d1eb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | // 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();
|