← 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 →