[object Object]

← back to Dw Photo Capture

scan: background logo-ID when a code locks but OCR names no vendor (cycle 8)

64ab508cdae955c8c34c73eecb2948c492e21914 · 2026-06-26 01:10:42 -0700 · Steve Abrams

When the scanner locks a code but OCR couldn't read the brand, the captured frame is
now threaded into applyScan and a non-blocking bgIdentify() fires /api/identify (VLM
logo recognition) in the background — the SKU resolve already ran, so this never blocks
it. If the logo names a vendor, it's surfaced ('🏷 Looks like X') and bound to the
locked SKU via /api/learn so the vendor profile learns. Fires from both the live scanner
(passes the locked frame) and the snap input (passes the photo). Only triggers when OCR
found no vendor — no extra VLM calls on the common path.

Verified: lint + 21 unit tests green, page serves 200, inline JS valid. $0 (local).

Files touched

Diff

commit 64ab508cdae955c8c34c73eecb2948c492e21914
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 26 01:10:42 2026 -0700

    scan: background logo-ID when a code locks but OCR names no vendor (cycle 8)
    
    When the scanner locks a code but OCR couldn't read the brand, the captured frame is
    now threaded into applyScan and a non-blocking bgIdentify() fires /api/identify (VLM
    logo recognition) in the background — the SKU resolve already ran, so this never blocks
    it. If the logo names a vendor, it's surfaced ('🏷 Looks like X') and bound to the
    locked SKU via /api/learn so the vendor profile learns. Fires from both the live scanner
    (passes the locked frame) and the snap input (passes the photo). Only triggers when OCR
    found no vendor — no extra VLM calls on the common path.
    
    Verified: lint + 21 unit tests green, page serves 200, inline JS valid. $0 (local).
---
 OVERNIGHT_LEDGER.md |  3 ++-
 public/index.html   | 26 ++++++++++++++++++++++----
 2 files changed, 24 insertions(+), 5 deletions(-)

diff --git a/OVERNIGHT_LEDGER.md b/OVERNIGHT_LEDGER.md
index 6bd0cba..e04cdb5 100644
--- a/OVERNIGHT_LEDGER.md
+++ b/OVERNIGHT_LEDGER.md
@@ -19,7 +19,7 @@ the single highest-value safe one, the officer executes it, and the loop resched
 - [x] Barcode UX: distinct "▍▍ BARCODE" lock label on a barcode read
 - [x] /learn: shows logo fingerprint + flags rejected logos with their read-as
 - [x] moondream fast-toggle for /api/identify ({fast}/{model}, allow-listed, documented in API.md)
-- [ ] Auto-fire /api/identify in background on a lock-with-code-but-no-vendor (non-blocking)
+- [x] Auto-fire /api/identify in background on a lock-with-code-but-no-vendor (bgIdentify)
 - [ ] /code-review (or claude-codex) pass on the full session diff; fix findings
 - [x] GATED (drafted to pending-approval): weekly launchd refresh — plist staged
 
@@ -34,3 +34,4 @@ the single highest-value safe one, the officer executes it, and the loop resched
 | 5 | **DTD 3/3 → A** | wire logo fingerprints into /api/identify as a TIEBREAKER (only when name-match is inconclusive) | clean name still via=name (no regression); fingerprint resolves Mind the Gap/Ralph Lauren in unit test | $0.003 (DTD) + $0 |
 | 6 | — (clear win) | analyzeOcr/vendor unit tests — harness slices the REAL fn source from server.js + eval-tests vs crafted inputs & real profile data; wired into npm test + selfcheck | 21/21 pass; lint clean; covers parseOcrRows/analyzeOcr(brand,barcode,prefix,weak,empty)/fuzzyVendor/fingerprintVendor | $0 |
 | 7 | — (clear win) | moondream fast-toggle for /api/identify: ollamaVision gains a model arg, allow-listed VISION_MODELS, {fast:true}/{model} per-request override (echoed back), API.md docs | qwen 21.5s (Kravet ✓) vs moondream 12.2s; lint+21 tests green; off-list ignored (no arbitrary pulls) | $0 |
