// Nameplates page controller: load an equipment schedule, photograph a plate, // OCR it (Tesseract.js, in the browser), parse and check the fields against the // schedule, and keep an asset register that exports to CSV. import { FIELDS, FIELD_GROUPS, EQUIPMENT_TYPES, STATUS_LABELS, SAMPLE_SCHEDULE, SAMPLE_PLATES, parseNameplate, parseEquipmentSchedule, candidatesFor, checkAgainst, statusOf, registerSummary, registerCsv, duplicateSerials, voltagesIn, } from './nameplate-model.js'; const TESSERACT = 'https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js'; const STORE = 'buildvision.nameplates.v1'; const LOW_CONF = 70; const NUMERIC = new Set(['phase', 'hz', 'kva', 'kw', 'amps', 'pf', 'mca', 'mocp', 'sccr', 'charge', 'tons', 'btu', 'cfm', 'gpm', 'batteryAh', 'cells', 'rpm', 'weight', 'uHeight', 'powerW', 'rackLoad', 'outlets', 'portSpeed', 'ports']); // Dark enough for white label text on the photo overlay. const COLORS = { manufacturer: '#0f2547', model: '#1b5fa8', serial: '#7340a8', voltage: '#c0262d', phase: '#b04f00', hz: '#7a4a35', kva: '#1f7a32', kw: '#0b7285', amps: '#a8327a', pf: '#8a1c5a', mca: '#5c6b00', mocp: '#8a5a00', sccr: '#6b2d8a', enclosure: '#3a4a5a', standards: '#4a3f6b', batteryType: '#5a2d6b', batteryAh: '#7a3f00', cells: '#6b4a00', refrigerant: '#4d6b1f', charge: '#2f6b3a', tons: '#0b6b6b', btu: '#0b5c8a', cfm: '#5a5c00', gpm: '#00636b', uHeight: '#3a3f8a', formFactor: '#4a3f8a', powerW: '#8a5a1f', rackLoad: '#6b5a1f', outletTypes: '#6b3a6b', outlets: '#6b3a6b', portSpeed: '#3a6b5a', ports: '#3a6b5a', rpm: '#7a2c2c', weight: '#444a52', mfgDate: '#555b63', }; const $ = id => document.getElementById(id); const els = Object.fromEntries([ 'npSchedDrop', 'npSchedFile', 'npSchedSample', 'npSchedClear', 'npSchedStatus', 'npDrop', 'npFile', 'npSample', 'npRotate', 'npRun', 'npProg', 'npStatus', 'npModel', 'npCanvas', 'npEmpty', 'npQuality', 'npReading', 'npVerdict', 'npFields', 'npTag', 'npTagNote', 'npChecks', 'npAdd', 'npText', 'npSummary', 'npCsv', 'npClearReg', 'npSearch', 'npFilter', 'npRows', 'npEmptyReg', 'npMissing', ].map(id => [id.slice(2, 3).toLowerCase() + id.slice(3), $(id)])); const state = { schedule: null, // { name, items } register: [], // [{ id, tag, type, fields, checks, status, thumb, scannedAt }] image: null, // canvas holding the photo as loaded (after rotation) reading: null, // parseNameplate() result, with edits applied candidates: [], tag: '', sample: 0, editing: null, // register id being reviewed (Add then updates it) photoFilter: 'all', // register status filter photoQuery: '', // register search text photos: new Map(), // register id -> { image, reading } for this session (photos are not stored) }; window.BuildVisionNameplates = state; // ------------------------------------------------------------------ storage function load() { try { const s = JSON.parse(localStorage.getItem(STORE) || 'null'); if (s) { state.schedule = s.schedule || null; state.register = s.register || []; } } catch { /* private window or cleared storage: start empty */ } } function save() { try { localStorage.setItem(STORE, JSON.stringify({ schedule: state.schedule, register: state.register })); } catch { setStatus('Could not save the register in this browser. Download the CSV to keep it.', true); } } function setStatus(msg, err = false) { els.status.textContent = msg; els.status.classList.toggle('err', err); } function setProgress(p) { els.prog.style.width = `${Math.round(p * 100)}%`; } const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); // "Model" -> "model" in a sentence, but MOCP and kVA keep their case. const inSentence = label => (label.slice(1) === label.slice(1).toLowerCase() ? label.toLowerCase() : label); const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || ''; // ------------------------------------------------------------------ schedule function setSchedule(text, name) { const { items, problems } = parseEquipmentSchedule(text); if (!items.length) { els.schedStatus.textContent = problems[0] || 'No equipment rows found.'; els.schedStatus.classList.add('err'); return; } state.schedule = { name, items }; els.schedStatus.classList.remove('err'); save(); renderSchedule(problems); if (state.reading) rematch(); renderRegister(); } function renderSchedule(problems = []) { els.schedClear.hidden = !state.schedule; if (!state.schedule) { els.schedStatus.textContent = 'No schedule loaded: plates are read but not checked.'; return; } const n = state.schedule.items.length; els.schedStatus.textContent = `${state.schedule.name}: ${n} item${n === 1 ? '' : 's'}.${problems.length ? ` ${problems.join(' ')}` : ''}`; } // ------------------------------------------------------------------ photo async function decodeFile(file) { const url = URL.createObjectURL(file); try { const img = new Image(); img.src = url; await img.decode(); return img; } finally { URL.revokeObjectURL(url); } } async function loadImageFile(file) { try { const img = await decodeFile(file); setImage(img, img.naturalWidth, img.naturalHeight); setStatus(`Loaded ${file.name}. Read it when ready.`); } catch { setStatus('That file could not be opened as an image.', true); } } /** * A walk's worth of photos: read each, match it to its likeliest tag and put it on the register. * Flagged and low-confidence plates are then reviewed by clicking their row. */ async function readBatch(files) { const busy = [els.run, els.sample, els.rotate, els.add]; busy.forEach(b => { b.disabled = true; }); let done = 0, failed = 0; try { for (const [i, file] of files.entries()) { setProgress(i / files.length); setStatus(`Reading ${i + 1} of ${files.length}: ${file.name}…`); let img; try { img = await decodeFile(file); } catch { failed++; continue; } setImage(img, img.naturalWidth, img.naturalHeight); state.reading = await ocrImage(state.image); state.reading.source = file.name; rematch(true); addToRegister({ quiet: true }); done++; } setProgress(1); const added = state.register.slice(-done); const flagged = added.filter(r => r.status === 'mismatch').length; const review = added.filter(r => r.status !== 'verified').length; setStatus(`Read ${done} plate${done === 1 ? '' : 's'}${failed ? ` (${failed} file${failed === 1 ? '' : 's'} could not be opened)` : ''}: ` + `${done - review} verified, ${flagged} flagged${review - flagged ? `, ${review - flagged} to review` : ''}. Click a row to review it.`, failed > 0 && !done); } catch (e) { setStatus(`OCR failed: ${e.message}. It needs a network connection the first time.`, true); } finally { busy.forEach(b => { b.disabled = false; }); els.add.disabled = false; } } /** Keep phone photos to a size OCR handles quickly; upscale small crops so text has enough pixels. */ function fitForOcr(src, w, h) { const scale = Math.min(2400 / Math.max(w, h), Math.max(1, 1400 / Math.max(w, h))); const c = document.createElement('canvas'); c.width = Math.round(w * scale); c.height = Math.round(h * scale); c.getContext('2d').drawImage(src, 0, 0, c.width, c.height); return c; } function setImage(src, w, h) { state.image = fitForOcr(src, w, h); state.reading = null; state.editing = null; getWorker().catch(() => {}); // start the 11 MB download and engine start-up now, not on Read els.reading.hidden = true; els.run.disabled = false; els.rotate.disabled = false; showQuality(photoWarning(state.image, w, h)); draw(); } /** Show (or clear) the advisory "retake this photo" line. */ function showQuality(text) { if (!els.quality) return; els.quality.textContent = text || ''; els.quality.classList.toggle('warn', !!text); } /** * Advisory read of the photo before OCR: too blurry, dark, glared or small to * read. Returns a sentence to show, or '' when the shot looks usable. Never * blocks a read; it just tells the person to retake if the result looks poor. */ function photoWarning(src, origW, origH) { try { const w = Math.min(240, src.width), h = Math.max(1, Math.round((src.height * w) / src.width)); const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d', { willReadFrequently: true }); g.drawImage(src, 0, 0, w, h); const d = g.getImageData(0, 0, w, h).data; const n = w * h, gray = new Float32Array(n); let sum = 0, bright = 0; for (let i = 0, j = 0; j < n; i += 4, j++) { gray[j] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; sum += gray[j]; if (gray[j] > 250) bright++; } let lapSum = 0, lapSq = 0, count = 0; for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; const lap = 4 * gray[i] - gray[i - 1] - gray[i + 1] - gray[i - w] - gray[i + w]; lapSum += lap; lapSq += lap * lap; count++; } const lapVar = count ? lapSq / count - (lapSum / count) ** 2 : 0; const notes = []; if (Math.min(origW || src.width, origH || src.height) < 500) notes.push('is low resolution'); else if (lapVar < 20) notes.push('looks blurry'); if (sum / n < 45) notes.push('is dark'); if (bright / n > 0.12) notes.push('has glare over the plate'); return notes.length ? `Photo ${notes.join(' and ')}. Retake closer and straight on, without glare, for a better read.` : ''; } catch { return ''; } } function rotate() { const s = state.image; const c = document.createElement('canvas'); c.width = s.height; c.height = s.width; const g = c.getContext('2d'); g.translate(c.width, 0); g.rotate(Math.PI / 2); g.drawImage(s, 0, 0); state.image = c; state.reading = null; els.reading.hidden = true; draw(); } /** A synthetic stamped-metal plate (fictional maker), drawn here so the sample needs no image file. */ function samplePlate(lines) { const c = document.createElement('canvas'); c.width = 1400; c.height = 960; const g = c.getContext('2d'); const bg = g.createLinearGradient(0, 0, c.width, c.height); bg.addColorStop(0, '#d9dcdf'); bg.addColorStop(0.5, '#c4c8cc'); bg.addColorStop(1, '#d2d5d8'); g.fillStyle = '#5b6067'; g.fillRect(0, 0, c.width, c.height); g.fillStyle = bg; roundRect(g, 40, 40, c.width - 80, c.height - 80, 26); g.fill(); for (let y = 60; y < c.height - 60; y += 3) { g.fillStyle = `rgba(255,255,255,${0.05 + 0.05 * Math.sin(y * 0.7)})`; g.fillRect(60, y, c.width - 120, 1); } for (const [x, y] of [[80, 80], [c.width - 80, 80], [80, c.height - 80], [c.width - 80, c.height - 80]]) { g.fillStyle = '#8b9096'; g.beginPath(); g.arc(x, y, 14, 0, Math.PI * 2); g.fill(); } g.fillStyle = '#1b1e22'; g.textBaseline = 'alphabetic'; g.font = 'bold 54px Arial, Helvetica, sans-serif'; g.fillText(lines[0], 120, 175); g.fillRect(120, 200, c.width - 240, 4); g.font = 'bold 38px Arial, Helvetica, sans-serif'; g.fillText(lines[1], 120, 270); g.font = 'bold 44px "Courier New", Courier, monospace'; lines.slice(2).forEach((t, i) => g.fillText(t, 120, 360 + i * 84)); return c; } function roundRect(g, x, y, w, h, r) { g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); } // ------------------------------------------------------------------ OCR let workerPromise = null; function loadScript(src) { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.async = true; s.onload = resolve; s.onerror = () => reject(new Error(`Could not load ${src}`)); document.head.appendChild(s); }); } let onOcrProgress = () => {}; function getWorker() { if (!workerPromise) { workerPromise = (async () => { els.model.textContent = 'Loading OCR…'; if (!window.Tesseract) await loadScript(TESSERACT); const w = await window.Tesseract.createWorker('eng', 1, { logger: m => onOcrProgress(m) }); // Plates are blocks of short lines; keep spacing so "480Y/277 VAC 3 PH" stays on one line. await w.setParameters({ preserve_interword_spaces: '1' }); els.model.textContent = 'OCR ready'; return w; })().catch(e => { workerPromise = null; els.model.textContent = 'OCR unavailable'; throw e; }); } return workerPromise; } /** * Plate photo -> clean grayscale for OCR: * 1. grayscale; * 2. denoise, only when the photo is noisy (median neighbour difference), since blur costs sharp text; * 3. flatten lighting: divide by a heavily blurred copy, which removes glare, shadows and the gradient * across brushed metal and keeps text polarity (dark-on-light and light-on-dark plates both work); * 4. stretch contrast (2nd–98th percentile). * Each step was kept because it raised field accuracy in tests/nameplates-bench.mjs. */ export function preprocess(src) { const w = src.width, h = src.height, n = w * h; const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d', { willReadFrequently: true }); g.drawImage(src, 0, 0); const im = g.getImageData(0, 0, w, h); const d = im.data; let gray = new Float32Array(n); for (let i = 0, j = 0; j < n; i += 4, j++) gray[j] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; const noise = noiseLevel(gray, w, h); if (noise > 4) gray = boxBlur(boxBlur(gray, w, h, noise > 12 ? 2 : 1), w, h, 1); // Lighting estimate: a wide blur. Computing it at half or quarter resolution was tried and cost // accuracy on the hardest photos (tests/nameplates-bench.mjs "phone": 98% -> 93%). const R = Math.max(8, Math.round(Math.max(w, h) / 30)); const bg = boxBlur(boxBlur(gray, w, h, R), w, h, R); const flat = new Float32Array(n); for (let j = 0; j < n; j++) flat[j] = Math.min(255, (gray[j] / (bg[j] + 4)) * 128); const hist = new Uint32Array(256); for (let j = 0; j < n; j++) hist[flat[j] | 0]++; let lo = 0, hi = 255, acc = 0; for (; lo < 255 && (acc += hist[lo]) < n * 0.02; lo++); acc = 0; for (; hi > 0 && (acc += hist[hi]) < n * 0.02; hi--); const span = Math.max(1, hi - lo); for (let i = 0, j = 0; j < n; i += 4, j++) { const v = Math.max(0, Math.min(255, ((flat[j] - lo) * 255) / span)); d[i] = d[i + 1] = d[i + 2] = v; } g.putImageData(im, 0, 0); return deskew(c, d, w, h); } /** * Straighten a plate shot at an angle: the text-line angle is the one whose row projection of * text pixels is most peaked (lines of text in sharp rows, gaps between). Searched ±20° on a * downsampled copy; Tesseract's own rotateAuto handles 90°/180° turns but not this. */ function deskew(c, d, w, h) { const hist = new Uint32Array(256); for (let i = 0; i < d.length; i += 4) hist[d[i]]++; let mode = 0; for (let v = 1; v < 256; v++) if (hist[v] > hist[mode]) mode = v; const step = Math.max(1, Math.round(Math.max(w, h) / 500)); const pts = []; for (let y = 0; y < h; y += step) for (let x = 0; x < w; x += step) if (Math.abs(d[(y * w + x) * 4] - mode) > 90) pts.push(x, y); if (pts.length < 200) return c; const bins = Math.ceil((w + h) / step) + 2; const peakiness = deg => { const t = (deg * Math.PI) / 180, sin = Math.sin(t), cos = Math.cos(t), rows = new Float64Array(bins); for (let i = 0; i < pts.length; i += 2) rows[((pts[i + 1] * cos - pts[i] * sin) / step + w / step) | 0]++; let sum = 0; for (const r of rows) sum += r * r; return sum; }; let best = 0, bestScore = peakiness(0); for (let deg = -20; deg <= 20; deg += 1) { const sc = peakiness(deg); if (sc > bestScore) { bestScore = sc; best = deg; } } for (let deg = best - 0.75; deg <= best + 0.75; deg += 0.25) { const sc = peakiness(deg); if (sc > bestScore) { bestScore = sc; best = deg; } } if (Math.abs(best) < 1) return c; const out = document.createElement('canvas'); out.width = w; out.height = h; const g = out.getContext('2d'); g.fillStyle = `rgb(${mode},${mode},${mode})`; g.fillRect(0, 0, w, h); g.translate(w / 2, h / 2); g.rotate((-best * Math.PI) / 180); g.drawImage(c, -w / 2, -h / 2); out.deskewAngle = best; return out; } /** Median absolute difference between horizontal neighbours on every 3rd row: ~0–2 on a clean photo, 20+ on a grainy one. */ function noiseLevel(gray, w, h) { const hist = new Uint32Array(256); let count = 0; for (let y = 1; y < h; y += 3) for (let x = 0, i = y * w; x < w - 1; x++, i++) { hist[Math.min(255, Math.abs(gray[i] - gray[i + 1]) | 0)]++; count++; } let acc = 0; for (let v = 0; v < 256; v++) if ((acc += hist[v]) >= count / 2) return v; return 0; } /** Separable box blur with running sums, O(n) in the radius. */ function boxBlur(src, w, h, r) { const tmp = new Float32Array(src.length), out = new Float32Array(src.length); for (let y = 0; y < h; y++) { const row = y * w; let sum = 0; for (let x = -r; x <= r; x++) sum += src[row + Math.min(w - 1, Math.max(0, x))]; for (let x = 0; x < w; x++) { tmp[row + x] = sum / (2 * r + 1); sum += src[row + Math.min(w - 1, x + r + 1)] - src[row + Math.max(0, x - r)]; } } for (let x = 0; x < w; x++) { let sum = 0; for (let y = -r; y <= r; y++) sum += tmp[Math.min(h - 1, Math.max(0, y)) * w + x]; for (let y = 0; y < h; y++) { out[y * w + x] = sum / (2 * r + 1); sum += tmp[Math.min(h - 1, y + r + 1) * w + x] - tmp[Math.max(0, y - r) * w + x]; } } return out; } /** * OCR one image into a parsed reading. Pass 1 uses Tesseract's automatic layout (PSM 3). That * occasionally classes a whole tilted plate as a picture and returns no text, and it drops lines * on busy photos, so when pass 1 reads few fields a sparse-text pass (PSM 11) runs and the two * merge field by field, keeping the more confident line. Only weak reads pay for the second pass. */ async function ocrImage(src) { const worker = await getWorker(); const img = preprocess(src); const pass = async psm => { await worker.setParameters({ tessedit_pageseg_mode: psm }); const { data } = await worker.recognize(img, { rotateAuto: true }); return parseNameplate((data.lines || []).map(l => ({ text: l.text, confidence: l.confidence, bbox: l.bbox }))); }; const first = await pass('3'); const reading = Object.keys(first.fields).length >= 6 && first.conf >= 70 ? first : mergeReadings(first, await pass('11')); reading.deskew = img.deskewAngle || 0; // OCR boxes are in the straightened frame; draw() turns them back return reading; } /** Two readings of one plate -> one: per field, the value from the more confident line. */ function mergeReadings(a, b) { const base = Object.keys(b.fields).length > Object.keys(a.fields).length ? b : a; const other = base === a ? b : a; const offset = base.lines.length; const fields = { ...base.fields }; for (const [k, f] of Object.entries(other.fields)) { if (!fields[k] || f.conf > fields[k].conf + 5) fields[k] = { ...f, line: f.line + offset }; } return { ...base, fields, type: base.type || other.type, lines: [...base.lines, ...other.lines] }; } // Hooks for tests/nameplates-bench.mjs: OCR an arbitrary canvas, draw a sample plate. window.__nameplates = { ocrImage, samplePlate, getWorker, fitForOcr, preprocess }; async function read() { if (!state.image) return; els.run.disabled = true; setProgress(0.02); try { setStatus('Loading the OCR engine (first read only)…'); onOcrProgress = m => { if (m.status === 'recognizing text') { setProgress(0.3 + 0.7 * m.progress); setStatus('Reading the plate…'); } else if (/load|initializ/.test(m.status)) { setProgress(0.05 + 0.25 * (m.progress || 0)); setStatus(`${m.status[0].toUpperCase()}${m.status.slice(1)}…`); } }; state.reading = await ocrImage(state.image); setProgress(1); const found = Object.keys(state.reading.fields).length; setStatus(found ? `Read ${found} field${found === 1 ? '' : 's'} at ${state.reading.conf}% average OCR confidence. Check them against the plate.` : 'No nameplate fields were recognised. Try a closer, straighter photo, or rotate it.', !found); rematch(true); } catch (e) { setStatus(`OCR failed: ${e.message}. It needs a network connection the first time.`, true); } finally { els.run.disabled = false; } } // ------------------------------------------------------------------ reading function rematch(pickBest = false) { const r = state.reading; if (!r) return; const items = state.schedule?.items || []; state.candidates = candidatesFor(r, items, state.register); if (pickBest || !items.some(i => i.tag === state.tag)) state.tag = state.candidates[0]?.item.tag || ''; renderReading(); draw(); } function currentChecks() { const item = state.schedule?.items.find(i => i.tag === state.tag); return item ? checkAgainst(state.reading, item) : []; } function renderReading() { const r = state.reading; els.reading.hidden = !r; if (!r) return; els.text.textContent = r.text || '(no text)'; // Fields: grouped, editable, low-confidence ones highlighted. const typeOpts = ['', ...EQUIPMENT_TYPES.map(([k, label]) => ``)].join(''); const fieldInput = ([k, label]) => { const f = r.fields[k]; const low = f && !f.edited && f.conf < LOW_CONF; return `` + ``; }; els.fields.innerHTML = `` + FIELD_GROUPS.map(([g, label]) => { const group = FIELDS.filter(f => (f[2] || 'id') === g); return group.length ? `