← back to Dw Photo Capture
feat: structured field auto-detect (sku#/model#/name/color) + live green highlight + per-vendor spec learning
1ffca172a9faf9f7bf7bbf656f238f17380ff1f2 · 2026-07-06 15:46:07 -0700 · Steve Abrams
- analyzeOcr returns fields{sku,model,name,color,barcode}: classifyFields pulls the human-facing
fields from the label read (Color: label + color-word lexicon, Pattern/Name label, secondary code),
strips a trailing colorway from the name (Compass Clay→Compass). Flows through every engine.
- Live camera: green SKU#/Model#/Name/Color chips light up + fill as each is detected (sticky).
- /api/learn now saves each vendor's colors/names + SKU shape (WDW2310→AAA####) — teaches the reader
each vendor's format; client sends detected name/color on resolve. Barcode stays ground truth.
- +6 tests (85 total, all green). Verified via Gemini: WDW2310/Compass/Clay.
Files touched
M public/index.htmlM server.jsM test-analyze-ocr.js
Diff
commit 1ffca172a9faf9f7bf7bbf656f238f17380ff1f2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 6 15:46:07 2026 -0700
feat: structured field auto-detect (sku#/model#/name/color) + live green highlight + per-vendor spec learning
- analyzeOcr returns fields{sku,model,name,color,barcode}: classifyFields pulls the human-facing
fields from the label read (Color: label + color-word lexicon, Pattern/Name label, secondary code),
strips a trailing colorway from the name (Compass Clay→Compass). Flows through every engine.
- Live camera: green SKU#/Model#/Name/Color chips light up + fill as each is detected (sticky).
- /api/learn now saves each vendor's colors/names + SKU shape (WDW2310→AAA####) — teaches the reader
each vendor's format; client sends detected name/color on resolve. Barcode stays ground truth.
- +6 tests (85 total, all green). Verified via Gemini: WDW2310/Compass/Clay.
---
public/index.html | 32 +++++++++++++++++++++----
server.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++--
test-analyze-ocr.js | 23 ++++++++++++++++++
3 files changed, 116 insertions(+), 6 deletions(-)
diff --git a/public/index.html b/public/index.html
index 2ce750f..272367b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -92,6 +92,12 @@
.scanlive.locked .scan-line{display:none}
.scanlive.locked .scan-status{background:var(--red);border-color:#fff;color:#fff;box-shadow:0 0 18px rgba(192,86,63,.85)}
@keyframes lockpulse{0%{box-shadow:0 0 0 4000px rgba(0,0,0,.45),0 0 0 0 var(--red) inset}100%{box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset}}
+ .scan-fields{position:absolute;left:0;right:0;bottom:calc(84px + env(safe-area-inset-bottom,0px));display:flex;flex-wrap:wrap;gap:6px;justify-content:center;padding:0 12px;z-index:112;pointer-events:none}
+ .sf-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:5px 9px;border-radius:999px;background:rgba(20,20,20,.68);color:#9aa0a6;border:1px solid rgba(255,255,255,.16);backdrop-filter:blur(4px);max-width:46vw;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;transition:background .18s,color .18s,box-shadow .18s}
+ .sf-chip b{font-weight:700;opacity:.6;font-size:10px;letter-spacing:.03em;text-transform:uppercase}
+ .sf-chip i{font-style:normal;font-weight:600;max-width:34vw;overflow:hidden;text-overflow:ellipsis}
+ .sf-chip.on{background:rgba(34,150,70,.94);color:#fff;border-color:#7CFFB0;box-shadow:0 0 10px rgba(46,200,90,.6)}
+ .sf-chip.on b{opacity:.85}
.scan-cancel{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 26px;font-size:15px;font-weight:600;cursor:pointer}
.scan-flip{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:18px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer}
.scan-logo{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 16px;font-size:15px;font-weight:600;cursor:pointer}
@@ -209,6 +215,12 @@
<video id="scanvideo" playsinline muted></video>
<div class="scan-frame"><div class="scan-line"></div></div>
<div class="scan-status" id="scanStatus">Point at the printed SKU…</div>
+ <div class="scan-fields" id="scanFields">
+ <span class="sf-chip" data-k="sku"><b>SKU#</b> <i>—</i></span>
+ <span class="sf-chip" data-k="model"><b>Model#</b> <i>—</i></span>
+ <span class="sf-chip" data-k="name"><b>Name</b> <i>—</i></span>
+ <span class="sf-chip" data-k="color"><b>Color</b> <i>—</i></span>
+ </div>
<button class="scan-flip" id="scanFlip">🔄 Flip camera</button>
<button class="scan-logo" id="scanLogo" title="Identify the brand by its logo / typeface (local AI)">🏷 Logo</button>
<button class="scan-pat" id="scanPat" title="Recognize the PATTERN → find this + similar across the whole catalog (local AI)">🎨 Pattern</button>
@@ -1054,7 +1066,7 @@ async function openLiveScan(){
unlockAudio(); // we're inside the user's tap → unlock audio for the lock-in cue
try{ await startScanStream(); }
catch(e){ toast('Camera blocked — using snap'); return $('#scanInput').click(); }
- _lastTop=null;_lockHits=0;_frontHits=0; $('#scanlive').classList.remove('locked'); $('#scanStatus').textContent='Point at the printed SKU…';
+ _lastTop=null;_lockHits=0;_frontHits=0; resetScanFields(); $('#scanlive').classList.remove('locked'); $('#scanStatus').textContent='Point at the printed SKU…';
$('#scanFlip').textContent=(camFacing==='user'?'🔄 Back camera':'🔄 Front camera');
$('#scanlive').hidden=false; document.body.style.overflow='hidden';
scanTimer=setInterval(scanTick,600);
@@ -1065,7 +1077,18 @@ async function flipCam(){ // toggle back<->front, persist, re-acquire live + r
if(!$('#scanlive').hidden){ try{ await startScanStream(); }catch(e){ toast('Could not switch camera'); } }
else toast(camFacing==='user'?'📷 Front camera':'📷 Back camera');
}
-function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null;} _frontHits=0; $('#scanlive').hidden=true; $('#scanlive').classList.remove('locked'); document.body.style.overflow=''; }
+function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null;} _frontHits=0; resetScanFields(); $('#scanlive').hidden=true; $('#scanlive').classList.remove('locked'); document.body.style.overflow=''; }
+// Live field readout: fill SKU#/Model#/Name/Color and flip each chip GREEN the moment it's detected.
+// Sticky — once a field is read it stays green (a later blank frame won't wipe a good read).
+let _sfSeen={};
+function updateScanFields(f){
+ f=f||{};
+ document.querySelectorAll('#scanFields .sf-chip').forEach(ch=>{
+ const k=ch.getAttribute('data-k'); const v=(f[k]!=null&&String(f[k]).trim())?String(f[k]).trim():(_sfSeen[k]||'');
+ if(v){ _sfSeen[k]=v; ch.classList.add('on'); ch.querySelector('i').textContent=v; }
+ });
+}
+function resetScanFields(){ _sfSeen={}; document.querySelectorAll('#scanFields .sf-chip').forEach(ch=>{ ch.classList.remove('on'); ch.querySelector('i').textContent='—'; }); }
async function ocrPost(dataUrl){ const r=await fetch('/api/ocr',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})}); return r.json(); }
function mirrorDataUrl(dataUrl){ return new Promise((res,rej)=>{ const im=new Image(); im.onload=()=>{ const c=document.createElement('canvas'); c.width=im.naturalWidth; c.height=im.naturalHeight; const x=c.getContext('2d'); x.translate(c.width,0); x.scale(-1,1); x.drawImage(im,0,0); res(c.toDataURL('image/jpeg',0.8)); }; im.onerror=rej; im.src=dataUrl; }); }
// front camera can deliver a MIRRORED frame (iOS) → backwards text reads as garbage. If a front-cam read finds nothing, retry flipped.
@@ -1084,6 +1107,7 @@ async function scanTick(){
const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
const d=await ocrSmart(c.toDataURL('image/jpeg',0.7)); // front-cam mirror-retry built in
+ if(d&&d.fields) updateScanFields(d.fields); // light SKU#/Model#/Name/Color chips green as they're read
if(d&&d.top){
_frontHits=0; // a real code appeared → not the pattern face; cancel any front streak
if(d.top===_lastTop) _lockHits++; else {_lastTop=d.top;_lockHits=1;}
@@ -1171,8 +1195,8 @@ async function applyScan(d, imgDataUrl){
playYoohoo(); // ✓ resolved to a real product — the happy lock
// LEARN: this scan resolved to a known vendor — refine that vendor's sample profile
// (fire-and-forget; server folds in the on-swatch code prefix). See /learn.
- if(hit.vendor){ fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
- body:JSON.stringify({text:(d&&d.text)||'',sku:hit.q,vendor:hit.vendor})}).catch(()=>{}); }
+ if(hit.vendor){ const _f=(d&&d.fields)||{}; fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({text:(d&&d.text)||'',sku:hit.q,vendor:hit.vendor,name:_f.name||null,color:_f.color||null})}).catch(()=>{}); }
$('#q').value=hit.q;
if(hit.via==='any'){ chipTo('any'); toast('✓ '+hit.q+' — '+hit.n+' on TWIL'); doLookup(); }
else { chipTo('shop'); toast('✓ '+hit.q+' — '+hit.n+' in Shopify'); doShopSearch(); }
diff --git a/server.js b/server.js
index d542955..561c3bf 100644
--- a/server.js
+++ b/server.js
@@ -287,6 +287,53 @@ const VENDOR_LEX = [
const STRONG_PFX = /^(GRS-|WD[A-Z]*\d|DWNAT|DW[A-Z]{2})/; // self-evidently a SKU, no brand needed
const CODE_TOKEN = /[A-Z0-9][A-Z0-9-]{2,13}/g; // candidate token shape
+// ── Field vocabulary: classify a label read into sku# / model# / name / color ──
+// Common interior-design colorway names + basic colors, so a printed color name is caught.
+const COLOR_WORDS = 'oatmeal|alabaster|greige|celadon|ecru|taupe|linen|flax|dove|pewter|charcoal|ebony|onyx|ivory|cream|bone|chalk|parchment|sand|camel|fawn|mushroom|mocha|espresso|chocolate|walnut|honey|amber|ochre|mustard|saffron|terracotta|rust|clay|brick|coral|blush|rose|blossom|mauve|plum|aubergine|burgundy|claret|scarlet|crimson|cardinal|sage|celery|moss|fern|olive|forest|emerald|jade|hunter|teal|aqua|turquoise|peacock|cerulean|cobalt|navy|indigo|denim|slate|sky|powder|periwinkle|lavender|lilac|violet|amethyst|gold|brass|bronze|copper|champagne|platinum|silver|graphite|smoke|stone|pearl|white|black|grey|gray|beige|blue|green|red|yellow|orange|purple|pink|brown|tan';
+const COLOR_RE = new RegExp('\\b(' + COLOR_WORDS + ')\\b', 'i');
+const COLOR_LABEL_RE = /colou?r(?:way)?\s*[:#]?\s*([A-Za-z][A-Za-z '\/-]{1,28})/i; // "Color: Oatmeal"
+const NAME_LABEL_RE = /(?:pattern|design|collection|name)\s*[:#]?\s*([A-Za-z][A-Za-z0-9 '\/-]{2,34})/i;
+const CODE_LABEL_RE = /(?:sku|item|model|mfr|product|pattern)\s*(?:#|no\.?|number)?\s*[:#]?\s*([A-Z0-9][A-Z0-9-]{2,15})/i;
+
+// From an OCR read (rows + analyzeOcr result), pull the human-facing fields the UI highlights:
+// sku# (primary code), model#/mfr# (labeled or secondary code), name (pattern/collection), color.
+function classifyFields(rows, a) {
+ const lines = rows.filter(r => !r.bc).map(r => String(r.t).trim()).filter(Boolean);
+ const raw = lines.join('\n');
+ const sku = a.top || null;
+ // color: an explicit "Color: X" wins; else the first known color word anywhere on the label.
+ let color = null;
+ const cl = COLOR_LABEL_RE.exec(raw); if (cl) color = cl[1].trim();
+ if (!color) { const cw = COLOR_RE.exec(raw); if (cw) color = cw[1]; }
+ if (color) color = color.replace(/\s+/g, ' ').trim().replace(/[ ,.;]+$/, '');
+ // model#/mfr#: a labeled code that differs from the SKU, else a distinct secondary code token.
+ let model = null;
+ const clab = CODE_LABEL_RE.exec(raw);
+ if (clab && clab[1].toUpperCase().replace(/\s+/g, '') !== sku) model = clab[1].toUpperCase().replace(/\s+/g, '');
+ if (!model) { const other = (a.candidates || []).find(c => c !== sku); if (other) model = other; }
+ // name: a labeled Pattern/Collection wins; else the longest alpha line that isn't the vendor,
+ // the color, or a code-bearing line (marketing prose is otherwise ignored by the scanner).
+ let name = null;
+ const nl = NAME_LABEL_RE.exec(raw); if (nl) name = nl[1].trim();
+ if (!name) {
+ const vfirst = a.vendor ? a.vendor.toUpperCase().split(/\s+/)[0] : null;
+ name = lines
+ .filter(l => /[A-Za-z]/.test(l) && !/\d{3,}/.test(l) && l.replace(/[^A-Za-z]/g, '').length >= 4)
+ .filter(l => !(vfirst && l.toUpperCase().includes(vfirst)))
+ .filter(l => !(color && l.toLowerCase() === color.toLowerCase())) // drop a line that's ONLY the color
+ .filter(l => !COLOR_LABEL_RE.test(l) && !/^(colou?r|pattern|design|collection|sku|item|model|mfr)\b/i.test(l))
+ .filter(l => !(sku && l.toUpperCase().replace(/\s+/g, '').includes(sku)))
+ .sort((x, y) => y.length - x.length)[0] || null;
+ if (name) {
+ name = name.replace(/\s+/g, ' ').trim().slice(0, 40);
+ // pattern names often embed the colorway ("Compass Clay") — strip a trailing color word.
+ if (color) name = name.replace(new RegExp('\\s*\\b' + escapeRe(color) + '\\b\\s*$', 'i'), '').trim();
+ if (!name) name = null;
+ }
+ }
+ return { sku, model: model || null, name: name || null, color: color || null, barcode: a.barcode || null };
+}
+
// Parse the OCR binary's "<heightThousandths>\t<text>" lines. Height = printed font
// size on the label (taller box = bigger print), which drives "largest letters first".
function parseOcrRows(stdout) {
@@ -351,8 +398,11 @@ function analyzeOcr(rows) {
else if (codes.length && (vendor || lineCount >= 2)) { side = 'back'; sideConf = 0.9; sideReason = 'SKU code + ' + (vendor ? 'vendor name' : 'printed text'); }
else if (lineCount >= 3 || textLen >= 24) { side = 'back'; sideConf = 0.72; sideReason = 'dense printed text'; }
else if (!codes.length && lineCount <= 1 && textLen < 8) { side = 'front'; sideConf = 0.8; sideReason = 'little/no printed text (pattern face)'; }
- return { text, vendor, candidates, top, topStrong, barcode: barcodes[0] || null,
+ const out = { text, vendor, candidates, top, topStrong, barcode: barcodes[0] || null,
side, side_confidence: sideConf, side_reason: sideReason };
+ // Human-facing fields the live camera highlights in green as they're detected.
+ out.fields = classifyFields(rows, out);
+ return out;
}
@@ -765,10 +815,23 @@ const appHandler = (req, res) => {
if (pre) prof.prefixes[pre] = (prof.prefixes[pre] || 0) + 1;
else prof.numeric_share = Math.min(1, (prof.numeric_share || 0) + 0.05);
if (!prof.examples.includes(sku) && prof.examples.length < 8) prof.examples.push(sku);
+ // Learn the vendor's SKU SHAPE (WDW2310 → AAA####) so we recognize their code format on sight.
+ const shape = sku.replace(/[A-Z]/g, 'A').replace(/[0-9]/g, '#');
+ prof.sku_shapes = prof.sku_shapes || {}; prof.sku_shapes[shape] = (prof.sku_shapes[shape] || 0) + 1;
+ // Save the vendor "specs" — the colorways + pattern names seen, so the reader learns each
+ // vendor's vocabulary (bounded lists; deduped, case-normalized).
+ const addTo = (key, val, cap) => {
+ val = String(val || '').replace(/\s+/g, ' ').trim().slice(0, 40);
+ if (!val) return; prof[key] = prof[key] || [];
+ if (!prof[key].some(x => x.toLowerCase() === val.toLowerCase()) && prof[key].length < cap) prof[key].push(val);
+ };
+ if (p.color) addTo('colors', p.color, 40);
+ if (p.name) addTo('names', p.name, 60);
prof.learned_scans = (prof.learned_scans || 0) + 1;
prof.updated_at = new Date().toISOString();
saveVendorProfiles(); rebuildLexicon();
- return send(res, 200, { ok: true, vendor, learned_prefix: pre || null, learned_scans: prof.learned_scans });
+ return send(res, 200, { ok: true, vendor, learned_prefix: pre || null, learned_scans: prof.learned_scans,
+ sku_shape: shape, colors: (prof.colors || []).length, names: (prof.names || []).length });
});
return;
}
diff --git a/test-analyze-ocr.js b/test-analyze-ocr.js
index a4cddd9..f7c17fe 100644
--- a/test-analyze-ocr.js
+++ b/test-analyze-ocr.js
@@ -35,7 +35,13 @@ const moduleSrc = [
sliceConst('VENDOR_LEX'),
sliceConst('STRONG_PFX'),
sliceConst('CODE_TOKEN'),
+ sliceConst('COLOR_WORDS'),
+ sliceConst('COLOR_RE'),
+ sliceConst('COLOR_LABEL_RE'),
+ sliceConst('NAME_LABEL_RE'),
+ sliceConst('CODE_LABEL_RE'),
'const LEXICON = VENDOR_LEX;',
+ sliceFn('classifyFields'),
sliceFn('parseOcrRows'),
sliceFn('analyzeOcr'),
sliceFn('_lev'),
@@ -111,6 +117,23 @@ const bc = (payload, sym = 'Code128') => `BC\t${payload}\t${sym}`;
eq(sparse.side, 'front', 'side: one tiny token (selvedge) → front');
}
+// ── analyzeOcr.fields: classify sku# / model# / name / color from a label read ──
+{
+ const d = analyzeOcr(parseOcrRows([line(46, 'WINFIELD THYBONY'), line(150, 'WDW2310'), line(30, 'Compass'), line(28, 'Color: Oatmeal')].join('\n')));
+ eq(d.fields.sku, 'WDW2310', 'fields: sku = top code');
+ eq(d.fields.color, 'Oatmeal', 'fields: color from "Color:" label');
+ eq(d.fields.name, 'Compass', 'fields: name = pattern prose (not vendor/color/code)');
+
+ // bare color word (no label) is still caught; barcode surfaces as a field
+ const d2 = analyzeOcr(parseOcrRows([line(120, 'GRS-26820'), line(30, 'Sage'), bc('012345678')].join('\n')));
+ eq(d2.fields.color, 'Sage', 'fields: color from a known color word');
+ eq(d2.fields.barcode, '012345678', 'fields: barcode surfaced as a field');
+
+ // no printed color → color null (no false positive)
+ const d3 = analyzeOcr(parseOcrRows(line(120, 'WDW9999')));
+ eq(d3.fields.color, null, 'fields: no color word → color null');
+}
+
// ── analyzeOcr: no brand, bare GRS- code still strong via prefix ──
{
const d = analyzeOcr(parseOcrRows([line(120, 'GRS-26820')].join('\n')));
← 761b356 feat: front side auto-matches the design — hold steady on th
·
back to Dw Photo Capture
·
fix: dw_unified 'match design' works on Kamatera — drop non- 9277da4 →