@@ -368,7 +368,7 @@ <h2>Decode an image</h2>
368368const video = document . getElementById ( 'cam-video' ) ;
369369const overlay = document . getElementById ( 'cam-overlay' ) ;
370370const scratch = document . createElement ( 'canvas' ) ;
371- let stream , running = false , lastSent = 0 , lastDecoded = [ ] , workerBusy = false ;
371+ let stream , running = false , lastSent = 0 , lastDecoded = [ ] , workerBusy = false , worker2dBusy = false , last2d = 0 ;
372372
373373// Decoded codes persist for a short window after their last confirmation instead of
374374// vanishing the instant one frame fails to re-read them. A hand-held camera only
@@ -393,6 +393,16 @@ <h2>Decode an image</h2>
393393 const uni = ( a . x1 - a . x0 ) * ( a . y1 - a . y0 ) + ( b . x1 - b . x0 ) * ( b . y1 - b . y0 ) - inter ;
394394 return uni > 0 ? inter / uni : 0 ;
395395} ;
396+ // Fold a batch of decoded results (each {symbology, text, box}) into the persistent
397+ // tracks — shared by the crop worker (1D + located regions) and the full-frame 2D worker.
398+ function mergeDecoded ( list , now ) {
399+ for ( const r of list ) {
400+ const key = r . symbology + '|' + r . text ;
401+ const tr = decodedTracks . get ( key ) ;
402+ if ( tr ) { tr . lastSeen = now ; if ( r . box ) tr . target = r . box ; }
403+ else decodedTracks . set ( key , { symbology : r . symbology , text : r . text , box : r . box , target : r . box , lastSeen : now } ) ;
404+ }
405+ }
396406
397407// Located candidates favour recall, so each frame reports transient boxes on text,
398408// hands and background clutter. Rather than draw every raw hit — which strobes — we
@@ -446,17 +456,46 @@ <h2>Decode an image</h2>
446456const decodeWorker = new Worker ( URL . createObjectURL ( new Blob ( [ workerSrc ] , { type : 'text/javascript' } ) ) ) ;
447457decodeWorker . onmessage = ( e ) => {
448458 workerBusy = false ;
449- const now = performance . now ( ) ;
450- // Merge this batch into the persistent tracks: refresh a code that is still visible,
451- // register a newly seen one. Expiry happens in the render loop by DECODE_HOLD_MS.
452- for ( const r of e . data ) {
453- const key = r . symbology + '|' + r . text ;
454- const tr = decodedTracks . get ( key ) ;
455- if ( tr ) { tr . lastSeen = now ; if ( r . box ) tr . target = r . box ; }
456- else decodedTracks . set ( key , { symbology : r . symbology , text : r . text , box : r . box , target : r . box , lastSeen : now } ) ;
457- }
459+ // Merge this batch into the persistent tracks; expiry happens in the render loop.
460+ mergeDecoded ( e . data , performance . now ( ) ) ;
458461} ;
459462
463+ // A second, parallel worker reads 2D codes (QR / Data Matrix / PDF417) directly off the
464+ // whole frame — they self-localize, so they do not need the coarse locator, which fixes
465+ // the case where a QR is in view but the locator never boxes it. It runs on a
466+ // half-resolution copy (fast, and big enough for a held-up code) and hands back each
467+ // code's box in full-frame pixels. Kept separate so its heavier full-frame pass never
468+ // stalls the crop worker's snappy 1D decoding.
469+ const worker2dSrc = `
470+ let w; const mem = () => new Uint8Array(w.memory.buffer);
471+ const ready = fetch(${ JSON . stringify ( WASM_URL ) } ).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
472+ self.onmessage = async (e) => {
473+ await ready;
474+ const { width, height, luma, scale } = e.data;
475+ const p = w.alloc(luma.length); mem().set(luma, p);
476+ const pk = w.decode2d(width, height, p); w.dealloc(p, luma.length);
477+ const ptr = Number(pk >> 32n), len = Number(pk & 0xffffffffn);
478+ const bytes = mem().slice(ptr, ptr + len); w.dealloc(ptr, len);
479+ const res = JSON.parse(new TextDecoder().decode(bytes) || '[]');
480+ self.postMessage(res.map(r => ({
481+ symbology: r.symbology, text: r.text,
482+ box: r.box ? { x0: r.box.x0 * scale, y0: r.box.y0 * scale, x1: r.box.x1 * scale, y1: r.box.y1 * scale } : null,
483+ })));
484+ };
485+ ` ;
486+ const worker2d = new Worker ( URL . createObjectURL ( new Blob ( [ worker2dSrc ] , { type : 'text/javascript' } ) ) ) ;
487+ worker2d . onmessage = ( e ) => { worker2dBusy = false ; mergeDecoded ( e . data , performance . now ( ) ) ; } ;
488+
489+ // Box-average a luma frame down 2x — a cheap, safe downscale for the full-frame 2D pass.
490+ function downscale2 ( luma , w , h ) {
491+ const w2 = w >> 1 , h2 = h >> 1 , out = new Uint8Array ( w2 * h2 ) ;
492+ for ( let y = 0 ; y < h2 ; y ++ ) {
493+ const s0 = ( 2 * y ) * w , s1 = ( 2 * y + 1 ) * w , d = y * w2 ;
494+ for ( let x = 0 ; x < w2 ; x ++ ) { const a = 2 * x ; out [ d + x ] = ( luma [ s0 + a ] + luma [ s0 + a + 1 ] + luma [ s1 + a ] + luma [ s1 + a + 1 ] ) >> 2 ; }
495+ }
496+ return { luma : out , w : w2 , h : h2 } ;
497+ }
498+
460499function bbox ( corners ) {
461500 const xs = corners . map ( p => p [ 0 ] ) , ys = corners . map ( p => p [ 1 ] ) ;
462501 return { x0 : Math . min ( ...xs ) , y0 : Math . min ( ...ys ) , x1 : Math . max ( ...xs ) , y1 : Math . max ( ...ys ) } ;
@@ -596,7 +635,7 @@ <h2>Decode an image</h2>
596635 document . getElementById ( 'cam-btn' ) . disabled = true ;
597636 document . getElementById ( 'cam-stop' ) . disabled = false ;
598637 document . getElementById ( 'cam-capture' ) . disabled = false ;
599- running = true ; workerBusy = false ; lastSent = 0 ; decodedTracks . clear ( ) ; candTracks = [ ] ; requestAnimationFrame ( loop ) ;
638+ running = true ; workerBusy = false ; worker2dBusy = false ; lastSent = 0 ; last2d = 0 ; decodedTracks . clear ( ) ; candTracks = [ ] ; requestAnimationFrame ( loop ) ;
600639} ;
601640document . getElementById ( 'cam-stop' ) . onclick = ( ) => {
602641 running = false ; stream && stream . getTracks ( ) . forEach ( t => t . stop ( ) ) ;
@@ -678,13 +717,25 @@ <h2>Decode an image</h2>
678717 }
679718 if ( best ) { tr . target = best ; tr . lastSeen = ts ; }
680719 }
681- // Decode off-thread: send a frame only when the worker is free, so a slow scan
682- // throttles itself and can never stall the UI.
683- if ( ! workerBusy && ts - lastSent > 120 ) {
684- lastSent = ts ; workerBusy = true ;
720+ // Decode off-thread on two parallel workers, each dispatched only when free so a slow
721+ // scan throttles itself and never stalls the UI: the crop worker (1D + located
722+ // regions, every ~120ms) and the full-frame 2D worker (self-localizing QR/2D on a
723+ // half-res copy, every ~400ms). Both read the one luma we build here; the 2D downscale
724+ // happens before the crop worker transfers the buffer away.
725+ const wantCrop = ! workerBusy && ts - lastSent > 120 ;
726+ const want2d = ! worker2dBusy && ts - last2d > 400 ;
727+ if ( wantCrop || want2d ) {
685728 const { luma } = lumaFrom ( img ) ;
686- const crops = liveCands . map ( c => { const b = bbox ( c . corners ) ; return { x0 : b . x0 - 16 , y0 : b . y0 - 16 , x1 : b . x1 + 16 , y1 : b . y1 + 16 } ; } ) ;
687- decodeWorker . postMessage ( { width : pw , height : ph , luma, crops } , [ luma . buffer ] ) ;
729+ if ( want2d ) {
730+ last2d = ts ; worker2dBusy = true ;
731+ const d = downscale2 ( luma , pw , ph ) ;
732+ worker2d . postMessage ( { width : d . w , height : d . h , luma : d . luma , scale : 2 } , [ d . luma . buffer ] ) ;
733+ }
734+ if ( wantCrop ) {
735+ lastSent = ts ; workerBusy = true ;
736+ const crops = liveCands . map ( c => { const b = bbox ( c . corners ) ; return { x0 : b . x0 - 16 , y0 : b . y0 - 16 , x1 : b . x1 + 16 , y1 : b . y1 + 16 } ; } ) ;
737+ decodeWorker . postMessage ( { width : pw , height : ph , luma, crops } , [ luma . buffer ] ) ;
738+ }
688739 }
689740 }
690741
0 commit comments