Skip to content

Commit 8560aa3

Browse files
MagicalTuxclaude
andcommitted
demo: track located candidates to kill acquiring-reticle noise
The locator favours recall, so every frame it reports transient boxes on text, hands and background that strobed as amber acquiring reticles. Candidates are now tracked across locate cycles (IoU match) and a reticle is drawn only once a detection has persisted CAND_MIN_HITS frames — single-frame noise never survives long enough to render. Surviving reticles ease toward their target for smooth motion, and the 'tracking' count now reflects stable detections. Tracks reset on camera start. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 03ad329 commit 8560aa3

1 file changed

Lines changed: 34 additions & 5 deletions

File tree

web/index.html

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,30 @@ <h2>Decode an image</h2>
394394
return uni > 0 ? inter / uni : 0;
395395
};
396396

397+
// Located candidates favour recall, so each frame reports transient boxes on text,
398+
// hands and background clutter. Rather than draw every raw hit — which strobes — we
399+
// track candidates across locate cycles and only surface one once it has been seen in a
400+
// few consecutive frames (a real object persists; single-frame noise does not).
401+
let candTracks = []; // { box, target, lastSeen, hits }
402+
const CAND_MIN_HITS = 3; // consecutive locate cycles before a candidate is drawn
403+
const CAND_HOLD_MS = 260; // drop a track not re-located within this window
404+
const CAND_IOU = 0.25; // overlap needed to treat two hits as the same object
405+
function updateCandTracks(cands, now) {
406+
const boxes = cands.map(c => bbox(c.corners));
407+
const used = new Set();
408+
for (const b of boxes) {
409+
let best = -1, bestIou = CAND_IOU;
410+
for (let i = 0; i < candTracks.length; i++) {
411+
if (used.has(i)) continue;
412+
const j = iou(candTracks[i].target, b);
413+
if (j > bestIou) { bestIou = j; best = i; }
414+
}
415+
if (best >= 0) { const t = candTracks[best]; t.target = b; t.lastSeen = now; t.hits++; used.add(best); }
416+
else candTracks.push({ box: b, target: b, lastSeen: now, hits: 1 });
417+
}
418+
candTracks = candTracks.filter(t => now - t.lastSeen <= CAND_HOLD_MS);
419+
}
420+
397421
// Decoding runs in a background worker (its own wasm instance) so the heavy sampler
398422
// can never block the UI thread — the live overlay stays smooth even when a scan is
399423
// slow. The main thread only sends a frame when the worker is free. It reuses the
@@ -539,9 +563,9 @@ <h2>Decode an image</h2>
539563
viewportFrame(ctx, W, H);
540564

541565
const dboxes = decoded.filter(r => r.box).map(r => r.box);
542-
// acquiring: faint pulsing ticks on located-but-unresolved candidates
566+
// acquiring: faint pulsing ticks on persisted located-but-unresolved candidates
543567
for (const c of cands) {
544-
const b = bbox(c.corners);
568+
const b = c.box;
545569
if (dboxes.some(d => boxesOverlap(d, b))) continue;
546570
const pulse = REDUCED ? 0.22 : 0.14 + 0.12 * (0.5 + 0.5 * Math.sin(ts / 420));
547571
reticle(ctx, b.x0 * sx, b.y0 * sy, (b.x1 - b.x0) * sx, (b.y1 - b.y0) * sy, HUD.acquire, 5, pulse, 9);
@@ -572,7 +596,7 @@ <h2>Decode an image</h2>
572596
document.getElementById('cam-btn').disabled = true;
573597
document.getElementById('cam-stop').disabled = false;
574598
document.getElementById('cam-capture').disabled = false;
575-
running = true; workerBusy = false; lastSent = 0; decodedTracks.clear(); requestAnimationFrame(loop);
599+
running = true; workerBusy = false; lastSent = 0; decodedTracks.clear(); candTracks = []; requestAnimationFrame(loop);
576600
};
577601
document.getElementById('cam-stop').onclick = () => {
578602
running = false; stream && stream.getTracks().forEach(t => t.stop());
@@ -632,6 +656,7 @@ <h2>Decode an image</h2>
632656
});
633657
lastMs = performance.now() - t0;
634658
liveScale = [pw, ph];
659+
updateCandTracks(liveCands, ts);
635660
// Track: steer each decoded code toward the located candidate that best overlaps it,
636661
// so the lock follows the code across camera movement between the sparse re-decodes.
637662
// A locate match also refreshes the track's liveness — locate only fires on
@@ -660,16 +685,20 @@ <h2>Decode an image</h2>
660685
for (const tr of decodedTracks.values()) if (tr.target) tr.box = boxLerp(tr.box, tr.target, 0.3);
661686
for (const [k, tr] of decodedTracks) if (ts - tr.lastSeen > DECODE_HOLD_MS) decodedTracks.delete(k);
662687
lastDecoded = [...decodedTracks.values()];
688+
// Ease located-candidate tracks too, and only surface the ones that have persisted
689+
// past the noise threshold — this is what the acquiring reticles are drawn from.
690+
for (const t of candTracks) t.box = boxLerp(t.box, t.target, 0.35);
691+
const acquiring = candTracks.filter(t => t.hits >= CAND_MIN_HITS);
663692
document.getElementById('cam-result').textContent =
664693
lastDecoded.length ? lastDecoded.map(r => `${r.symbology}: ${r.text}`).join('\n') : '';
665694

666695
// HUD every frame (cheap: canvas only) for a smooth reticle/scan animation.
667696
overlay.width = video.clientWidth; overlay.height = video.clientHeight;
668697
const octx = overlay.getContext('2d');
669698
octx.clearRect(0, 0, overlay.width, overlay.height);
670-
renderHUD(octx, overlay.width, overlay.height, liveCands, lastDecoded, overlay.width / liveScale[0], overlay.height / liveScale[1], ts);
699+
renderHUD(octx, overlay.width, overlay.height, acquiring, lastDecoded, overlay.width / liveScale[0], overlay.height / liveScale[1], ts);
671700
document.getElementById('cam-stat').innerHTML =
672-
`◈ tracking <b>${liveCands.length}</b> · locked <b>${lastDecoded.length}</b> · scan <b>${lastMs.toFixed(1)}ms</b>`;
701+
`◈ tracking <b>${acquiring.length}</b> · locked <b>${lastDecoded.length}</b> · scan <b>${lastMs.toFixed(1)}ms</b>`;
673702
}
674703

675704
// ---- file decode ----

0 commit comments

Comments
 (0)