[object Object]

← back to Dw Photo Capture

batch mode: fix gate + queue bugs from contrarian review

02ce83cacbf3bae838e7ce7aab3625b33adf0c5a · 2026-09-18 10:34:14 -0700 · Steve Abrams

- Drift/exposure/WB now measured on a fixed background reference band (CAL_REF),
  not the whole frame — a dark/light/saturated sample can no longer be misread as
  lighting drift (was firing false RETAKE/RE-CAL on ordinary samples).
- Blur (varLaplacian) + clipping (clipFrac) scoped to the sample region (CAL_TARGET),
  consistent with the fill check, so background composition no longer contaminates them.
- Durable queue: reclaim stranded 'uploading' rows on drain (reload recovery), cap
  retries and PARK a shot as 'failed' (never delete its blobs), surface + tap-to-retry.
  Closes the silent-dropped-shot path a reload/kill mid-POST created.

Additive only; single-shot flow untouched. On-device threshold tuning still required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uns3k4K15WBcFJNr3GcUd3

Files touched

Diff

commit 02ce83cacbf3bae838e7ce7aab3625b33adf0c5a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 10:34:14 2026 -0700

    batch mode: fix gate + queue bugs from contrarian review
    
    - Drift/exposure/WB now measured on a fixed background reference band (CAL_REF),
      not the whole frame — a dark/light/saturated sample can no longer be misread as
      lighting drift (was firing false RETAKE/RE-CAL on ordinary samples).
    - Blur (varLaplacian) + clipping (clipFrac) scoped to the sample region (CAL_TARGET),
      consistent with the fill check, so background composition no longer contaminates them.
    - Durable queue: reclaim stranded 'uploading' rows on drain (reload recovery), cap
      retries and PARK a shot as 'failed' (never delete its blobs), surface + tap-to-retry.
      Closes the silent-dropped-shot path a reload/kill mid-POST created.
    
    Additive only; single-shot flow untouched. On-device threshold tuning still required.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Uns3k4K15WBcFJNr3GcUd3
---
 public/batch.html | 86 ++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 60 insertions(+), 26 deletions(-)

diff --git a/public/batch.html b/public/batch.html
index 1aa61bf..3840cf1 100644
--- a/public/batch.html
+++ b/public/batch.html
@@ -201,13 +201,14 @@ const CFG = {
   fillVarMin: 45,          // min luma variance inside the target rect (a sample has texture)
   driftLumaMax: 38,        // Δluma vs cal that forces re-cal
   driftWBMax: 0.16,        // per-channel WB ratio drift vs cal that forces re-cal
-  skuConfMin: 0.55         // OCR confidence below this → keep, but flag for manual confirm
+  skuConfMin: 0.55,        // OCR confidence below this → keep, but flag for manual confirm
+  maxTries: 6              // upload attempts before a shot is PARKED as failed (never dropped)
 };
 
 // ── Session + calibration state (persisted) ──
 const LS_SESSION = 'dwbatch.session', LS_CAL = 'dwbatch.cal.';   // cal keyed by sessionId
 let session = null;        // {id, vendor, collection, date, total, n}
-let cal = null;            // {wbR,wbG,wbB, lumaTarget, crop:{x,y,w,h}(0-1), quad, settings, at}
+let cal = null;            // {wbR,wbG,wbB, lumaTarget, refLuma,refCR,refCB, crop:{x,y,w,h}(0-1), quad, settings, at}
 let stream=null, track=null, camLive=false;
 
 function toast(m){ const t=$('#toast'); if(!t)return; t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2400); }
@@ -267,11 +268,19 @@ function stats(img, rect){          // mean R/G/B/luma + luma variance over a (0
   const mr=sr/n,mg=sg/n,mb=sb/n,ml=sl/n;
   return { r:mr,g:mg,b:mb,luma:ml, var:Math.max(0,sl2/n-ml*ml), n };
 }
