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