[object Object]

← back to Dw Photo Capture

feat: front+back identify UI + QR/vendor clues. #fbModal captures back label + front pattern; client jsQR decodes QR silently (extracts SKU token, never navigates); server fuses code+QR+vendor+visual. QR token filter requires a digit (skips domain words). Vendor name off the label used to weight visual + fill primary. Verified: QR URL→TR2581→RHT264/Seabrook.

ac1d6d0356fdda300354c5d81d667c90fdcb5129 · 2026-07-06 19:06:39 -0700 · Steve Abrams

Files touched

Diff

commit ac1d6d0356fdda300354c5d81d667c90fdcb5129
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 19:06:39 2026 -0700

    feat: front+back identify UI + QR/vendor clues. #fbModal captures back label + front pattern; client jsQR decodes QR silently (extracts SKU token, never navigates); server fuses code+QR+vendor+visual. QR token filter requires a digit (skips domain words). Vendor name off the label used to weight visual + fill primary. Verified: QR URL→TR2581→RHT264/Seabrook.
---
 public/index.html | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 21 ++++++++++++++++---
 2 files changed, 81 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index 819f15b..b6f532c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -146,6 +146,15 @@
   .samp-print.samp-addr{background:#2d5b86}
   .samp-print:disabled{opacity:.5;cursor:default}
   .samp-note{font-size:12px;color:#9a917d;margin-top:10px;text-align:center;min-height:14px}
+  .fb-tiles{display:flex;gap:12px;margin-bottom:14px}
+  .fb-tile{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;background:#0f0e0b;border:1px dashed var(--line);border-radius:14px;padding:12px 6px;color:#b7ad98;font-size:13px;cursor:pointer}
+  .fb-tile.set{border-style:solid;border-color:#2b7a3f;color:#fff}
+  .fb-thumb{width:100%;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:30px}
+  #fbResult{margin-top:12px}
+  .fb-hit{display:flex;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:12px;margin-top:8px}
+  .fb-hit img{width:54px;height:54px;object-fit:cover;border-radius:8px;background:#000;flex-shrink:0}
+  .fb-conf{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;margin-left:6px}
+  .fb-conf.veryhigh,.fb-conf.high{background:#2b7a3f;color:#fff}.fb-conf.medium{background:#b8902f;color:#fff}.fb-conf.low{background:#7a3b2b;color:#fff}
   .ed-panel{flex:0 0 auto;background:var(--card);border-top:1px solid var(--line);padding:12px 14px max(14px,env(safe-area-inset-bottom));max-height:54vh;overflow-y:auto}
   .ed-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
   .ed-head .t{font:600 13px/1 "SF Pro Display",sans-serif;letter-spacing:.05em;text-transform:uppercase;color:var(--gold)}
@@ -200,8 +209,11 @@
     <button class="scanbtn" id="micBtn" title="Voice search — say a vendor, SKU, or what to look for">🎤</button>
     <button class="scanbtn" id="camBtn" title="Choose camera (back / front)">📷 Back</button>
     <button class="scanbtn" id="simBtn" title="Photograph a pattern/swatch → find this + similar across the whole catalog">🎨 Similar</button>
+    <button class="scanbtn" id="fbBtn" title="Identify by photos of the BACK label + FRONT pattern (best ID: code + visual)">🔎 ID F+B</button>
     <input type="file" accept="image/*" capture="environment" id="scanInput" hidden>
     <input type="file" accept="image/*" capture="environment" id="simInput" hidden>
+    <input type="file" accept="image/*" capture="environment" id="fbBackInput" hidden>
+    <input type="file" accept="image/*" capture="environment" id="fbFrontInput" hidden>
     <select id="sort">
       <option value="ready">Priced first</option>
       <option value="title">Title A→Z</option>
@@ -325,6 +337,22 @@
     <div class="samp-note" id="sampNote"></div>
   </div>
 </div>
+
+<!-- front + back identify: fuse back-label code + front-pattern visual match -->
+<div class="samp" id="fbModal" hidden>
+  <div class="samp-card">
+    <button class="samp-x" id="fbClose">✕</button>
+    <div class="samp-pat" style="margin-bottom:4px">Identify a sample</div>
+    <div class="samp-meta" style="margin-bottom:12px">Photograph the BACK label, the FRONT pattern, or both — best ID uses both.</div>
+    <div class="fb-tiles">
+      <button class="fb-tile" id="fbBackTile"><div class="fb-thumb" id="fbBackThumb">📄</div><span>Back · label</span></button>
+      <button class="fb-tile" id="fbFrontTile"><div class="fb-thumb" id="fbFrontThumb">🎨</div><span>Front · pattern</span></button>
+    </div>
+    <button class="samp-print" id="fbGo" disabled>🔎 Identify</button>
+    <div class="samp-note" id="fbNote"></div>
+    <div id="fbResult"></div>
+  </div>
+</div>
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -1306,6 +1334,41 @@ async function checkSampleRequest(code,mfr){
 $('#sampClose').addEventListener('click',closeSampleModal);
 $('#sampPrintSku').addEventListener('click',()=>sampPrint('sku'));
 $('#sampPrintAddr').addEventListener('click',()=>sampPrint('address'));
+
+// ── Front + Back identify: fuse back-label code + vendor + QR (client-decoded) with front visual ──
+let _fbBack=null,_fbFront=null,_fbQR=null,_jsqr=null;
+function loadJsQR(){ if(_jsqr) return Promise.resolve(_jsqr); return new Promise((res)=>{ const s=document.createElement('script'); s.src='https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'; s.onload=()=>{_jsqr=window.jsQR;res(_jsqr);}; s.onerror=()=>res(null); document.head.appendChild(s); }); }
+async function decodeQR(dataUrl){ const jsQR=await loadJsQR(); if(!jsQR) return null;
+  return new Promise(r=>{ const im=new Image(); im.onload=()=>{ try{ const c=document.createElement('canvas'); c.width=im.naturalWidth; c.height=im.naturalHeight; const x=c.getContext('2d'); x.drawImage(im,0,0); const d=x.getImageData(0,0,c.width,c.height); const q=jsQR(d.data,c.width,c.height); r(q?q.data:null);}catch(e){r(null);} }; im.onerror=()=>r(null); im.src=dataUrl; }); }
+async function fbRead(file,isBack){ if(!file)return;
+  const url=await downscale(file,1400);
+  if(isBack){ _fbBack=url; const t=$('#fbBackThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbBackTile').classList.add('set');
+    $('#fbNote').textContent='Reading label + QR…'; _fbQR=await decodeQR(url).catch(()=>null); $('#fbNote').textContent=_fbQR?'🔗 QR code read':''; }
+  else { _fbFront=url; const t=$('#fbFrontThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbFrontTile').classList.add('set'); }
+  $('#fbGo').disabled=!(_fbBack||_fbFront);
+}
+function fbVisualHtml(vis){ if(!vis||!vis.length) return ''; return '<div class="samp-meta" style="margin-top:12px">Visual matches:</div>'+vis.slice(0,4).map(v=>`<div class="fb-hit">${v.image?`<img src="${v.image}">`:''}<div><b>${v.pattern||v.dw_sku||''}</b> <small>${Math.round((v.score||0)*100)}%</small><br><small>${[v.vendor,v.dw_sku].filter(Boolean).join(' · ')}</small></div></div>`).join(''); }
+async function fbIdentify(){
+  $('#fbGo').disabled=true; $('#fbNote').textContent='Identifying…'; $('#fbResult').innerHTML='';
+  try{
+    const r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({back:_fbBack,front:_fbFront,qr:_fbQR})})).json();
+    if(!r.found){ $('#fbNote').textContent='No confident match — try a clearer/closer photo.'; $('#fbResult').innerHTML=fbVisualHtml(r.visual); $('#fbGo').disabled=false; return; }
+    const p=r.primary, conf=(r.confidence||'').replace('-','');
+    const via=r.source==='qr'?'🔗 QR':r.source==='code'?'📄 label code':'🎨 visual';
+    $('#fbNote').innerHTML=`Source: ${via}${r.corroborated?' · ✓ front+back agree':''}`;
+    $('#fbResult').innerHTML=`<div class="fb-hit">${p.image?`<img src="${p.image}">`:''}<div><b>${p.pattern||p.mfr_sku||p.dw_sku||'—'}</b><span class="fb-conf ${conf}">${r.confidence}</span><br><small>${[p.vendor,p.dw_sku,p.mfr_code].filter(Boolean).join(' · ')}</small></div></div>`+fbVisualHtml(r.visual);
+    checkSampleRequest(p.dw_sku||p.mfr_code, p.mfr_code);   // → who ordered this sample (opens the print popup)
+  }catch(e){ $('#fbNote').textContent='Failed: '+e.message; }
+  $('#fbGo').disabled=false;
+}
+function openFbModal(){ _fbBack=_fbFront=_fbQR=null; $('#fbBackThumb').style.backgroundImage=''; $('#fbBackThumb').textContent='📄'; $('#fbFrontThumb').style.backgroundImage=''; $('#fbFrontThumb').textContent='🎨'; $('#fbBackTile').classList.remove('set'); $('#fbFrontTile').classList.remove('set'); $('#fbGo').disabled=true; $('#fbNote').textContent=''; $('#fbResult').innerHTML=''; $('#fbModal').hidden=false; document.body.style.overflow='hidden'; }
+$('#fbBtn').addEventListener('click',openFbModal);
+$('#fbClose').addEventListener('click',()=>{ $('#fbModal').hidden=true; document.body.style.overflow=''; });
+$('#fbBackTile').addEventListener('click',()=>$('#fbBackInput').click());
+$('#fbFrontTile').addEventListener('click',()=>$('#fbFrontInput').click());
+$('#fbBackInput').addEventListener('change',e=>fbRead(e.target.files[0],true));
+$('#fbFrontInput').addEventListener('change',e=>fbRead(e.target.files[0],false));
+$('#fbGo').addEventListener('click',fbIdentify);
 $('#scanvideo').addEventListener('click',resumeScan);   // tap the paused overlay to resume scanning
 $('#scanStatus').addEventListener('click',resumeScan);
 $('#scanCancel').addEventListener('click',closeLiveScan);
diff --git a/server.js b/server.js
index 1a2470f..46150c6 100644
--- a/server.js
+++ b/server.js
@@ -1123,22 +1123,37 @@ const appHandler = (req, res) => {
           const cands = [backOcr && backOcr.barcode, backOcr && backOcr.top, ...((backOcr && backOcr.candidates) || [])].filter(Boolean);
           for (const c of cands) { codeId = await identifyUnified(c).catch(() => null); if (codeId) { code = c; break; } }
         }
+        // QR clue (client-decoded): a raw code OR a URL. We parse code tokens out and resolve them —
+        // "open silent": we read the QR's payload for identity, we never navigate to it.
+        let qrUsed = false;
+        if (!codeId && p.qr) {
+          // pull SKU-shaped tokens (must contain a digit) so URL/domain words (SHOP, EXAMPLE, COM) don't false-match
+          const toks = (String(p.qr).toUpperCase().match(/[A-Z0-9][A-Z0-9-]{3,15}/g) || []).filter(t => /\d/.test(t) && !/^\d{1,3}$/.test(t));
+          for (const t of toks) { codeId = await identifyUnified(t).catch(() => null); if (codeId) { code = t; qrUsed = true; break; } }
+        }
+        const backVendor = (backOcr && backOcr.vendor) || null;   // vendor name off the full back label
         // FRONT → CLIP visual search (nearest catalog products by actual pixels)
         let visual = [];
         if (p.front) visual = await visualSearch(strip(p.front), 8);
+        // vendor clue: with no exact code, prefer visual matches from the vendor printed on the label
+        if (!codeId && backVendor && visual.length) {
+          const vlow = backVendor.toLowerCase().split(/\s+/)[0];
+          visual = visual.slice().sort((a, b) => (String(b.vendor || '').toLowerCase().includes(vlow) ? 1 : 0) - (String(a.vendor || '').toLowerCase().includes(vlow) ? 1 : 0));
+        }
         // FUSE the clues
         let primary = null, confidence = 'low', source = null, corroborated = false;
         if (codeId) {
           primary = { dw_sku: codeId.internal_sku, mfr_sku: codeId.mfr_sku, mfr_code: codeId.mfr_code, vendor: codeId.vendor, pattern: codeId.pattern, image: codeId.image };
-          source = 'code'; confidence = 'high';
+          source = qrUsed ? 'qr' : 'code'; confidence = 'high';
+          if (!primary.vendor && backVendor) primary.vendor = backVendor;
           corroborated = visual.some(v => (v.dw_sku && codeId.internal_sku && String(v.dw_sku).toUpperCase() === String(codeId.internal_sku).toUpperCase()) || (v.mfr_sku && codeId.mfr_sku && v.mfr_sku === codeId.mfr_sku));
           if (corroborated) confidence = 'very-high';
         } else if (visual.length) {
           const v0 = visual[0];
-          primary = { dw_sku: v0.dw_sku, mfr_sku: v0.mfr_sku, vendor: v0.vendor, pattern: v0.pattern, image: v0.image };
+          primary = { dw_sku: v0.dw_sku, mfr_sku: v0.mfr_sku, vendor: v0.vendor || backVendor, pattern: v0.pattern, image: v0.image };
           source = 'visual'; confidence = v0.score >= 0.92 ? 'high' : v0.score >= 0.85 ? 'medium' : 'low';
         }
-        send(res, 200, { ok: true, found: !!primary, primary, confidence, source, corroborated,
+        send(res, 200, { ok: true, found: !!primary, primary, confidence, source, corroborated, qr_used: qrUsed, back_vendor: backVendor,
           code, code_identity: codeId, visual,
           back_read: backOcr && { top: backOcr.top, candidates: backOcr.candidates, vendor: backOcr.vendor, fields: backOcr.fields } });
       } catch (e) { send(res, 500, { err: e.message }); }

← 023d1b2 feat: CLIP visual search + multi-photo front/back fusion ide  ·  back to Dw Photo Capture  ·  feat: add new SKU to dw_unified + Shopify (draft) with vendo 9a59376 →