-function clipFrac(img){ const {data}=img; let n=0,c=0; for(let i=0;i<data.length;i+=16){ const l=0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; if(l<=2||l>=253)c++; n++; } return n?c/n:0; }
-function varLaplacian(img){         // sharpness: variance of 4-neighbour Laplacian on grayscale
+function clipFrac(img, rect){       // fraction of clipped (0/255) pixels within a (0-1) rect
+  const {data,width,height}=img;
+  const x0=Math.floor((rect?rect.x:0)*width), y0=Math.floor((rect?rect.y:0)*height);
+  const x1=Math.min(width, Math.ceil((rect?rect.x+rect.w:1)*width)), y1=Math.min(height, Math.ceil((rect?rect.y+rect.h:1)*height));
+  let n=0,c=0; for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){ const i=(y*width+x)*4;
+    const l=0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; if(l<=2||l>=253)c++; n++; } } return n?c/n:0;
+}
+function varLaplacian(img, rect){   // sharpness: variance of 4-neighbour Laplacian on grayscale, within a (0-1) rect
   const {data,width,height}=img; let s=0,s2=0,n=0;
   const L=(x,y)=>{ const i=(y*width+x)*4; return 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; };
-  for(let y=1;y<height-1;y+=2){ for(let x=1;x<width-1;x+=2){
+  const x0=Math.max(1,Math.floor((rect?rect.x:0)*width)), y0=Math.max(1,Math.floor((rect?rect.y:0)*height));
+  const x1=Math.min(width-1, Math.ceil((rect?rect.x+rect.w:1)*width)), y1=Math.min(height-1, Math.ceil((rect?rect.y+rect.h:1)*height));
+  for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){
     const v=(-4*L(x,y)+L(x-1,y)+L(x+1,y)+L(x,y-1)+L(x,y+1)); s+=v;s2+=v*v;n++; } }
   return n?Math.max(0,s2/n-(s/n)*(s/n)):0;
 }
