[object Object]

← 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

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 →