← 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
M public/index.htmlM server.js
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 →