@@ -298,12 +307,16 @@ $('#bStart').onclick=()=>{
 // perspective quad is stored (= the crop corners) for the v1.1 true-warp; v1 applies CROP.
 const CAL_TARGET={x:0.10,y:0.10,w:0.80,h:0.80};   // sample-fill box (green)
 const CAL_GRAY={x:0.42,y:0.42,w:0.16,h:0.16};     // gray-card patch (center square)
+// Drift reference = a fixed background band ABOVE the sample box (empty stand in every shot).
+// The gate reads luma/WB drift HERE — product-independent — instead of over the whole frame.
+const CAL_REF={x:0.0,y:0.0,w:1.0,h:0.08};
 function drawCalOverlay(){
   const cnv=$('#cov'), v=$('#cv'); const rect=v.getBoundingClientRect();
   cnv.width=rect.width; cnv.height=rect.height; const c=cnv.getContext('2d'); c.clearRect(0,0,cnv.width,cnv.height);
   const R=(r,col,lab)=>{ c.strokeStyle=col; c.lineWidth=2; c.setLineDash(col===getCss('--gold')?[6,5]:[]);
     c.strokeRect(r.x*cnv.width,r.y*cnv.height,r.w*cnv.width,r.h*cnv.height);
     if(lab){ c.fillStyle=col; c.font='600 12px ui-monospace,monospace'; c.fillText(lab, r.x*cnv.width+6, r.y*cnv.height+16); } };
+  R(CAL_REF, '#4a90d9', 'light ref — keep clear');
   R(CAL_TARGET, getCss('--green'), 'sample fill');
   R(CAL_GRAY, getCss('--gold'), 'gray patch');
 }
@@ -319,12 +332,15 @@ $('#cSave').onclick=()=>{
   const v=$('#cv'); const g=gateGrab(v); if(!g){ toast('Camera not ready'); return; }
   const gray=stats(g.data, CAL_GRAY);
   if(gray.n<10||gray.luma<8){ toast('Can’t read the gray patch — check light'); return; }
+  const ref=stats(g.data, CAL_REF);          // fixed background band = the drift/exposure reference
   // von Kries: normalize each channel to green so a neutral card reads neutral
   const g0=gray.g||1;
   cal={
     wbR: clamp(g0/(gray.r||1),0.3,3), wbG:1, wbB: clamp(g0/(gray.b||1),0.3,3),
-    lumaTarget: stats(g.data,null).luma,     // whole-frame mean luma = the exposure anchor
+    lumaTarget: stats(g.data,null).luma,     // whole-frame mean luma = the MASTER exposure-normalize anchor
     grayLuma: gray.luma,
+    // product-independent drift reference — re-read from CAL_REF each tick and compared to these:
+    refLuma: ref.luma, refCR: (ref.g||1)/(ref.r||1), refCB: (ref.g||1)/(ref.b||1),
     crop: {...CAL_TARGET},
     quad: [ {x:CAL_TARGET.x,y:CAL_TARGET.y},{x:CAL_TARGET.x+CAL_TARGET.w,y:CAL_TARGET.y},
             {x:CAL_TARGET.x+CAL_TARGET.w,y:CAL_TARGET.y+CAL_TARGET.h},{x:CAL_TARGET.x,y:CAL_TARGET.y+CAL_TARGET.h} ],
@@ -362,16 +378,19 @@ function startGateLoop(){ if(gateTimer) clearInterval(gateTimer); gateTimer=setI
 function gateTick(){
   if(busy||$('#vShoot').hidden) return;
   const v=$('#v'); const g=gateGrab(v); if(!g){ return; } lastGate=g;
-  const whole=stats(g.data,null);
-  const tgt=stats(g.data, CAL_TARGET);
-  const clip=clipFrac(g.data);
-  const blur=varLaplacian(g.data);
-  const expDev=cal?Math.abs(whole.luma-cal.lumaTarget):0;
-  // WB drift: compare current per-channel ratios (to green) against cal's
+  const whole=stats(g.data,null);        // whole-frame: motion detection ONLY
+  const tgt=stats(g.data, CAL_TARGET);   // the sample region
+  const ref=stats(g.data, CAL_REF);      // fixed background band: product-independent drift reference
+  const clip=clipFrac(g.data, CAL_TARGET);     // clipping OF THE SAMPLE, not the background
+  const blur=varLaplacian(g.data, CAL_TARGET); // sharpness OF THE SAMPLE
+  const haveRef = !!(cal && cal.refLuma!=null);
+  // exposure + WB drift measured on the FIXED reference band, so a dark / light / saturated
+  // sample can't be misread as a lighting change (was: whole-frame vs cal — the core v1 bug).
+  const expDev = haveRef ? Math.abs(ref.luma-cal.refLuma) : 0;
   let wbDrift=0;
-  if(cal){ const cr=(whole.g||1)/(whole.r||1), cb=(whole.g||1)/(whole.b||1);
-    wbDrift=Math.max(Math.abs(cr-cal.wbR)/cal.wbR, Math.abs(cb-cal.wbB)/cal.wbB); }
-  // motion / stillness
+  if(haveRef){ const cr=(ref.g||1)/(ref.r||1), cb=(ref.g||1)/(ref.b||1);
+    wbDrift=Math.max(Math.abs(cr-cal.refCR)/cal.refCR, Math.abs(cb-cal.refCB)/cal.refCB); }
+  // motion / stillness (whole-frame delta — a sample swap is legitimately "motion")
   let moving=true;
   if(prevLuma!=null){ const d=Math.abs(whole.luma-prevLuma); moving=d>CFG.stillThresh;
     if(d<CFG.stillThresh) stillCount++; else if(d>CFG.motionThresh) stillCount=0; }
@@ -382,13 +401,15 @@ function gateTick(){
   if(clip>CFG.clipMax) fails.push('clipping');
   if(expDev>CFG.expDevMax) fails.push('exposure');
   if(tgt.var<CFG.fillVarMin) fails.push('no sample in frame');
-  const drift = cal && (expDev>CFG.driftLumaMax || wbDrift>CFG.driftWBMax);
+  // a cal saved before this fix lacks the reference band → force a re-cal rather than run blind
+  const needRecal = !!(cal && !haveRef);
+  const drift = needRecal || (haveRef && (expDev>CFG.driftLumaMax || wbDrift>CFG.driftWBMax));
   // status chips
-  setChip('#dCal', drift?'err':(cal?'ok':'warn')); $('#calTxt').textContent = drift?'DRIFT':(cal?'ok':'none');
+  setChip('#dCal', drift?'err':(cal?'ok':'warn')); $('#calTxt').textContent = needRecal?'RE-CAL':(drift?'DRIFT':(cal?'ok':'none'));
   $('#deltaTxt').textContent = 'L'+expDev.toFixed(0)+' W'+(wbDrift*100).toFixed(0)+'%';
   $('#qmeta').textContent = `blur ${blur.toFixed(0)}  clip ${(clip*100).toFixed(1)}%  exp ${expDev.toFixed(0)}  fill ${tgt.var.toFixed(0)}`;
   // drift → force re-cal (the software "lock")
-  if(drift){ $('#recalMeta').textContent='Δluma '+expDev.toFixed(0)+' · ΔWB '+(wbDrift*100).toFixed(0)+'%'; $('#bigRecal').hidden=false; setState('wait','RE-CAL'); return; }
+  if(drift){ $('#recalMeta').textContent = needRecal ? 'Calibration predates this build — please recalibrate.' : ('Δluma '+expDev.toFixed(0)+' · ΔWB '+(wbDrift*100).toFixed(0)+'%'); $('#bigRecal').hidden=false; setState('wait','RE-CAL'); return; }
   else { $('#bigRecal').hidden=true; }
   // OCR the SKU on stillness (debounced) if no manual sku typed for this scene
   const still = stillCount>=CFG.stableFrames && !moving;
@@ -520,25 +541,38 @@ let draining=false;
 async function drainQueue(){
   if(draining) return; draining=true;
   try{
-    let rows=(await allQ()).filter(r=>r.state!=='done');
-    // concurrency 1–2; keep it 1 to stay gentle on iPad + prod
+    // RELOAD RECOVERY: a row left 'uploading' by a killed/reloaded/backgrounded pass would
+    // otherwise be skipped forever (a silently-dropped shot). Reclaim any to 'queued' first.
+    const snap=await allQ();
+    for(const r of snap){ if(r.state==='uploading'){ await markQ(r.k,'queued',r.tries||0); } }
+    let rows=(await allQ()).filter(r=>r.state==='queued');   // 'done' + 'failed' excluded
+    // concurrency 1 — gentle on iPad + prod
     for(const r of rows){
-      if(r.state==='uploading') continue;
-      await markQ(r.k,'uploading',r.tries);
+      const tries=r.tries||0;
+      if(tries>=CFG.maxTries){ await markQ(r.k,'failed',tries); updateQueueTxt(); continue; }  // parked, NEVER deleted
+      await markQ(r.k,'uploading',tries);
       try{
         const body={ sessionId:r.item.sessionId, sku:r.item.sku, seq:r.item.seq, vendor:r.item.vendor, collection:r.item.collection,
           original:await b2d(r.item.original), master:await b2d(r.item.master), web:await b2d(r.item.web), meta:r.item.meta };
         const resp=await fetch('/api/batch-shot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
         const j=await resp.json();
-        if(j&&j.ok){ await delQ(r.k); } else { await markQ(r.k,'queued',(r.tries||0)+1); }
-      }catch(e){ await markQ(r.k,'queued',(r.tries||0)+1); }
+        if(j&&j.ok){ await delQ(r.k); }
+        else { const nt=tries+1; await markQ(r.k, nt>=CFG.maxTries?'failed':'queued', nt); }
+      }catch(e){ const nt=tries+1; await markQ(r.k, nt>=CFG.maxTries?'failed':'queued', nt); }
       updateQueueTxt();
     }
   }finally{ draining=false; updateQueueTxt(); }
 }
 function b2d(blob){ return new Promise((res,rej)=>{ const fr=new FileReader(); fr.onload=()=>res(fr.result); fr.onerror=rej; fr.readAsDataURL(blob); }); }
-async function updateQueueTxt(){ try{ const rows=await allQ(); const q=rows.filter(r=>r.state==='queued').length, up=rows.filter(r=>r.state==='uploading').length, fail=rows.filter(r=>(r.tries||0)>=3).length;
-  $('#queueTxt').textContent = `queue ${q} · uploading ${up}${fail?(' · '+fail+' stuck'):''} · cost $${batchCost.toFixed(3)}`; }catch(e){} }
+async function updateQueueTxt(){ try{ const rows=await allQ();
+  const q=rows.filter(r=>r.state==='queued').length, up=rows.filter(r=>r.state==='uploading').length, fail=rows.filter(r=>r.state==='failed').length;
+  const el=$('#queueTxt'); if(!el) return;
+  el.textContent = `queue ${q} · uploading ${up}${fail?(' · ⚠ '+fail+' FAILED — tap to retry'):''} · cost $${batchCost.toFixed(3)}`;
+  el.style.cursor = fail?'pointer':''; el.onclick = fail?requeueFailed:null;
+}catch(e){} }
+async function requeueFailed(){ const rows=await allQ(); let m=0;
+  for(const r of rows){ if(r.state==='failed'){ await markQ(r.k,'queued',0); m++; } }
+  if(m){ toast('Retrying '+m+' failed upload'+(m>1?'s':'')); drainQueue(); } }
 setInterval(()=>{ if(!$('#vShoot').hidden) drainQueue(); }, 6000);   // retry loop for offline/failed
 window.addEventListener('online', drainQueue);
 

← 374d995 auto-data-snapshot: 2026-09-18T10:30:02 (1 data files) — dat  ·  back to Dw Photo Capture  ·  batch mode: draw the light-reference band live during shooti 39ffa6e →