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();