+| 8 | — (clear win) | auto-fire /api/identify in background on a lock-with-code-but-no-vendor: thread captured frame into applyScan, bgIdentify() fires VLM logo-ID non-blocking, binds discovered vendor to the SKU via /api/learn | lint+21 tests green; page 200; fire-and-forget, never blocks resolve | $0 |
diff --git a/public/index.html b/public/index.html
index 08554c5..b52e830 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1100,7 +1100,8 @@ async function scanTick(){
         try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}   // haptic on lock; audio waits for the resolve outcome
         if(scanTimer){clearInterval(scanTimer);scanTimer=null;}   // freeze ticks on the red frame
         const hit=d;
-        setTimeout(()=>{ closeLiveScan(); applyScan(hit); },420);  // hold the red a beat, then GO
+        const frame=c.toDataURL('image/jpeg',0.8);   // keep the locked frame for a background logo-ID if no vendor
+        setTimeout(()=>{ closeLiveScan(); applyScan(hit, frame); },420);  // hold the red a beat, then GO
       }
     } else if(_lockHits<2){ $('#scanStatus').textContent='Point at the printed SKU…'; }
   }catch(e){} finally{ scanBusy=false; }
@@ -1122,15 +1123,32 @@ async function scanResolve(cands){
   }
   return null;
 }
-async function applyScan(d){
+// Non-blocking logo-ID: when a code locked but OCR couldn't name the vendor, ask the VLM
+// in the background (the resolve already ran — this never blocks it). If it finds a vendor,
+// surface it and bind it to the locked SKU so the profile learns. See /api/identify.
+function bgIdentify(imgDataUrl, sku){
+  if(!imgDataUrl) return;
+  fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:imgDataUrl})})
+    .then(r=>r.json()).then(v=>{
+      if(v&&v.vendor){
+        toast('🏷 Looks like '+v.vendor);
+        if(sku) fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
+          body:JSON.stringify({sku:sku,vendor:v.vendor})}).catch(()=>{});
+      }
+    }).catch(()=>{});
+}
+async function applyScan(d, imgDataUrl){
   const cands=(d&&d.candidates&&d.candidates.length)?d.candidates:(d&&d.top?[d.top]:[]);
   if(!cands.length){ playUhoh(); toast('No SKU/name found — try a closer, flatter shot'); return; }
   toast('🔎 Matching '+cands.length+' read'+(cands.length>1?'s':'')+'…');
   const hit=await scanResolve(cands);
   collapsed=false;
   if(!hit){                               // locked a code but nothing matched anywhere → "not right"
-    playUhoh(); chipTo('shop'); $('#q').value=cands[0]; toast('✗ No match for '+cands[0]+' — searching all Shopify'); return doShopSearch();
+    playUhoh(); chipTo('shop'); $('#q').value=cands[0]; toast('✗ No match for '+cands[0]+' — searching all Shopify');
+    if(!d.vendor) bgIdentify(imgDataUrl, cands[0]);   // no vendor from OCR → try the logo in the background
+    return doShopSearch();
   }
+  if(!hit.vendor && !d.vendor) bgIdentify(imgDataUrl, hit.q);   // resolved a product but no vendor named → logo-ID
   playYoohoo();                           // ✓ resolved to a real product — the happy lock
   // LEARN: this scan resolved to a known vendor — refine that vendor's sample profile
   // (fire-and-forget; server folds in the on-swatch code prefix). See /learn.
@@ -1187,7 +1205,7 @@ $('#scanInput').addEventListener('change',async e=>{
     const d=await ocrSmart(dataUrl);   // front-cam mirror-retry + multi-candidate
     e.target.value='';
     // multi-pass resolve: every OCR candidate against TWIL sku/mfr/name → fall through to all-Shopify
-    await applyScan(d);
+    await applyScan(d, dataUrl);
   }catch(err){ toast('Scan failed — check connection'); }
 });
 // 🎨 Similar — photograph the PATTERN itself (not a label) → recognize + find lookalikes.

← d76cc5a identify: per-request model toggle (moondream fast path) + A  ·  back to Dw Photo Capture  ·  auto-save: 2026-06-26T01:38:39 (1 files) — data/build.json a412dce →