@@ -172,15 +172,14 @@ <h2>Decode an image</h2>
172172 luma [ i ] = ( data [ j ] * 299 + data [ j + 1 ] * 587 + data [ j + 2 ] * 114 ) / 1000 | 0 ;
173173 return { w, h, luma } ;
174174}
175- function callJson ( fn , imageData ) {
176- const { w, h, luma } = lumaFrom ( imageData ) ;
175+ function callJson ( fn , w , h , luma ) {
177176 const ptr = writeBytes ( luma ) ;
178177 const packed = fn ( w , h , ptr ) ;
179178 wasm . dealloc ( ptr , luma . length ) ;
180179 return JSON . parse ( new TextDecoder ( ) . decode ( takeBytes ( packed ) ) || '[]' ) ;
181180}
182- const decode = ( img ) => callJson ( wasm . decode , img ) ;
183- const locate = ( img ) => callJson ( wasm . locate , img ) ;
181+ const decode = ( img ) => { const { w , h , luma } = lumaFrom ( img ) ; return callJson ( wasm . decode , w , h , luma ) ; } ;
182+ const locateLuma = ( w , h , luma ) => callJson ( wasm . locate , w , h , luma ) ;
184183
185184// ---- encode UI ----
186185const encCanvas = document . getElementById ( 'enc-canvas' ) ;
@@ -430,8 +429,10 @@ <h2>Decode an image</h2>
430429
431430// Decoding runs in a background worker (its own wasm instance) so the heavy sampler
432431// can never block the UI thread — the live overlay stays smooth even when a scan is
433- // slow. The main thread only sends a frame when the worker is free. It reuses the
434- // cache-busted WASM_URL defined above so the worker loads the same fresh build.
432+ // slow. The main thread only sends a frame when the worker is free, transferring the
433+ // raw RGBA (zero-copy); the worker converts *only the crop pixels* to luminance, so
434+ // the main thread never pays for a full-frame conversion. It reuses the cache-busted
435+ // WASM_URL defined above so the worker loads the same fresh build.
435436const workerSrc = `
436437let w; const mem = () => new Uint8Array(w.memory.buffer);
437438const ready = fetch(${ JSON . stringify ( WASM_URL ) } ).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
@@ -440,14 +441,19 @@ <h2>Decode an image</h2>
440441function decLuma(cw, ch, luma){ const p = wr(luma); const pk = w.decode(cw, ch, p); w.dealloc(p, luma.length); return JSON.parse(new TextDecoder().decode(take(pk)) || '[]'); }
441442self.onmessage = async (e) => {
442443 await ready;
443- const { width, height, luma, crops } = e.data;
444+ const { width, height, rgba, crops } = e.data;
445+ const data = new Uint8Array(rgba);
444446 const seen = new Set(), found = [];
445447 const add = (r, box) => { const k = r.symbology + '|' + r.text; if (!seen.has(k)) { seen.add(k); found.push({ symbology: r.symbology, text: r.text, box }); } };
446448 for (const b of crops.slice(0, 6)) {
447449 const x0 = Math.max(0, b.x0 | 0), y0 = Math.max(0, b.y0 | 0), x1 = Math.min(width, Math.ceil(b.x1)), y1 = Math.min(height, Math.ceil(b.y1));
448450 const cw = x1 - x0, ch = y1 - y0; if (cw < 12 || ch < 12) continue;
449451 const crop = new Uint8Array(cw * ch);
450- for (let y = 0; y < ch; y++) { const s = (y0 + y) * width + x0; crop.set(luma.subarray(s, s + cw), y * cw); }
452+ for (let y = 0; y < ch; y++) {
453+ let s = ((y0 + y) * width + x0) * 4;
454+ const d = y * cw;
455+ for (let x = 0; x < cw; x++, s += 4) crop[d + x] = (data[s] * 299 + data[s + 1] * 587 + data[s + 2] * 114) / 1000 | 0;
456+ }
451457 for (const r of decLuma(cw, ch, crop)) add(r, b);
452458 }
453459 self.postMessage(found);
@@ -462,10 +468,11 @@ <h2>Decode an image</h2>
462468
463469// A second, parallel worker reads 2D codes (QR / Data Matrix / PDF417) directly off the
464470// 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.
471+ // the case where a QR is in view but the locator never boxes it. It reuses the locate
472+ // pass's half-resolution luma (transferred, zero extra conversion — fast, and big
473+ // enough for a held-up code) and hands back each code's box in full-frame pixels. Kept
474+ // separate so its heavier full-frame pass never stalls the crop worker's snappy 1D
475+ // decoding.
469476const worker2dSrc = `
470477let w; const mem = () => new Uint8Array(w.memory.buffer);
471478const ready = fetch(${ JSON . stringify ( WASM_URL ) } ).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
@@ -486,33 +493,10 @@ <h2>Decode an image</h2>
486493const worker2d = new Worker ( URL . createObjectURL ( new Blob ( [ worker2dSrc ] , { type : 'text/javascript' } ) ) ) ;
487494worker2d . onmessage = ( e ) => { worker2dBusy = false ; mergeDecoded ( e . data , performance . now ( ) ) ; } ;
488495
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-
499496function bbox ( corners ) {
500497 const xs = corners . map ( p => p [ 0 ] ) , ys = corners . map ( p => p [ 1 ] ) ;
501498 return { x0 : Math . min ( ...xs ) , y0 : Math . min ( ...ys ) , x1 : Math . max ( ...xs ) , y1 : Math . max ( ...ys ) } ;
502499}
503- // Copy a padded sub-rectangle of an ImageData (for decoding a located region at full res).
504- function cropRGBA ( img , x0 , y0 , x1 , y1 ) {
505- x0 = Math . max ( 0 , x0 | 0 ) ; y0 = Math . max ( 0 , y0 | 0 ) ;
506- x1 = Math . min ( img . width , Math . ceil ( x1 ) ) ; y1 = Math . min ( img . height , Math . ceil ( y1 ) ) ;
507- const cw = x1 - x0 , ch = y1 - y0 ;
508- if ( cw < 12 || ch < 12 ) return null ;
509- const out = new ImageData ( cw , ch ) ;
510- for ( let y = 0 ; y < ch ; y ++ ) {
511- const s = ( ( y0 + y ) * img . width + x0 ) * 4 ;
512- out . data . set ( img . data . subarray ( s , s + cw * 4 ) , y * cw * 4 ) ;
513- }
514- return out ;
515- }
516500// ---- futuristic target-lock HUD ----
517501const HUD = { lock : '#5be9d4' , acquire : '#f5a623' , chip : '#ffc24b' , text : '#eafffb' , ink : 'rgba(6,13,17,0.80)' } ;
518502const REDUCED = matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches ;
@@ -680,28 +664,34 @@ <h2>Decode an image</h2>
680664} ;
681665
682666let liveCands = [ ] , acquiring = [ ] , liveScale = [ 1 , 1 ] , lastLocate = 0 , lastMs = 0 ;
667+ const locCanvas = document . createElement ( 'canvas' ) ;
683668function loop ( ts ) {
684669 if ( ! running ) { return ; }
685670 requestAnimationFrame ( loop ) ;
686671 const vw = video . videoWidth , vh = video . videoHeight ;
687672 if ( ! vw ) return ;
688673 const pw = Math . min ( vw , 1920 ) , ph = Math . round ( pw * vh / vw ) ;
689674
690- // Grab + locate at ~30 fps (getImageData at 1080p is the heavy main-thread step);
691- // the HUD still renders every frame below, so the overlay stays smooth.
675+ // Grab + locate at ~30 fps on a GPU-downscaled half-res frame: getImageData and the
676+ // RGBA→luma conversion — the two heavy main-thread steps — cost 4× less than at full
677+ // resolution, and the wasm locator compensates by skipping its own downscale on the
678+ // smaller input, so the reduced image it analyses (and detection quality) is
679+ // unchanged. The full-resolution grab happens only when the crop worker is actually
680+ // dispatched (~8×/s), never on the steady locate tick.
692681 if ( ts - lastLocate > 33 ) {
693682 lastLocate = ts ;
694- scratch . width = pw ; scratch . height = ph ;
695- const sctx = scratch . getContext ( '2d' , { willReadFrequently : true } ) ;
696- sctx . drawImage ( video , 0 , 0 , pw , ph ) ;
697- const img = sctx . getImageData ( 0 , 0 , pw , ph ) ;
698- const frameArea = pw * ph ;
683+ const lw = pw >> 1 , lh = ph >> 1 , sc = pw / lw ;
684+ if ( locCanvas . width !== lw ) locCanvas . width = lw ;
685+ if ( locCanvas . height !== lh ) locCanvas . height = lh ;
686+ const lctx = locCanvas . getContext ( '2d' , { willReadFrequently : true } ) ;
687+ lctx . drawImage ( video , 0 , 0 , lw , lh ) ;
699688 const t0 = performance . now ( ) ;
700- // Drop background-sized boxes: a code is a bounded object, not half the scene.
701- liveCands = locate ( img ) . filter ( c => {
702- const b = bbox ( c . corners ) ;
703- return ( b . x1 - b . x0 ) * ( b . y1 - b . y0 ) < 0.5 * frameArea ;
704- } ) ;
689+ const { luma } = lumaFrom ( lctx . getImageData ( 0 , 0 , lw , lh ) ) ;
690+ // Corners come back in half-res pixels; lift them to processed (full-res) space so
691+ // tracks, crops and the HUD all share one coordinate system. No area filter here:
692+ // the locator itself caps scene-sized blobs, and — unlike the old JS filter — it
693+ // knows a finder-backed box is a close-up code, not background.
694+ liveCands = locateLuma ( lw , lh , luma ) . map ( c => ( { family : c . family , corners : c . corners . map ( p => [ p [ 0 ] * sc , p [ 1 ] * sc ] ) } ) ) ;
705695 lastMs = performance . now ( ) - t0 ;
706696 liveScale = [ pw , ph ] ;
707697 updateCandTracks ( liveCands , ts ) ;
@@ -719,23 +709,27 @@ <h2>Decode an image</h2>
719709 }
720710 // Decode off-thread on two parallel workers, each dispatched only when free so a slow
721711 // 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 ;
712+ // regions, every ~120ms, fed the full-res RGBA by transfer) and the full-frame 2D
713+ // worker (self-localizing QR/2D, every ~400ms, fed this tick's half-res luma by
714+ // transfer). Order matters: the 2D dispatch reuses `luma` before it is detached.
726715 const want2d = ! worker2dBusy && ts - last2d > 400 ;
727- if ( wantCrop || want2d ) {
728- const { luma } = lumaFrom ( img ) ;
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- }
716+ if ( want2d ) {
717+ last2d = ts ; worker2dBusy = true ;
718+ worker2d . postMessage ( { width : lw , height : lh , luma, scale : sc } , [ luma . buffer ] ) ;
719+ }
720+ const wantCrop = ! workerBusy && ts - lastSent > 120 ;
721+ if ( wantCrop ) {
722+ lastSent = ts ; workerBusy = true ;
723+ if ( scratch . width !== pw ) scratch . width = pw ;
724+ if ( scratch . height !== ph ) scratch . height = ph ;
725+ const sctx = scratch . getContext ( '2d' , { willReadFrequently : true } ) ;
726+ sctx . drawImage ( video , 0 , 0 , pw , ph ) ;
727+ const img = sctx . getImageData ( 0 , 0 , pw , ph ) ;
728+ 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 } ; } ) ;
729+ // No candidate this tick (e.g. motion blur wiped the texture pass) — still try
730+ // the centre band, where a user aiming at a code will have put it.
731+ if ( ! crops . length ) crops . push ( { x0 : 0 , y0 : ph * 0.2 , x1 : pw , y1 : ph * 0.8 } ) ;
732+ decodeWorker . postMessage ( { width : pw , height : ph , rgba : img . data . buffer , crops } , [ img . data . buffer ] ) ;
739733 }
740734 }
741735
@@ -753,7 +747,10 @@ <h2>Decode an image</h2>
753747 lastDecoded . length ? lastDecoded . map ( r => `${ r . symbology } : ${ r . text } ` ) . join ( '\n' ) : '' ;
754748
755749 // HUD every frame (cheap: canvas only) for a smooth reticle/scan animation.
756- overlay . width = video . clientWidth ; overlay . height = video . clientHeight ;
750+ // Resizing a canvas reallocates its backing store — only do it when the layout
751+ // actually changed.
752+ if ( overlay . width !== video . clientWidth ) overlay . width = video . clientWidth ;
753+ if ( overlay . height !== video . clientHeight ) overlay . height = video . clientHeight ;
757754 const octx = overlay . getContext ( '2d' ) ;
758755 octx . clearRect ( 0 , 0 , overlay . width , overlay . height ) ;
759756 renderHUD ( octx , overlay . width , overlay . height , acquiring , lastDecoded , overlay . width / liveScale [ 0 ] , overlay . height / liveScale [ 1 ] , ts ) ;
0 commit comments