Download nameplates-app.js from constructelligence/data-centre-nameplates: direct link, hf CLI and curl.
- Browser
- Download file 37.4 kB
-
https://huggingface.co/spaces/constructelligence/data-centre-nameplates/resolve/main/nameplates-app.js
- Command line
-
hf download hf://spaces/constructelligence/data-centre-nameplates/nameplates-app.js
-
curl -L -o nameplates-app.js https://huggingface.co/spaces/constructelligence/data-centre-nameplates/resolve/main/nameplates-app.js
37.4 kB
| // 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 = ['<option value="">Unknown</option>', ...EQUIPMENT_TYPES.map(([k, label]) => `<option value="${k}"${r.type === k ? ' selected' : ''}>${esc(label)}</option>`)].join(''); | |
| const fieldInput = ([k, label]) => { | |
| const f = r.fields[k]; | |
| const low = f && !f.edited && f.conf < LOW_CONF; | |
| return `<label for="npf-${k}"><span class="np-key" style="background:${f ? COLORS[k] : 'transparent'}"></span>${esc(label)}</label>` + | |
| `<input id="npf-${k}" data-key="${k}" value="${esc(f?.value ?? '')}" ${low ? `class="low" title="OCR confidence ${f.conf}%: check against the plate"` : ''} autocomplete="off" spellcheck="false" />`; | |
| }; | |
| els.fields.innerHTML = `<label for="npf-type">Type</label><select id="npf-type">${typeOpts}</select>` + | |
| FIELD_GROUPS.map(([g, label]) => { | |
| const group = FIELDS.filter(f => (f[2] || 'id') === g); | |
| return group.length ? `<div class="np-group">${esc(label)}</div>${group.map(fieldInput).join('')}` : ''; | |
| }).join(''); | |
| // Tag picker: likely tags first, then the rest of the schedule. | |
| const items = state.schedule?.items || []; | |
| const cand = new Set(state.candidates.map(c => c.item.tag)); | |
| const opt = (i, extra = '') => `<option value="${esc(i.tag)}"${i.tag === state.tag ? ' selected' : ''}>${esc(i.tag)}${i.model ? ` · ${esc(i.model)}` : ''}${extra}</option>`; | |
| els.tag.innerHTML = `<option value=""${state.tag ? '' : ' selected'}>Not on the schedule</option>` + | |
| (state.candidates.length ? `<optgroup label="Likely matches">${state.candidates.map(c => opt(c.item, c.registered ? ' (already scanned)' : '')).join('')}</optgroup>` : '') + | |
| (items.length > cand.size ? `<optgroup label="All scheduled items">${items.filter(i => !cand.has(i.tag)).map(i => opt(i)).join('')}</optgroup>` : ''); | |
| els.tag.disabled = !items.length; | |
| const same = state.candidates.filter(c => c.score === state.candidates[0]?.score).length; | |
| els.tagNote.textContent = !items.length ? 'Load an equipment schedule to check this plate.' | |
| : !state.candidates.length ? 'No scheduled item looks like this plate.' | |
| : same > 1 ? `${same} scheduled items share this model. Picked the first not yet scanned; change it to the tag on site.` | |
| : ''; | |
| if (state.register.some(x => x.tag && x.tag === state.tag)) els.tagNote.textContent += ` ${state.tag} is already on the register; adding replaces it.`; | |
| renderChecks(); | |
| } | |
| function renderChecks() { | |
| const checks = currentChecks(); | |
| const status = statusOf(checks, state.tag); | |
| els.checks.innerHTML = checks.map(c => `<li class="${c.status}"><span>${c.status === 'ok' ? '✓' : c.status === 'mismatch' ? '✗' : '–'}</span>` + | |
| `<span><b>${esc(c.label)}</b> ${c.status === 'ok' ? esc(c.found) : c.status === 'mismatch' ? `schedule ${esc(c.expected)}, plate ${esc(c.found)}` : `not read (schedule ${esc(c.expected)})`}</span></li>`).join(''); | |
| const bad = checks.filter(c => c.status === 'mismatch').map(c => inSentence(c.label)); | |
| els.verdict.className = `np-verdict ${status}`; | |
| els.verdict.textContent = status === 'verified' ? `Matches ${state.tag} on every scheduled field.` | |
| : status === 'mismatch' ? `Does not match ${state.tag}: ${bad.join(', ')}. Hold before install.` | |
| : status === 'partial' ? `Matches ${state.tag} so far; some scheduled fields were not read.` | |
| : state.schedule ? 'Not matched to the schedule.' : 'Read only: no schedule loaded to check against.'; | |
| } | |
| function editField(key, raw) { | |
| const r = state.reading; | |
| const v = raw.trim(); | |
| if (!v) delete r.fields[key]; | |
| else if (key === 'voltage') r.fields.voltage = { value: v, conf: 100, edited: true, values: voltagesIn(v.toUpperCase()).values.length ? voltagesIn(v.toUpperCase()).values : (v.match(/\d{3,5}/g) || []).map(Number) }; | |
| else r.fields[key] = { value: NUMERIC.has(key) ? (parseFloat(v.replace(',', '')) || v) : v, conf: 100, edited: true, line: r.fields[key]?.line }; | |
| $(`npf-${key}`)?.classList.remove('low'); | |
| if (key === 'model' || key === 'serial' || key === 'manufacturer') { | |
| state.candidates = candidatesFor(r, state.schedule?.items || [], state.register); | |
| } | |
| renderChecks(); | |
| } | |
| // ------------------------------------------------------------------ canvas | |
| function draw() { | |
| const c = els.canvas, s = state.image; | |
| els.empty.hidden = !!s; | |
| c.hidden = !s; | |
| if (!s) return; | |
| c.width = s.width; c.height = s.height; | |
| const g = c.getContext('2d'); | |
| g.drawImage(s, 0, 0); | |
| const r = state.reading; | |
| if (!r) return; | |
| // Outline each line a field came from, labelled with every field read off it (voltage, phase | |
| // and Hz often share one line). Boxes are in the straightened frame OCR saw: rotate them back. | |
| const byLine = new Map(); | |
| for (const [k, f] of Object.entries(r.fields)) { | |
| if (f.edited || !r.lines[f.line]?.bbox) continue; | |
| byLine.set(f.line, [...(byLine.get(f.line) || []), k]); | |
| } | |
| const lw = Math.max(2, s.width / 450), fs = Math.round(Math.max(14, s.width / 60)), pad = fs * 0.3; | |
| g.save(); | |
| g.translate(s.width / 2, s.height / 2); | |
| g.rotate(((r.deskew || 0) * Math.PI) / 180); | |
| g.translate(-s.width / 2, -s.height / 2); | |
| g.font = `600 ${fs}px system-ui, sans-serif`; | |
| g.textBaseline = 'middle'; | |
| for (const [line, keys] of byLine) { | |
| const b = r.lines[line].bbox; | |
| g.strokeStyle = COLORS[keys[0]]; g.lineWidth = lw; | |
| g.strokeRect(b.x0 - 4, b.y0 - 4, b.x1 - b.x0 + 8, b.y1 - b.y0 + 8); | |
| let x = b.x0 - 4; | |
| for (const k of keys) { | |
| const label = FIELDS.find(f => f[0] === k)?.[1] || k; | |
| const tw = g.measureText(label).width + pad * 2, th = fs + pad * 1.4, y = Math.max(0, b.y0 - 4 - th); | |
| g.fillStyle = COLORS[k]; g.fillRect(x, y, tw, th); | |
| g.fillStyle = '#fff'; g.fillText(label, x + pad, y + th / 2); | |
| x += tw + 3; | |
| } | |
| } | |
| g.restore(); | |
| } | |
| // ------------------------------------------------------------------ register | |
| function addToRegister({ quiet = false } = {}) { | |
| const r = state.reading; | |
| if (!r) return; | |
| const checks = currentChecks(); | |
| const prev = state.editing && state.register.find(x => x.id === state.editing); | |
| const entry = { | |
| id: prev?.id || `np-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, | |
| tag: state.tag, type: $('npf-type')?.value ?? r.type ?? '', fields: structuredClone(r.fields), | |
| checks, status: statusOf(checks, state.tag), thumb: prev?.thumb || thumbnail(state.image), | |
| scannedAt: prev?.scannedAt || Date.now(), source: r.source || prev?.source || '', | |
| // Fields OCR was unsure of and nobody has corrected: listed on the register for a second look. | |
| lowConf: Object.entries(r.fields).filter(([, f]) => !f.edited && f.conf < LOW_CONF).map(([k, f]) => `${k}:${f.conf}`), | |
| }; | |
| for (const f of Object.values(entry.fields)) { delete f.line; } | |
| state.photos.set(entry.id, { image: state.image, reading: structuredClone(r) }); | |
| const at = prev ? state.register.indexOf(prev) : -1; | |
| state.register = state.register.filter(x => x.id !== entry.id && !(entry.tag && x.tag === entry.tag)); | |
| if (at >= 0) state.register.splice(Math.min(at, state.register.length), 0, entry); | |
| else state.register.push(entry); | |
| save(); | |
| renderRegister(); | |
| if (!quiet) setStatus(`${entry.tag || 'Unmatched plate'} ${prev ? 'updated on' : 'added to'} the register (${STATUS_LABELS[entry.status].toLowerCase()}).`); | |
| state.editing = null; | |
| els.add.textContent = 'Add to register'; | |
| state.reading = null; | |
| els.reading.hidden = true; | |
| draw(); | |
| } | |
| /** Open a register entry for review: its photo and reading if read this session, else its stored fields over the thumbnail. */ | |
| async function review(id) { | |
| const entry = state.register.find(x => x.id === id); | |
| if (!entry) return; | |
| const photo = state.photos.get(id); | |
| if (photo) { | |
| state.image = photo.image; | |
| state.reading = structuredClone(photo.reading); | |
| state.reading.fields = structuredClone(entry.fields); | |
| for (const [k, f] of Object.entries(state.reading.fields)) f.line = photo.reading.fields[k]?.line; | |
| } else { | |
| const img = new Image(); | |
| img.src = entry.thumb; | |
| try { await img.decode(); state.image = fitForOcr(img, img.naturalWidth, img.naturalHeight); } catch { state.image = null; } | |
| state.reading = { fields: structuredClone(entry.fields), type: entry.type, conf: 100, text: '', lines: [] }; | |
| } | |
| state.reading.type = entry.type || state.reading.type; | |
| state.editing = id; | |
| state.candidates = candidatesFor(state.reading, state.schedule?.items || [], state.register.filter(x => x.id !== id)); | |
| state.tag = entry.tag; | |
| els.add.textContent = 'Update register entry'; | |
| els.run.disabled = !state.image; | |
| els.rotate.disabled = !state.image; | |
| renderReading(); | |
| draw(); | |
| renderRegister(); | |
| els.reading.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); | |
| setStatus(`Reviewing ${entry.tag || 'an unmatched plate'}. Correct any field, then update the entry.`); | |
| } | |
| function thumbnail(src) { | |
| const c = document.createElement('canvas'); | |
| const s = 240 / Math.max(src.width, src.height); | |
| c.width = Math.round(src.width * s); c.height = Math.round(src.height * s); | |
| c.getContext('2d').drawImage(src, 0, 0, c.width, c.height); | |
| return c.toDataURL('image/jpeg', 0.7); | |
| } | |
| function renderRegister() { | |
| const items = state.schedule?.items || []; | |
| const sum = registerSummary(state.register, items); | |
| const dups = new Set(duplicateSerials(state.register).flat()); | |
| els.summary.innerHTML = state.register.length || items.length ? [ | |
| items.length ? `<span><b>${sum.scannedOfSchedule}</b> of ${items.length} scheduled scanned</span>` : `<span><b>${sum.scanned}</b> scanned</span>`, | |
| `<span><b>${sum.verified}</b> verified</span>`, | |
| `<span style="color:${sum.flagged ? 'var(--danger)' : 'inherit'}"><b>${sum.flagged}</b> flagged</span>`, | |
| sum.unmatched ? `<span><b>${sum.unmatched}</b> not on schedule</span>` : '', | |
| sum.duplicates ? `<span style="color:var(--danger)"><b>${sum.duplicates}</b> duplicate serial${sum.duplicates === 1 ? '' : 's'}</span>` : '', | |
| ].join('') : ''; | |
| // Filter and search the register: a walk can be hundreds of tags. | |
| const q = state.photoQuery.trim().toUpperCase(); | |
| const shown = state.register.filter(r => { | |
| if (state.photoFilter !== 'all' && r.status !== state.photoFilter) return false; | |
| if (!q) return true; | |
| return [r.tag, typeLabel(r.type), r.fields?.model?.value, r.fields?.serial?.value, r.fields?.manufacturer?.value, r.source] | |
| .join(' ').toUpperCase().includes(q); | |
| }); | |
| els.rows.innerHTML = shown.map(r => { | |
| const issues = r.checks.filter(c => c.status === 'mismatch').map(c => `${c.label}: schedule ${c.expected}, plate ${c.found}`); | |
| if (dups.has(r.id)) issues.push('Serial also on another scanned plate'); | |
| if (r.lowConf?.length) issues.push(`Check ${r.lowConf.map(x => { const [k, c] = x.split(':'); return `${inSentence((FIELDS.find(f => f[0] === k) || [k, k])[1])} (OCR ${c}%)`; }).join(', ')}`); | |
| return `<tr data-id="${r.id}" class="${r.id === state.editing ? 'sel' : ''}" tabindex="0" title="Review this plate"><td class="np-hide-sm">${r.thumb ? `<img src="${r.thumb}" alt="">` : ''}</td>` + | |
| `<td><b>${esc(r.tag || '—')}</b><br><span class="np-note">${esc(typeLabel(r.type))}</span></td>` + | |
| `<td><span class="np-chip ${r.status}">${esc(STATUS_LABELS[r.status])}</span></td>` + | |
| `<td>${esc(r.fields.model?.value || '')}</td><td class="np-hide-sm">${esc(r.fields.serial?.value || '')}</td>` + | |
| `<td class="np-hide-sm">${esc(r.fields.voltage?.value || '')}</td><td>${esc(issues.join('; '))}</td>` + | |
| `<td><button class="np-btn link" type="button" data-remove="${r.id}" aria-label="Remove ${esc(r.tag || 'entry')}">✕</button></td></tr>`; | |
| }).join(''); | |
| els.emptyReg.hidden = state.register.length > 0; | |
| if (els.emptyReg) els.emptyReg.textContent = state.register.length && !shown.length | |
| ? 'No register entries match the filter.' | |
| : 'Nothing scanned yet. Each plate you add appears here with its check result.'; | |
| if (els.filter) els.filter.value = state.photoFilter; | |
| els.csv.disabled = !state.register.length && !items.length; | |
| els.clearReg.disabled = !state.register.length; | |
| els.missing.textContent = items.length && sum.missing.length && state.register.length | |
| ? `Not scanned yet (${sum.missing.length}): ${sum.missing.slice(0, 24).join(', ')}${sum.missing.length > 24 ? '…' : ''}` + | |
| (shown.length !== state.register.length ? ` Showing ${shown.length} of ${state.register.length} scanned.` : '') : ''; | |
| } | |
| function download(name, text, type) { | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(new Blob([text], { type })); | |
| a.download = name; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 1000); | |
| } | |
| // ------------------------------------------------------------------ wiring | |
| function dropZone(zone, input, onFile, onFiles) { | |
| const take = list => { const files = [...list]; if (files.length > 1 && onFiles) onFiles(files); else if (files[0]) onFile(files[0]); }; | |
| input.addEventListener('change', () => { take(input.files); input.value = ''; }); | |
| zone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } }); | |
| zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('over'); }); | |
| zone.addEventListener('dragleave', () => zone.classList.remove('over')); | |
| zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('over'); take(e.dataTransfer.files); }); | |
| } | |
| dropZone(els.schedDrop, els.schedFile, async f => setSchedule(await f.text(), f.name)); | |
| dropZone(els.drop, els.file, loadImageFile, files => readBatch(files.filter(f => /^image\//.test(f.type) || /\.(jpe?g|png|webp|heic|gif|bmp)$/i.test(f.name)))); | |
| els.schedSample.addEventListener('click', () => setSchedule(SAMPLE_SCHEDULE, 'Sample schedule')); | |
| els.schedClear.addEventListener('click', () => { state.schedule = null; save(); renderSchedule(); rematch(); renderRegister(); }); | |
| els.sample.addEventListener('click', () => { | |
| const c = samplePlate(SAMPLE_PLATES[state.sample % SAMPLE_PLATES.length]); | |
| state.sample++; | |
| setImage(c, c.width, c.height); | |
| setStatus(`Sample plate ${((state.sample - 1) % SAMPLE_PLATES.length) + 1} of ${SAMPLE_PLATES.length} loaded (a drawn plate, fictional maker). Read it when ready.`); | |
| }); | |
| els.rotate.addEventListener('click', rotate); | |
| els.run.addEventListener('click', read); | |
| els.add.addEventListener('click', () => addToRegister()); | |
| els.tag.addEventListener('change', () => { state.tag = els.tag.value; renderReading(); }); | |
| els.fields.addEventListener('change', e => { | |
| if (e.target.id === 'npf-type') { state.reading.type = e.target.value || null; state.candidates = candidatesFor(state.reading, state.schedule?.items || [], state.register); renderReading(); } | |
| else if (e.target.dataset.key) editField(e.target.dataset.key, e.target.value); | |
| }); | |
| els.rows.addEventListener('click', e => { | |
| const id = e.target.closest('[data-remove]')?.dataset.remove; | |
| if (id) { | |
| state.register = state.register.filter(r => r.id !== id); | |
| state.photos.delete(id); | |
| if (state.editing === id) { state.editing = null; state.reading = null; els.reading.hidden = true; els.add.textContent = 'Add to register'; } | |
| save(); | |
| renderRegister(); | |
| return; | |
| } | |
| const row = e.target.closest('tr[data-id]'); | |
| if (row) review(row.dataset.id); | |
| }); | |
| els.rows.addEventListener('keydown', e => { | |
| const row = e.target.closest('tr[data-id]'); | |
| if (row && (e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) { e.preventDefault(); review(row.dataset.id); } | |
| }); | |
| els.clearReg.addEventListener('click', () => { | |
| if (!confirm('Remove every scanned plate from the register?')) return; | |
| state.register = []; | |
| save(); | |
| renderRegister(); | |
| }); | |
| els.search?.addEventListener('input', () => { state.photoQuery = els.search.value; renderRegister(); }); | |
| els.filter?.addEventListener('change', () => { state.photoFilter = els.filter.value; renderRegister(); }); | |
| els.csv.addEventListener('click', () => download('asset-register.csv', registerCsv(state.register, state.schedule?.items || []), 'text/csv')); | |
| load(); | |
| renderSchedule(); | |
| renderRegister(); | |