[object Object]

← back to Dw Photo Capture

Recognize Pattern refactor: dedupe SQL term-match, single-trigger visual rerank, shared image-pipe helper, reuse one fingerprint canvas

6264e2890b360506000521a0447d4a4fd85e6d0f · 2026-06-26 00:06:53 -0700 · Steve Abrams

Files touched

Diff

commit 6264e2890b360506000521a0447d4a4fd85e6d0f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 26 00:06:53 2026 -0700

    Recognize Pattern refactor: dedupe SQL term-match, single-trigger visual rerank, shared image-pipe helper, reuse one fingerprint canvas
---
 public/index.html | 22 ++++++++++++----------
 server.js         | 29 ++++++++++++++++-------------
 2 files changed, 28 insertions(+), 23 deletions(-)

diff --git a/public/index.html b/public/index.html
index 795831d..08554c5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -362,15 +362,16 @@ async function doShopSearch(){
 // 🎨 Recognize a PATTERN (the swatch itself) → this + similar across the whole unified DB.
 let SIMILAR=[], RECOGNIZED=null, QSIG=null, VRANK='', _rerankSeq=0;
 const _SIGCACHE=new Map();
+// One reusable 8×8 canvas for every fingerprint — imgSig runs synchronously (draw→read→return),
+// so there's no reentrancy and ~49 transient canvas allocations per recognize collapse to one.
+const _SIGN=8, _sigCtx=Object.assign(document.createElement('canvas'),{width:_SIGN,height:_SIGN}).getContext('2d',{willReadFrequently:true});
 // Perceptual "thumbnail fingerprint": average the image down to an 8×8 RGB grid (192 nums,
 // 0-1). Two patterns that look alike — similar colors in similar places — land close together.
 function imgSig(src){
-  const N=8, c=document.createElement('canvas'); c.width=N; c.height=N;
-  const x=c.getContext('2d',{willReadFrequently:true});
-  try{ x.drawImage(src,0,0,N,N); }catch(e){ return null; }
-  let d; try{ d=x.getImageData(0,0,N,N).data; }catch(e){ return null; }   // tainted canvas → null
-  const sig=new Float32Array(N*N*3);
-  for(let i=0;i<N*N;i++){ sig[i*3]=d[i*4]/255; sig[i*3+1]=d[i*4+1]/255; sig[i*3+2]=d[i*4+2]/255; }
+  try{ _sigCtx.drawImage(src,0,0,_SIGN,_SIGN); }catch(e){ return null; }
+  let d; try{ d=_sigCtx.getImageData(0,0,_SIGN,_SIGN).data; }catch(e){ return null; }   // tainted canvas → null
+  const sig=new Float32Array(_SIGN*_SIGN*3);
+  for(let i=0;i<_SIGN*_SIGN;i++){ sig[i*3]=d[i*4]/255; sig[i*3+1]=d[i*4+1]/255; sig[i*3+2]=d[i*4+2]/255; }
   return sig;
 }
 function sigDist(a,b){ if(!a||!b)return 1e9; let s=0; for(let i=0;i<a.length;i++){const e=a[i]-b[i]; s+=e*e;} return s; }
@@ -410,14 +411,15 @@ async function visualRerank(){
 }
 async function recognizeDataUrl(dataUrl){
   toast('🎨 Recognizing pattern…'); $('#count').textContent='🎨 Reading pattern…';
-  QSIG=null; VRANK=''; _rerankSeq++;
-  sigFromDataUrl(dataUrl).then(s=>{ QSIG=s; if(SIMILAR.length) visualRerank(); });   // fingerprint the photo
+  QSIG=null; VRANK=''; const seq=++_rerankSeq;
+  const sigP=sigFromDataUrl(dataUrl);            // fingerprint the photo in parallel with the VLM call
   try{
     const r=await fetch('/api/recognize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})});
     const d=await r.json();
-    RECOGNIZED=d.recognized||null; SIMILAR=(d.items||[]).map(x=>({...x,_vdist:null}));
+    RECOGNIZED=d.recognized||null; SIMILAR=d.items||[];
     collapsed=false; chipTo('similar'); $('#q').value=''; render();
-    if(QSIG) visualRerank();                       // sort by the actual pattern image
+    QSIG=await sigP;                               // already resolved in the common (VLM-slow) case
+    if(QSIG && seq===_rerankSeq) visualRerank();   // sort by the actual pattern image
     if(d.err && !SIMILAR.length){ toast('Recognize failed: '+d.err); }
     else if(RECOGNIZED && RECOGNIZED.code){ toast('🏷 Code seen: '+RECOGNIZED.code+' · '+SIMILAR.length+' similar'); }
     else { toast('🎨 '+SIMILAR.length+' similar pattern'+(SIMILAR.length===1?'':'s')+' found'); }
diff --git a/server.js b/server.js
index 9b55f67..dcde82a 100644
--- a/server.js
+++ b/server.js
@@ -450,6 +450,17 @@ function fingerprintVendor(reads, typeface) {
   return bestScore >= 0.6 ? { vendor: best, via: 'fingerprint', score: Math.round(bestScore * 100) / 100 } : null;
 }
 
+// Pipe a remote image through same-origin so a <canvas> can read its pixels without a
+// cross-origin taint. Shared by /api/current-image (server-derived src) and the
+// host-whitelisted /api/imgproxy (client-supplied src). `headers` adds caching/CORS.
+function pipeUpstreamImage(src, res, headers) {
+  https.get(src, ir => {
+    if ((ir.statusCode || 0) >= 400) { ir.resume(); return send(res, 502, { err: 'upstream ' + ir.statusCode }); }
+    res.writeHead(200, Object.assign({ 'Content-Type': ir.headers['content-type'] || 'image/jpeg' }, headers || {}));
+    ir.pipe(res);
+  }).on('error', e => send(res, 502, { err: e.message }));
+}
+
 const appHandler = (req, res) => {
   // public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
   const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest'];
@@ -836,10 +847,7 @@ const appHandler = (req, res) => {
       const imgs = (r.body && r.body.images) || [];
       const src = imgs.length ? imgs[imgs.length - 1].src : null; // featured/last
       if (!src) return send(res, 404, { err: 'no image' });
-      https.get(src, (ir) => {
-        res.writeHead(200, { 'Content-Type': ir.headers['content-type'] || 'image/jpeg', 'Cache-Control': 'no-store' });
-        ir.pipe(res);
-      }).on('error', e => send(res, 502, { err: e.message }));
+      pipeUpstreamImage(src, res, { 'Cache-Control': 'no-store' });
     })();
     return;
   }
@@ -851,13 +859,9 @@ const appHandler = (req, res) => {
   if (u.pathname === '/api/imgproxy' && req.method === 'GET') {
     const src = u.searchParams.get('url') || '';
     let su; try { su = new URL(src); } catch (e) { return send(res, 400, { err: 'bad url' }); }
-    const OK = /(^|\.)(shopify\.com|shopifycdn\.com|myshopify\.com|cdn\.shopify\.com)$/i;
+    const OK = /(^|\.)(shopify\.com|shopifycdn\.com|myshopify\.com)$/i;   // cdn.shopify.com ⊂ .shopify.com
     if (su.protocol !== 'https:' || !OK.test(su.hostname)) return send(res, 403, { err: 'host not allowed' });
-    https.get(su.href, ir => {
-      if ((ir.statusCode || 0) >= 400) { ir.resume(); return send(res, 502, { err: 'upstream ' + ir.statusCode }); }
-      res.writeHead(200, { 'Content-Type': ir.headers['content-type'] || 'image/jpeg', 'Cache-Control': 'max-age=86400', 'Access-Control-Allow-Origin': '*' });
-      ir.pipe(res);
-    }).on('error', e => send(res, 502, { err: e.message }));
+    pipeUpstreamImage(su.href, res, { 'Cache-Control': 'max-age=86400', 'Access-Control-Allow-Origin': '*' });
     return;
   }
 
@@ -1277,15 +1281,14 @@ function unifiedSimilar(terms) {
     const t = terms.filter(Boolean).slice(0, 8);
     if (!t.length) return resolve([]);
     const hay = "lower(coalesce(tags,'')||' '||coalesce(title,'')||' '||coalesce(pattern_name,''))";
+    // score = how many recognized terms this row matches; the WHERE is just score>0 (≥1 match).
     const score = t.map(x => `(case when ${hay} like '%${x}%' then 1 else 0 end)`).join('+');
-    const anyOf = t.map(x => `${hay} like '%${x}%'`).join(' or ');
     const SQL = `select regexp_replace(coalesce(shopify_id,''),'\\D','','g') as product_id,
         coalesce(dw_sku,''), coalesce(mfr_sku,''), coalesce(title,''), coalesce(vendor,''),
         coalesce(case when price>4.25 then price end, retail_price, min_variant_price, price),
         coalesce(image_url,''), upper(coalesce(status,'')), (${score}) as score
       from shopify_products
-      where status ilike 'active' and image_url is not null and image_url <> ''
-        and (${anyOf})
+      where status ilike 'active' and image_url is not null and image_url <> '' and (${score}) > 0
       order by score desc, (coalesce(price,0)>4.25) desc, updated_at_shopify desc nulls last
       limit 48`;
     execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL],

← 94dce12 ledger: switch overnight loop to NEVER-STOP idea-adding mode  ·  back to Dw Photo Capture  ·  test: analyzeOcr/vendor unit tests (overnight cycle 6) bf7279e →