From 0b19f2748fe71e7e2a276843f75870ab3a7d8d12 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:23:55 -0600 Subject: [PATCH 01/37] encoder: keep matrix words in Int32Array Every consumer of the packed matrix is bitwise or popcount, so the sign of a word never matters, but its representation does: a v1 symbol never sets bit 31, so an engine that encodes v1 first specializes on int32, and the first full word read back from a Uint32Array arrives as a double that deoptimizes the mask race for the rest of the process. Signed words stay int32 in every case, the masks become ~(-1 << bits) and -1, and the >>> 0 coercions go. Benchmark (bun, encode raw, ECC medium, v1 first in the process): v1 2.73 -> 2.59 us, v3 5.58 -> 4.53, v8 17.5 -> 15.7, v18 48.9 -> 43.6. --- src/index.ts | 50 +++++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index 81a1549..71bfc9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -361,31 +361,35 @@ const POP16: Uint8Array = /* @__PURE__ */ (() => { })(); const popcnt = (n: number): number => POP16[n & 0xffff] + POP16[n >>> 16]; -const TRANSPOSE_TMP = /* @__PURE__ */ new Uint32Array(32); +const TRANSPOSE_TMP = /* @__PURE__ */ new Int32Array(32); // 32x32 in-place bit-matrix transpose (butterfly network). -function transpose32(a: Uint32Array): void { +function transpose32(a: Int32Array): void { const masks = [0x55555555, 0x33333333, 0x0f0f0f0f, 0x00ff00ff, 0x0000ffff]; for (let stage = 0; stage < 5; stage++) { - const m = masks[stage] >>> 0; + const m = masks[stage]; const s = 1 << stage; for (let i = 0; i < 32; i += s << 1) { for (let k = 0; k < s; k++) { - const x = a[i + k] >>> 0; - const y = a[i + k + s] >>> 0; + const x = a[i + k]; + const y = a[i + k + s]; const t = ((x >>> s) ^ y) & m; - a[i + k] = (x ^ (t << s)) >>> 0; - a[i + k + s] = (y ^ t) >>> 0; + a[i + k] = x ^ (t << s); + a[i + k + s] = y ^ t; } } } } -// Packed square bit matrix: LSB-first bits, `words` u32 per row. Bits at +// Packed square bit matrix: LSB-first bits, `words` i32 per row. Bits at // x >= size are kept zero — the penalty scanners rely on that invariant. -type Mat = { size: number; words: number; v: Uint32Array }; +// Signed words on purpose: every consumer is bitwise or popcount, and a +// v1 symbol never sets bit 31, so an engine that meets v1 first specializes +// on int32; the first full word from a Uint32Array then arrives as a double +// and the recompiled mixed-type code stays slower for the whole process. +type Mat = { size: number; words: number; v: Int32Array }; const mat = (size: number): Mat => { const words = (size + 31) >>> 5; - return { size, words, v: new Uint32Array(words * size) }; + return { size, words, v: new Int32Array(words * size) }; }; const matGet = (m: Mat, x: number, y: number): number => (m.v[y * m.words + (x >>> 5)] >>> (x & 31)) & 1; @@ -416,12 +420,12 @@ function transposeMat(src: Mat, dst: Mat): void { // each run contributes (L-4) windows plus one run-start window counted twice. function runsPenaltyVertical(m: Mat): number { const { size, words, v } = m; - const tail = size & 31 ? ((1 << (size & 31)) - 1) >>> 0 : 0xffffffff; + const tail = size & 31 ? ~(-1 << (size & 31)) : -1; let score = 0; for (let wi = 0; wi < words; wi++) { - const valid = wi === words - 1 ? tail : 0xffffffff; + const valid = wi === words - 1 ? tail : -1; let r3 = v[3 * words + wi]; - let dPrev = 0xffffffff; + let dPrev = -1; let d0 = v[wi] ^ v[words + wi]; let d1 = v[words + wi] ^ v[2 * words + wi]; let d2 = v[2 * words + wi] ^ r3; @@ -429,7 +433,7 @@ function runsPenaltyVertical(m: Mat): number { const r4 = v[idx]; const d3 = r3 ^ r4; const w = ~(d0 | d1 | d2 | d3) & valid; - if (w) score += popcnt(w >>> 0) + 2 * popcnt((w & dPrev) >>> 0); + if (w) score += popcnt(w) + 2 * popcnt(w & dPrev); dPrev = d0; d0 = d1; d1 = d2; @@ -444,10 +448,10 @@ function runsPenaltyVertical(m: Mat): number { // both patterns at once across a 32-column stripe. function finderPenaltyVertical(m: Mat): number { const { size, words, v } = m; - const tail = size & 31 ? ((1 << (size & 31)) - 1) >>> 0 : 0xffffffff; + const tail = size & 31 ? ~(-1 << (size & 31)) : -1; let count = 0; for (let wi = 0; wi < words; wi++) { - const valid = wi === words - 1 ? tail : 0xffffffff; + const valid = wi === words - 1 ? tail : -1; for (let y = 0; y <= size - 11; y++) { let i = y * words + wi; const r0 = v[i]; @@ -463,7 +467,7 @@ function finderPenaltyVertical(m: Mat): number { const r10 = v[i + words]; const m0 = valid & r0 & ~r1 & r2 & r3 & r4 & ~r5 & r6 & ~(r7 | r8 | r9 | r10); const m1 = valid & ~(r0 | r1 | r2 | r3) & r4 & ~r5 & r6 & r7 & r8 & ~r9 & r10; - count += popcnt(m0 >>> 0) + popcnt(m1 >>> 0); + count += popcnt(m0) + popcnt(m1); } } return count; @@ -487,13 +491,13 @@ function penaltyScore(m: Mat, t: Mat, limit: number = Infinity): number { if (adjacent >= limit) return adjacent; // N2: 3 points per 2x2 same-color box (overlapping). Valid left-edge // positions in the last word: one less than the bits it actually holds. - const tail2 = ((1 << (size - 32 * (words - 1) - 1)) - 1) >>> 0; + const tail2 = ~(-1 << (size - 32 * (words - 1) - 1)); let boxes = 0; let dark = 0; for (let y = 0; y < size; y++) { for (let wi = 0; wi < words; wi++) { const a0 = v[y * words + wi]; - dark += popcnt(a0 >>> 0); + dark += popcnt(a0); if (y === size - 1) continue; const a1 = v[(y + 1) * words + wi]; const n0 = wi + 1 < words ? v[y * words + wi + 1] : 0; @@ -503,7 +507,7 @@ function penaltyScore(m: Mat, t: Mat, limit: number = Infinity): number { const eqH1 = ~(a1 ^ ((a1 >>> 1) | (n1 << 31))); let w = eqV & eqH0 & eqH1; if (wi === words - 1) w &= tail2; - boxes += popcnt(w >>> 0); + boxes += popcnt(w); } } const total = size * size; @@ -550,10 +554,10 @@ function drawInfo(m: Mat, ver: number, ecc: ErrorCorrection, mask: number): void // overwhelmingly encode one version repeatedly; worst case (v40) ~190KB. type SymCache = { ver: number; - tpl: Uint32Array; + tpl: Int32Array; pos: Uint16Array; - planes: Uint32Array[]; - planesT: Uint32Array[]; + planes: Int32Array[]; + planesT: Int32Array[]; work: [Mat, Mat, Mat, Mat]; }; let symCache: SymCache | undefined; From 2d1e1b7eb90a1f859f2fcff22cec03af52b8338d Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:24:43 -0600 Subject: [PATCH 02/37] encoder: roll the N3 window down each stripe finderPenaltyVertical reloaded eleven matrix words at every row step. The ten words of the window now load once per word column and each step loads one new word and shifts the rest, one load per row instead of eleven, on the scan that dominates whenever the early-out does not fire. Benchmark (bun, encode raw, ECC medium): v1 2.59 -> 2.53 us, v3 4.53 -> 4.18, v8 15.7 -> 14.1, v18 43.6 -> 41.5. --- src/index.ts | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 71bfc9b..9e226ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -452,22 +452,34 @@ function finderPenaltyVertical(m: Mat): number { let count = 0; for (let wi = 0; wi < words; wi++) { const valid = wi === words - 1 ? tail : -1; + // The eleven-row window rolls down the stripe: ten words load once per + // column, then each row step loads one new word and shifts the rest. + let i = wi; + let r0 = v[i]; + let r1 = v[(i += words)]; + let r2 = v[(i += words)]; + let r3 = v[(i += words)]; + let r4 = v[(i += words)]; + let r5 = v[(i += words)]; + let r6 = v[(i += words)]; + let r7 = v[(i += words)]; + let r8 = v[(i += words)]; + let r9 = v[(i += words)]; for (let y = 0; y <= size - 11; y++) { - let i = y * words + wi; - const r0 = v[i]; - const r1 = v[(i += words)]; - const r2 = v[(i += words)]; - const r3 = v[(i += words)]; - const r4 = v[(i += words)]; - const r5 = v[(i += words)]; - const r6 = v[(i += words)]; - const r7 = v[(i += words)]; - const r8 = v[(i += words)]; - const r9 = v[(i += words)]; - const r10 = v[i + words]; + const r10 = v[(i += words)]; const m0 = valid & r0 & ~r1 & r2 & r3 & r4 & ~r5 & r6 & ~(r7 | r8 | r9 | r10); const m1 = valid & ~(r0 | r1 | r2 | r3) & r4 & ~r5 & r6 & r7 & r8 & ~r9 & r10; count += popcnt(m0) + popcnt(m1); + r0 = r1; + r1 = r2; + r2 = r3; + r3 = r4; + r4 = r5; + r5 = r6; + r6 = r7; + r7 = r8; + r8 = r9; + r9 = r10; } } return count; From dbfc610d6ea22519075db67ae344f768a74df1fb Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:25:39 -0600 Subject: [PATCH 03/37] encoder: mask predicates from a 72-entry table Every Table 10 mask predicate repeats every 6 columns and 12 rows, so the 8-bit predicate vector for a module is a lookup filled once from the arithmetic instead of six modulos per call. The encoder pays it once per version when it builds the mask planes; the decoder pays it per module when it unmasks a read grid, which is where it shows. Benchmark (bun): decode raster v1 151 -> 141 us, raster v18 419 -> 377; encode within noise. --- src/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9e226ab..69758b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -336,7 +336,7 @@ function encodeData( * 8-bit vector (bit m set when mask predicate m fires at x,y). Shared with * the decoder, which tests a single mask's bit to unmask read modules. */ -function maskBits(x: number, y: number): number { +function maskCalc(x: number, y: number): number { const x2 = x % 2; const y2 = y % 2; const x3 = x % 3; @@ -353,6 +353,16 @@ function maskBits(x: number, y: number): number { if (((x2 ^ y2) + xy3) % 2 === 0) bits |= 128; return bits; } +// Every predicate is periodic in 6 columns and 12 rows, so the vector is a +// 72-entry lookup filled once from the arithmetic. +const MASK_TABLE: Uint8Array = /* @__PURE__ */ (() => { + const t = new Uint8Array(72); + for (let y = 0; y < 12; y++) for (let x = 0; x < 6; x++) t[y * 6 + x] = maskCalc(x, y); + return t; +})(); +function maskBits(x: number, y: number): number { + return MASK_TABLE[(y % 12) * 6 + (x % 6)]; +} const POP16: Uint8Array = /* @__PURE__ */ (() => { const t = new Uint8Array(1 << 16); From 85081b37ab3faad315f24cdc000567bc8f795504 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:26:20 -0600 Subject: [PATCH 04/37] encoder: build only the shorter SVG move command renderSvg built both the absolute and the relative move string for every dark module and compared their lengths, then allocated a point per module to remember the previous one. The lengths are now counted from the digit counts of the coordinates, only the winning command is built, and the previous position is two numbers. Ties still go to the relative move, so the output is byte-identical. Benchmark (bun, encode svg, ECC medium): v1 5.73 -> 4.75 us, v3 10.4 -> 8.62, v8 32.5 -> 27.3, v18 102 -> 85.5. --- src/index.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 69758b3..75d2c69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -844,11 +844,19 @@ function renderTerm(r: Raster): string { return out; } +// Character counts of a path coordinate and of a signed move offset, so the +// shorter move command is chosen without building both. +const digits = (n: number): number => + n < 10 ? 1 : n < 100 ? 2 : n < 1000 ? 3 : n < 10000 ? 4 : String(n).length; +const chars = (d: number): number => (d < 0 ? 1 + digits(-d) : digits(d)); + function renderSvg(r: Raster, optimize: boolean): string { const W = r.W; let out = ``; let pathData = ''; - let prev: { x: number; y: number } | undefined; + let prevX = 0; + let prevY = 0; + let hasPrev = false; for (let y = 0; y < W; y++) { for (let x = 0; x < W; x++) { if (!dark(r, x, y)) continue; @@ -856,13 +864,17 @@ function renderSvg(r: Raster, optimize: boolean): string { out += ``; continue; } - let mv = `M${x} ${y}`; - if (prev) { - const rel = `m${x - prev.x} ${y - prev.y}`; - if (rel.length <= mv.length) mv = rel; - } + // The shorter move wins, relative on ties; only the winner is built. + let mv: string; + if (hasPrev) { + const dx = x - prevX; + const dy = y - prevY; + mv = chars(dx) + chars(dy) <= digits(x) + digits(y) ? `m${dx} ${dy}` : `M${x} ${y}`; + } else mv = `M${x} ${y}`; pathData += `${mv}h1v1${x < 10 ? `H${x}` : 'h-1'}Z`; - prev = { x, y }; + prevX = x; + prevY = y; + hasPrev = true; } } if (optimize) out += ``; From f19106612db0e97ac96b9083f24bef8833341449 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:26:58 -0600 Subject: [PATCH 05/37] encoder: copy GIF spans with a byte loop Each LZW span, at most 126 pixels, was copied through a subarray view, one allocation per span. A byte loop copies the same bytes with none. Benchmark (bun, encode gif, ECC medium): v1 2.96 -> 2.50 us, v3 4.85 -> 4.08, v18 42.0 -> 40.8; v8 within noise. --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 75d2c69..37aaeaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -930,7 +930,8 @@ function renderGif(r: Raster): Uint8Array { out[p++] = 0x80; // LZW clear code } const n = Math.min(N - (i % N), W - x); - out.set(row.subarray(x, x + n), p); + // A byte loop: a subarray view per span costs more than the copy. + for (let k = 0; k < n; k++) out[p + k] = row[x + k]; p += n; x += n; i += n; From 8c52886b144f0a6aa64274c4ec530c026ce31665 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:30:26 -0600 Subject: [PATCH 06/37] decoder: create payload views on first use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scanner eagerly built one Uint8Array view per possible byte-segment length (2957 of them for the version 40 arena), so each decodeQR call paid for thousands of view objects it never touched. Create a view the first time a segment of that length is decoded and keep it for reuse. Before → after (bun, M-series): raster v1 149 µs → 63 µs raster v18 395 µs → 296 µs 720p 1.92 ms → 1.66 ms 1080p 4.01 ms → 3.79 ms --- src/decode.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index c9a3291..9277991 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -245,8 +245,9 @@ type PayloadState = { const Payload = { create(capacity: number): PayloadState { const bytes = new Uint8Array(capacity); + // One prefix view per length, created on first use: a scanner that + // never decodes a byte segment of that length never allocates it. const views = new Array(capacity + 1); - for (let i = 0; i < views.length; i++) views[i] = new Uint8Array(bytes.buffer, 0, i); let state: PayloadState; const read = (bits: number) => { const start = state.position; @@ -332,9 +333,12 @@ const Payload = { } else { const encoding = ECI_ENCODINGS[eci]; if (!encoding || length >= state.views.length) return FAIL.data; + const view = + state.views[length] ?? + (state.views[length] = new Uint8Array(state.bytes.buffer, 0, length)); const decoder = ECI_DECODERS[eci] || new TextDecoder(encoding); for (let i = 0; i < length; i++) state.bytes[i] = read(8); - res += decoder.decode(state.views[length]); + res += decoder.decode(view); } } else return FAIL.data; } From 5829702ddae1f660841c6e8c1e1748341a3eaf33 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:32:06 -0600 Subject: [PATCH 07/37] decoder: keep bitmap words in Int32Array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed bitmap lived in a Uint32Array, so every word with its top bit set came back as a double and the run walker and bitmap writer paid for `>>> 0` coercions on each step. Int32Array keeps every word a machine integer end to end; the masks and shifts are unchanged in meaning, and Math.clz32 already reads its argument as unsigned. Before → after (bun, M-series): raster v1 63 µs → 58 µs raster v18 296 µs → 280 µs 720p 1.66 ms → 1.61 ms 1080p 3.79 ms → 3.74 ms 1080p miss, noise 38.0 ms → 26.7 ms 1080p miss, stripes 10.9 ms → 9.7 ms --- src/decode.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 9277991..3801622 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -96,7 +96,7 @@ export type QRScannerOpts = DecodeOpts & { /** Internal row layout used by DOM VideoFrame ingestion. */ export type _QRLayout = { offset: number; stride: number }; export type _QRLayer = { - bitmap: Uint32Array; + bitmap: Int32Array; blockHeight: number; blockWidth: number; blocks: Uint8Array; @@ -678,10 +678,10 @@ const run = ( const shift = x & 31; // 1-bits of `stops` mark where the run ends; windowed toward the walk direction so // clz32 (left) or the isolated lowest bit (right) yields the matching-bit count. - const stops = (color ? ~layer.bitmap[row + (x >>> 5)] : layer.bitmap[row + (x >>> 5)]) >>> 0; - const w = dx > 0 ? stops >>> shift : (stops << (31 - shift)) >>> 0; + const stops = color ? ~layer.bitmap[row + (x >>> 5)] : layer.bitmap[row + (x >>> 5)]; + const w = dx > 0 ? stops >> shift : stops << (31 - shift); const span = dx > 0 ? Math.min(32 - shift, layer.width - x) : shift + 1; - const first = !w ? 32 : dx > 0 ? 31 - Math.clz32((w & -w) >>> 0) : Math.clz32(w); + const first = !w ? 32 : dx > 0 ? 31 - Math.clz32(w & -w) : Math.clz32(w); const len = Math.min(first, span); n += len; x += dx * len; @@ -959,12 +959,12 @@ const scanRows = { for (let xx = 0; xx < block; xx++) value |= +(brightness[pos + xx] <= average) << xx; const shift = xPos & 31; const word = (yPos + yy) * layer.words + (xPos >>> 5); - const lowMask = (0xff << shift) >>> 0; - layer.bitmap[word] = ((layer.bitmap[word] & ~lowMask) | ((value << shift) >>> 0)) >>> 0; + const lowMask = 0xff << shift; + layer.bitmap[word] = (layer.bitmap[word] & ~lowMask) | (value << shift); if (shift > 24) { const highMask = (1 << (shift - 24)) - 1; layer.bitmap[word + 1] = - ((layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift))) >>> 0; + (layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift)); } pos += layer.width; } @@ -1182,7 +1182,7 @@ export class _QRScanner { // Native-resolution descriptor for fine re-sampling from this layer (undefined on layer 0). const fine = i ? { luma: this.image, r: i } : undefined; layers.push({ - bitmap: new Uint32Array(Math.ceil(width / 32) * height), + bitmap: new Int32Array(Math.ceil(width / 32) * height), blockHeight: 0, blockWidth: 0, blocks, From d98a39ba1f583176f0794263b2e7395ce53e0254 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:33:02 -0600 Subject: [PATCH 08/37] decoder: walk finder runs straight off the packed row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finder scan called run() once per run, re-deriving the row base and bounds checks on every call and clamping through an Infinity cap. Measure each run inline instead: isolate the word's opposite-color bits with clz32 and consume whole words until a stop or the row's end. Also skip ratio() when the center run is no longer than a neighbor, which ratio() would reject anyway (center > 1.5 modules, neighbors < 1.5), so results are identical. Before → after (bun, M-series): raster v1 58 µs → 48 µs raster v18 280 µs → 230 µs 720p 1.61 ms → 1.47 ms 1080p 3.74 ms → 3.32 ms 1080p miss, noise 26.7 ms → 11.2 ms 1080p miss, stripes 9.7 ms → 5.5 ms --- src/decode.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 3801622..ca8c4c6 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -975,7 +975,11 @@ const scanRows = { find(layer: ScannerLayer, from: number, to: number) { // Rolling window over each row's run-length encoding (no per-row arrays): // check every 5-run window that starts, centers, and ends on a black run. - // run() always advances (previous matches the bit at x by construction). + // Each run is measured straight off the packed row: the word's opposite-color bits + // are isolated with clz32 and whole words are consumed until a stop or the row's end. + const width = layer.width; + const words = layer.words; + const bitmap = layer.bitmap; for (let y = from; y < to; y += 2) { let r0 = 0; let r1 = 0; @@ -983,10 +987,22 @@ const scanRows = { let r3 = 0; let r4 = 0; let runs = 0; - let previous = !!bit(layer, 0, y | 0); - for (let x = 0; x < layer.width;) { - const length = run(layer, x, y, 1, 0, +previous, Infinity); - x += length; + const row = y * words; + let previous = (bitmap[row] & 1) === 1; + for (let x = 0; x < width;) { + let length = 0; + for (;;) { + const shift = x & 31; + const word = bitmap[row + (x >>> 5)]; + const stops = previous ? ~word : word; + const w = stops >> shift; + const span = Math.min(32 - shift, width - x); + const first = !w ? 32 : 31 - Math.clz32(w & -w); + const len = Math.min(first, span); + length += len; + x += len; + if (first < span || x >= width) break; + } r0 = r1; r1 = r2; r2 = r3; @@ -996,7 +1012,9 @@ const scanRows = { const black = previous; previous = !previous; candidate: { - if ((runs | 0) < 5) break candidate; + // A center run exceeds 1.5 modules and its neighbors fall short of 1.5, so a + // center no longer than a neighbor never passes ratio(). + if ((runs | 0) < 5 || r2 <= r1 || r2 <= r3) break candidate; const inverted = !black; const ms = ratio(r0, r1, r2, r3, r4); if (!ms) break candidate; From 70764cc2828ce365fb53b130d9fe7b7d0bf59dc3 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:37:02 -0600 Subject: [PATCH 09/37] decoder: binarize eight pixels a row with word-wise compares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blocks(): read the eight pixels of a block row into locals and fold sum, min and max with plain compares instead of an inner loop of Math.min and Math.max calls. bitmap(): add the 5x5 smoother's row as five terms, and on little-endian hosts with word-aligned rows compare four luma pixels per 32-bit word in two 16-bit lanes ((cut + 256) - v sets lane bit 8 exactly when v <= cut, and no lane borrows because every difference stays positive). Rows that are not word-aligned keep the scalar loop, and a threshold that is not a number keeps it too, so the packed bitmap is identical bit for bit on every probe image, tiny ones included. Before → after (bun, M-series): raster v1 46 µs → 37 µs 1080p miss, blank 4.9 ms → 3.7 ms 1080p miss, stripes 5.5 ms → 4.4 ms raster v18, 720p, 1080p: within noise --- src/decode.ts | 78 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index ca8c4c6..a5fbede 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -109,6 +109,7 @@ export type _QRLayer = { width: number; words: number; }; +const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; const cap = (value: number, min?: number, max?: number) => { let result = value; if (max !== undefined) result = Math.min(result, max); @@ -466,6 +467,8 @@ type ScannerTriple = Triple & { // A fractional initial value establishes unboxed-double fields before per-frame writes. const makePattern = (): Pattern => ({ x: 0.1, y: 0.1, ms: 0.1 }); type ScannerLayer = _QRLayer & { + // Four luma pixels per word for the binarizer; undefined on big-endian hosts. + readonly lumaWords: Int32Array | undefined; readonly plane: Plane; readonly context: Ctx; found: boolean; @@ -904,13 +907,33 @@ const scanRows = { let min = 0xff; let max = 0; let pos = yPos * layer.width + xPos; + // Eight pixels per block row, unrolled by hand; `block` is fixed at 8. for (let yy = 0; yy < block; yy++) { - for (let xx = 0; xx < block; xx++) { - const pixel = brightness[pos + xx]; - sum += pixel; - min = Math.min(min, pixel); - max = Math.max(max, pixel); - } + const p0 = brightness[pos]; + const p1 = brightness[pos + 1]; + const p2 = brightness[pos + 2]; + const p3 = brightness[pos + 3]; + const p4 = brightness[pos + 4]; + const p5 = brightness[pos + 5]; + const p6 = brightness[pos + 6]; + const p7 = brightness[pos + 7]; + sum += p0 + p1 + p2 + p3 + p4 + p5 + p6 + p7; + if (p0 < min) min = p0; + if (p1 < min) min = p1; + if (p2 < min) min = p2; + if (p3 < min) min = p3; + if (p4 < min) min = p4; + if (p5 < min) min = p5; + if (p6 < min) min = p6; + if (p7 < min) min = p7; + if (p0 > max) max = p0; + if (p1 > max) max = p1; + if (p2 > max) max = p2; + if (p3 > max) max = p3; + if (p4 > max) max = p4; + if (p5 > max) max = p5; + if (p6 > max) max = p6; + if (p7 > max) max = p7; pos += layer.width; } let average = Math.floor(sum / block ** 2); @@ -939,6 +962,8 @@ const scanRows = { const maxY = layer.height - block; const maxX = layer.width - block; const blocks = layer.blocks; + // Four pixels per word when rows keep word alignment. + const lumaWords = (layer.width & 3) === 0 ? layer.lumaWords : undefined; for (let y = from; y < to; y++) { const yPos = cap(y * block, 0, maxY); // The historical 5x5 smoother improves perspective coverage. @@ -949,17 +974,42 @@ const scanRows = { let sum = 0; for (let yy = -2; yy <= 2; yy++) { const row = bWidth * (top + yy) + left; - for (let xx = -2; xx <= 2; xx++) sum += blocks[row + xx]; + sum += + blocks[row - 2] + blocks[row - 1] + blocks[row] + blocks[row + 1] + blocks[row + 2]; } const average = sum / 25; - layer.cuts[y * bWidth + x] = Math.floor(average); + const cut = Math.floor(average); + layer.cuts[y * bWidth + x] = cut; let pos = yPos * layer.width + xPos; + const shift = xPos & 31; + let word = yPos * layer.words + (xPos >>> 5); + const lowMask = 0xff << shift; + // A word-aligned row compares four pixels per word in two 16-bit lanes: + // (cut + 256) - v sets lane bit 8 exactly when v <= cut, and no lane borrows + // because every difference stays positive. + const swar = lumaWords !== undefined && (pos & 3) === 0 && cut >= 0; + const lanes = ((cut + 256) << 16) | (cut + 256); for (let yy = 0; yy < block; yy++) { let value = 0; - for (let xx = 0; xx < block; xx++) value |= +(brightness[pos + xx] <= average) << xx; - const shift = xPos & 31; - const word = (yPos + yy) * layer.words + (xPos >>> 5); - const lowMask = 0xff << shift; + if (swar) { + const w0 = lumaWords[pos >> 2]; + const w1 = lumaWords[(pos >> 2) + 1]; + const a = lanes - (w0 & 0x00ff00ff); + const b = lanes - ((w0 >>> 8) & 0x00ff00ff); + const c = lanes - (w1 & 0x00ff00ff); + const d = lanes - ((w1 >>> 8) & 0x00ff00ff); + value = + ((a >>> 8) & 1) | + ((b >>> 7) & 2) | + ((a >>> 22) & 4) | + ((b >>> 21) & 8) | + ((c >>> 4) & 16) | + ((d >>> 3) & 32) | + ((c >>> 18) & 64) | + ((d >>> 17) & 128); + } else { + for (let xx = 0; xx < block; xx++) value |= +(brightness[pos + xx] <= average) << xx; + } layer.bitmap[word] = (layer.bitmap[word] & ~lowMask) | (value << shift); if (shift > 24) { const highMask = (1 << (shift - 24)) - 1; @@ -967,6 +1017,7 @@ const scanRows = { (layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift)); } pos += layer.width; + word += layer.words; } } } @@ -1207,6 +1258,9 @@ export class _QRScanner { cuts, height: 0, luma, + lumaWords: LITTLE_ENDIAN + ? new Int32Array(luma.buffer, luma.byteOffset, luma.length >> 2) + : undefined, patternCount: 0, patterns: new Float64Array(centers * 4), used: false, From 1d81df782613716a7f2ccdda1dc5119dd213a5a1 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:39:22 -0600 Subject: [PATCH 10/37] decoder: convert luma and build the pyramid a word at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copyLuma copied planar input byte by byte and folded four-byte pixels one channel at a time. Planar rows now go through TypedArray.set (one memcpy for a tight plane), and on little-endian hosts a word-aligned RGBA/BGRA/X frame is folded four bytes per read as (r + 2g + b) >> 2 straight from the signed word. The 2x2 pyramid box filter likewise sums one word from each source row in two 16-bit lanes to emit two pixels. Rows that are not word aligned keep the scalar loops; every layer's luma is identical bit for bit on 576 format/stride/alignment probes. Before → after (bun, M-series): raster v1 37 µs → 33 µs raster v18 225 µs → 206 µs 720p 1.50 ms → 1.22 ms 1080p 3.4 ms → 2.8 ms 12MP 19.5 ms → 16.2 ms 12MP, max effort 16.3 ms → 11.8 ms 1080p miss, blank 3.7 ms → 2.9 ms 1080p miss, noise 11.4 ms → 10.2 ms --- src/decode.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index a5fbede..c4aa2ca 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -579,6 +579,27 @@ const validateImage = ( ); return format; }; +// Luma of four-byte pixels straight from their words: (r + 2g + b) >> 2 with the fourth +// byte ignored. The view is signed because an opaque pixel sets the top bit, which an +// unsigned read would return as a double; every operation below is bitwise. +const copyWords = (out: Uint8Array, data: Image['data'], byteStart: number, n: number) => { + const words = new Int32Array(data.buffer, byteStart, n); + let i = 0; + for (; i + 3 < n; i += 4) { + const p = words[i]; + const q = words[i + 1]; + const r = words[i + 2]; + const s = words[i + 3]; + out[i] = ((p & 255) + ((p >>> 7) & 510) + ((p >>> 16) & 255)) >> 2; + out[i + 1] = ((q & 255) + ((q >>> 7) & 510) + ((q >>> 16) & 255)) >> 2; + out[i + 2] = ((r & 255) + ((r >>> 7) & 510) + ((r >>> 16) & 255)) >> 2; + out[i + 3] = ((s & 255) + ((s >>> 7) & 510) + ((s >>> 16) & 255)) >> 2; + } + for (; i < n; i++) { + const p = words[i]; + out[i] = ((p & 255) + ((p >>> 7) & 510) + ((p >>> 16) & 255)) >> 2; + } +}; const copyLuma = ( out: Uint8Array, maxSize: Size, @@ -593,14 +614,23 @@ const copyLuma = ( // Native luma may already be the decoder arena. Preserve that zero-copy path while sharing // every packed/planar conversion with alternate generated scanner backends. if (data === out && !offset && stride === width && step === 1) return; + if ( + step === 4 && + LITTLE_ENDIAN && + stride === width * 4 && + ((data.byteOffset + offset) & 3) === 0 + ) { + copyWords(out, data, data.byteOffset + offset, width * height); + return; + } + if (step === 1 && stride === width) { + out.set(data.subarray(offset, offset + width * height)); + return; + } for (let y = 0; y < height; y++) { let src = offset + y * stride; let dst = y * width; - if (step === 1) - for (let x = 0; x < width; x++) { - out[dst++] = data[src]; - src++; - } + if (step === 1) out.set(data.subarray(src, src + width), dst); else if (step === 2) for (let x = 0; x < width; x++) { out[dst++] = (data[src] | (data[src + 1] << 8)) >>> (bits - 8); @@ -881,6 +911,31 @@ const scanRows = { from: number, to: number ) { + // Whole words when rows keep word alignment: one word from each source row holds four + // pixels, summed in two 16-bit lanes to produce two output pixels. + if (LITTLE_ENDIAN && (width & 3) === 0 && (dstWidth & 1) === 0 && (src.byteOffset & 3) === 0) { + const words = new Int32Array(src.buffer, src.byteOffset, (width * (to << 1)) >> 2); + const wordsPerRow = width >> 2; + const pairs = dstWidth >> 1; + for (let y = from; y < to; y++) { + const w0 = (y << 1) * wordsPerRow; + const w1 = w0 + wordsPerRow; + let dstPos = y * dstWidth; + for (let k = 0; k < pairs; k++) { + const a = words[w0 + k]; + const b = words[w1 + k]; + const sum = + (a & 0x00ff00ff) + + (b & 0x00ff00ff) + + ((a >>> 8) & 0x00ff00ff) + + ((b >>> 8) & 0x00ff00ff); + dst[dstPos] = ((sum & 0xffff) + 2) >> 2; + dst[dstPos + 1] = ((sum >>> 16) + 2) >> 2; + dstPos += 2; + } + } + return; + } for (let y = from; y < to; y++) { let srcPos = (y << 1) * width; let dstPos = y * dstWidth; From 6f3df099ebf1f40fcc25ab6547b54079a6e6eb7c Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:40:22 -0600 Subject: [PATCH 11/37] decoder: settle intact blocks with the encoder's LFSR remainder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every block computed all of its syndromes through log/exp multiplies before learning it had no errors, which is the common case for a clean read. Divide the block by the generator first using the encoder's coefficient*feedback products table (one byte lookup per step, shared via the RS cache): a zero remainder and all-zero syndromes are the same condition, so an intact block skips the syndrome loop entirely and a damaged one proceeds exactly as before. Before → after (bun, M-series): raster v18 206 µs → 179 µs raster 114px symbol 70 µs → 65 µs raster 202px symbol 177 µs → 157 µs raster 306px symbol 451 µs → 361 µs --- src/decode.ts | 25 +++++++++++++++++++------ src/index.ts | 1 + 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index c4aa2ca..7c4a69c 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -21,6 +21,7 @@ import { _formatBits as formatBits, _maskBits as maskBits, _popcnt as popcnt, + _rsCached as rsCached, _versionBits as versionBits, } from './index.ts'; @@ -2201,17 +2202,29 @@ export class _QRScanner { correct: { // Byte offsets in tmp8: syndromes, sigma, previous, and next. All four are live // during Berlekamp-Massey; previous/next become omega/locations afterward. - let hasError = false; + // The generator divides an intact block: a zero LFSR remainder, computed exactly + // as the encoder does from its products table, settles the common case without + // syndromes (a zero remainder and all-zero syndromes are the same condition). + const products = rsCached(words).mul; + const rem = next; + const last = words - 1; + fun.fill(0, rem, rem + words); + for (let i = 0; i < length; i++) { + const base = (blockBytes[offset + i] ^ fun[rem]) * words; + for (let j = 0; j < last; j++) fun[rem + j] = fun[rem + j + 1] ^ products[base + j]; + fun[rem + last] = products[base + last]; + } + let dirty = 0; + for (let j = 0; j < words; j++) dirty |= fun[rem + j]; + if (!dirty) { + corrected = true; + break correct; + } for (let i = 0; i < words; i++) { let value = 0; for (let j = 0; j < length; j++) value = mul(value, EXP[i]) ^ blockBytes[offset + j]; fun[syndromes + i] = value; - if (value) hasError = true; - } - if (!hasError) { - corrected = true; - break correct; } fun.fill(0, sigma, sigma + words + 1); fun.fill(0, previous, previous + words + 1); diff --git a/src/index.ts b/src/index.ts index 37aaeaf..fd3f125 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1088,6 +1088,7 @@ export { formatBits as _formatBits, maskBits as _maskBits, popcnt as _popcnt, + rsCached as _rsCached, versionBits as _versionBits, }; From 54564660473dc2ba4eaa7cfd00ae074ce17b6e34 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:42:44 -0600 Subject: [PATCH 12/37] dom: wait for a decoded frame before constructing a VideoFrame new VideoFrame(video) throws while the element has no decoded frame yet (readyState below HAVE_CURRENT_DATA). readFrame treated that throw as missing WebCodecs support and cached the verdict for the whole source, so a scanner started before the first frame arrived fell back to canvas drawImage for the life of the stream and never took the native luma path. Return early instead, exactly as draw() already does; the next frame request finds the element ready. --- src/dom.ts | 3 +++ test/dom.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/dom.ts b/src/dom.ts index fda06e4..c84f6a1 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -1034,6 +1034,9 @@ export class QRCamera { this.videoFrame = false; return this.draw(canvas, fullSize); } + // Before the first decoded frame the constructor throws, which the fallback + // below would read as missing support; the frame is simply not here yet. + if (this.player.readyState < 2) return; if (this.reading) return; this.reading = true; const source = this.source; diff --git a/test/dom.test.ts b/test/dom.test.ts index 22f1db6..a24682c 100644 --- a/test/dom.test.ts +++ b/test/dom.test.ts @@ -595,6 +595,7 @@ it( try { const player = previousCreate('video'); Object.defineProperties(player, { + readyState: { configurable: true, value: 2 }, videoWidth: { configurable: true, value: 6 }, videoHeight: { configurable: true, value: 4 }, }); @@ -1539,6 +1540,7 @@ it('QRCamera decodes padded WebCodecs formats with reusable buffers', async () = source = videoFrameSource(format, width, height, luma); const player = previousCreate('video'); Object.defineProperties(player, { + readyState: { configurable: true, value: 2 }, videoWidth: { configurable: true, value: width }, videoHeight: { configurable: true, value: height }, }); From b654ecf558d23fed2e158a1de007b16794c7f60c Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:44:07 -0600 Subject: [PATCH 13/37] dom: skip frame capture while an async decode reads the arena With opts.async, decode() returns early when a decode is still pending, but readFrame had already copied the new VideoFrame into the scanner's luma arena by then, under the feet of the cooperative decode still sampling it. Let the canvas reader report that a decode is pending and leave the frame alone until it settles. --- src/dom.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dom.ts b/src/dom.ts index c84f6a1..ad89f1a 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -230,6 +230,7 @@ type ScannedFrame = { }; type CanvasReader = { + busy(): boolean; clean(): void; crop: boolean; luma: Uint8Array; @@ -406,6 +407,7 @@ export class QRCanvas { }; this.scanner = new _scanner(decoder); this.reader = { + busy: () => !!this.pending, clean: () => { this.generation++; this.task?.abort(); @@ -1037,7 +1039,8 @@ export class QRCamera { // Before the first decoded frame the constructor throws, which the fallback // below would read as missing support; the frame is simply not here yet. if (this.player.readyState < 2) return; - if (this.reading) return; + // An async decode still reads the arena; a new frame must not land in it. + if (this.reading || reader.busy()) return; this.reading = true; const source = this.source; let frame: VideoFrame; From 1cdda65e8dbcf769dba1aaea3235b56f5fad1daf Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:44:21 -0600 Subject: [PATCH 14/37] dom: mute the player through the property as well setAttribute('muted') only sets the element's default; the muted property is the state that mobile autoplay policy checks. A video element created after parsing keeps its property false, so an inline camera preview could stay paused on iOS until the user tapped it. Set the property before the stream attaches. --- src/dom.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dom.ts b/src/dom.ts index ad89f1a..eba8033 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -968,6 +968,8 @@ export class QRCamera { player.setAttribute('autoplay', ''); player.setAttribute('muted', ''); player.setAttribute('playsinline', ''); + // The muted attribute is only the default; the property is the state autoplay checks. + player.muted = true; player.srcObject = stream; } /** From f42824d3d4c882643f2220ac2bcb5f8825ccc5fa Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:10:58 -0600 Subject: [PATCH 15/37] decoder: walk vertical runs down the column without bit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vertical run kept calling bit() per step, re-deriving the word offset and mask and re-checking four bounds each time. The column's word offset and mask are fixed; only the row bound moves, so step the word index by the row stride and test one bound. Same run lengths bit for bit. Before → after (bun, M-series, alternating A/B): 1080p miss, noise 10.9 ms → 9.9 ms raster v1 46 µs → 44 µs raster v18 248 µs → 237 µs --- src/decode.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/decode.ts b/src/decode.ts index 7c4a69c..4f06f05 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -700,9 +700,18 @@ const run = ( ): number => { let n = 0; if (dy) { - while (bit(layer, x, y) === color && n <= cap) { + if (x < 0 || x >= layer.width) return 0; + // The column's word offset and mask are fixed; only the row bound moves per step. + const bitmap = layer.bitmap; + const words = layer.words; + const height = layer.height; + const mask = 1 << (x & 31); + const want = color ? mask : 0; + let pos = y * words + (x >>> 5); + while (y >= 0 && y < height && (bitmap[pos] & mask) === want && n <= cap) { n++; y += dy; + pos += dy * words; } return n; } From aefa25b52f7e28745d9c09bba1cb3a9da4109942 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:11:09 -0600 Subject: [PATCH 16/37] decoder: leave cross() as soon as a run cannot pass ratio() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ratio() accepts only a center run over 1.5 modules with every other run under that, so a center shorter than two bits, or any side run no shorter than the center, can never pass. Measure the center first (both directions), then check each side run as it is measured. No run's start or length changes; on a 1080p noise frame 18,700 of 27,000 vertical cross-checks now leave early and vertical runs drop from 163k to 106k. Before → after (bun, M-series, alternating A/B): 1080p miss, noise 10.5 ms → 9.7 ms raster v1, v18: within noise --- src/decode.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 4f06f05..548b55d 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -746,17 +746,22 @@ const cross = ( ): number => { const center = +!inverted; const side = +inverted; - let r2 = run(layer, cx, cy, -dx, -dy, center, Infinity); - let back = r2; + let back = run(layer, cx, cy, -dx, -dy, center, Infinity); + const forward = run(layer, cx + dx, cy + dy, dx, dy, center, Infinity); + const r2 = back + forward; + // ratio() needs a center run over 1.5 modules and every other run under that, so a center + // shorter than two bits or no longer than a neighbor fails before the remaining runs. + if (r2 < 2) return -1; const r1 = run(layer, cx - dx * back, cy - dy * back, -dx, -dy, side, maxMs); + if (r1 >= r2) return -1; back += r1; const r0 = run(layer, cx - dx * back, cy - dy * back, -dx, -dy, center, maxMs); + if (r0 >= r2) return -1; back += r0; const start = (dx ? cx : cy) - back; - const forward = run(layer, cx + dx, cy + dy, dx, dy, center, Infinity); - r2 += forward; let ahead = 1 + forward; const r3 = run(layer, cx + dx * ahead, cy + dy * ahead, dx, dy, side, maxMs); + if (r3 >= r2) return -1; ahead += r3; const r4 = run(layer, cx + dx * ahead, cy + dy * ahead, dx, dy, center, maxMs); if (!ratio(r0, r1, r2, r3, r4)) return -1; From 0ddf95de150e0c14adf71a9a4548959319529fe6 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:11:18 -0600 Subject: [PATCH 17/37] decoder: test finder ratios on integer bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs are integers, so the half-module tolerance |ms - a| < ms / 2 with ms = total / 7 is exactly total < 14a < 3 * total, and the center test is 3 * total < 14c < 9 * total. Replace the division and five Math.abs calls with integer multiplies and compares; the returned pitch is still total / 7. Verified equal on every (a, b, d, e) up to 36 with c up to 108 plus two million random tuples: 206,283,549 cases, no differences. Before → after (bun, M-series, alternating A/B): 1080p miss, noise 10.1 ms → 9.7 ms raster: within noise --- src/decode.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 548b55d..9e0a818 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -675,15 +675,22 @@ const bit = (layer: ScannerLayer, x: number, y: number) => { const ratio = (a: number, b: number, c: number, d: number, e: number) => { const total = a + b + c + d + e; if (total < 7) return 0; - const ms = total / 7; - // Half-module tolerance accommodates sampling noise around the three-module center. - const tol = ms * 0.5; - return Math.abs(ms - a) < tol && - Math.abs(ms - b) < tol && - Math.abs(3 * ms - c) < 3 * tol && - Math.abs(ms - d) < tol && - Math.abs(ms - e) < tol - ? ms + // Half-module tolerance accommodates sampling noise around the three-module center: + // each side run within (0.5, 1.5) modules and the center within (1.5, 4.5), tested on + // integer runs as 14 * run against multiples of the seven-module total. + const lo = total; + const hi = 3 * total; + return lo < 14 * a && + 14 * a < hi && + lo < 14 * b && + 14 * b < hi && + hi < 14 * c && + 14 * c < 9 * total && + lo < 14 * d && + 14 * d < hi && + lo < 14 * e && + 14 * e < hi + ? total / 7 : 0; }; // Consecutive `color` bits from (x,y) inclusive stepping (dx,dy); stops on mismatch, border, From 143595bc2ea8742cb81b61d2f7a90021c651934a Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:11:30 -0600 Subject: [PATCH 18/37] decoder: keep the current bitmap word resident across finder runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each run recomputed x & 31 and x >>> 5 and reloaded its word. Carry the word and its consumed-bit shift from run to run and load the next word only when a run crosses a word boundary. Same run lengths bit for bit. Before → after (bun, M-series, alternating A/B): 1080p miss, noise 9.9 ms → 9.2 ms raster v1, v18: within noise --- src/decode.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 9e0a818..8012345 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -1115,13 +1115,13 @@ const scanRows = { let r3 = 0; let r4 = 0; let runs = 0; - const row = y * words; - let previous = (bitmap[row] & 1) === 1; + let at = y * words; + let word = bitmap[at]; + let shift = 0; + let previous = (word & 1) === 1; for (let x = 0; x < width;) { let length = 0; for (;;) { - const shift = x & 31; - const word = bitmap[row + (x >>> 5)]; const stops = previous ? ~word : word; const w = stops >> shift; const span = Math.min(32 - shift, width - x); @@ -1129,7 +1129,10 @@ const scanRows = { const len = Math.min(first, span); length += len; x += len; + shift += len; if (first < span || x >= width) break; + word = bitmap[++at]; + shift = 0; } r0 = r1; r1 = r2; From ae70226906b91859493a4b6d6b36c52d7a3582ad Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:20:40 -0600 Subject: [PATCH 19/37] decoder: sample the module grid without per-module calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projectQuad called read() through a mapPoint scratch object for every module. Hoist the plane fields and the nine homography entries, compute the three row products once per row and sum them in read()'s exact order, and sample inline; the tile bounds become explicit parameters. Same samples bit for bit (grid, codewords and results identical on 7,247 symbol variants and all 536 BoofCV photos). Before → after (node, min of alternating rounds): projectQuad v10 6.1 µs → 5.7 µs projectQuad v18 29.1 µs → 22.5 µs projectQuad v40 77.9 µs → 60.4 µs bun: neutral (JSC already inlines the call) --- src/decode.ts | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 8012345..45fe57f 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -2338,21 +2338,49 @@ export class _QRScanner { s: Plane, map: Float64Array, size: number, - left = 0, - right = size, - top = 0, - bottom = size + left: number, + right: number, + top: number, + bottom: number ): void { - for (let y = top; y < bottom; y++) + const { W, H, d, cut, sh, bw } = s; + const grid = this.grid; + const inverted = this.invertedProjection; + const m0 = map[0]; + const m1 = map[1]; + const m2 = map[2]; + const m3 = map[3]; + const m4 = map[4]; + const m5 = map[5]; + const m6 = map[6]; + const m7 = map[7]; + const m8 = map[8]; + // read() unrolled: each row's homography terms are products of one module coordinate, + // computed once per row and summed in read()'s order. + for (let y = top; y < bottom; y++) { + const my = y + 0.5; + const rx = m1 * my; + const ry = m4 * my; + const rd = m7 * my; for (let x = left; x < right; x++) { - this.grid[y * size + x] = this.read(s, map, x + 0.5, y + 0.5); + const mx = x + 0.5; + const den = m6 * mx + rd + m8; + const px = Math.floor((m0 * mx + rx + m2) / den); + const py = Math.floor((m3 * mx + ry + m5) / den); + let value = 0; + if (px >= 0 && py >= 0 && px < W && py < H) { + const dark = d[py * W + px] <= cut[(py >> sh) * bw + (px >> sh)]; + value = dark !== inverted ? 1 : 0; + } + grid[y * size + x] = value; } + } } // Timing prefilter + global grid projection against one plane. private projectMap(s: Plane, map: Float64Array, size: number, ctx: Ctx): Attempt { const ok = this.timing(s, map, size); - if (ok) this.projectQuad(s, map, size); + if (ok) this.projectQuad(s, map, size, 0, size, 0, size); return ok ? this.decodeGrid(size, ctx) : FAIL.timing; } From af8a66b1dc8d6f6df7077f4430c53b1820d13cf8 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:20:47 -0600 Subject: [PATCH 20/37] decoder: walk codewords with packed column masks and whole-byte stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction walk evaluated maskBits() per module and OR-ed single bits into the codeword array. Mask predicates repeat every twelve rows, so pack one period per column into a word (24 maskBits calls per column pair instead of one per module), track y mod 12 incrementally, and accumulate eight bits before storing each byte whole; every byte in the codeword range is written, so the clearing fill goes. Before → after (decodeGrid, clean symbol, min of alternating rounds): v10 bun 5.8 µs → 5.3 µs node 9.5 µs → 6.9 µs v18 bun 20.5 µs → 17.4 µs node 37.2 µs → 26.4 µs v40 bun 56.0 µs → 46.6 µs node 105 µs → 72 µs --- src/decode.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 45fe57f..41bcc7b 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -2167,25 +2167,42 @@ export class _QRScanner { } const bytes = this.codewords; const total = BYTES[ver - 1]; - bytes.fill(0, 0, total); + const limit = 8 * total; + const grid = this.grid; let bit = 0; + let acc = 0; let dir = -1; let y = size - 1; for (let xOffset = size - 1; xOffset > 0; xOffset -= 2) { if (xOffset === 6) xOffset = 6 - 1; + // Mask predicates repeat every 12 rows: pack one period per column into a word. + let mask0 = 0; + let mask1 = 0; + for (let i = 0; i < 12; i++) { + mask0 |= ((maskBits(xOffset, i) >> mask) & 1) << i; + mask1 |= ((maskBits(xOffset - 1, i) >> mask) & 1) << i; + } + let ym = y % 12; for (;;) { + const row = y * size; for (let j = 0; j < 2; j++) { const x = xOffset - j; - if (fun[y * size + x]) continue; - if ( - bit < 8 * total && - (this.grid[y * size + x] ^ ((maskBits(x, y) >> mask) & 1)) === 1 - ) - bytes[bit >> 3] |= 0x80 >> (bit & 7); + if (fun[row + x]) continue; + // Codewords fill in walk order, so each byte lands whole after its eighth bit. + if (bit < limit) { + acc = (acc << 1) | (grid[row + x] ^ (((j ? mask1 : mask0) >> ym) & 1)); + if ((bit & 7) === 7) { + bytes[bit >> 3] = acc; + acc = 0; + } + } bit++; } if (y + dir < 0 || y + dir >= size) break; y += dir; + ym += dir; + if (ym < 0) ym = 11; + else if (ym === 12) ym = 0; } dir = -dir; } From f587166900c6eb8153ca00f175c0ccb79f4962db Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:20:54 -0600 Subject: [PATCH 21/37] decoder: read payload fields through a three-byte window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payload.read assembled each field one bit at a time. No field exceeds 16 bits, so three bytes cover any field at any bit offset: shift the window down and mask. Bytes past the end read as zero and are masked away; the length check is unchanged. Before → after (decodePayload, min of alternating rounds): v10 bun 0.85 µs → 0.50 µs node 1.1 µs → 0.7 µs v18 bun 3.2 µs → 1.9 µs node 7.3 µs → 3.5 µs v40 bun 10.2 µs → 6.5 µs node 20.3 µs → 9.5 µs --- src/decode.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 41bcc7b..e1c6522 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -254,14 +254,13 @@ const Payload = { const read = (bits: number) => { const start = state.position; if (start + bits > state.dataLen * 8) return -1; - let value = 0; - let pos = start; - for (let i = 0; i < bits; i++) { - value = (value << 1) | ((state.data[pos >> 3] >> (7 - (pos & 7))) & 1); - pos++; - } - state.position = pos; - return value; + const data = state.data; + const byte = start >> 3; + // No field exceeds 16 bits, so three bytes cover it at any bit offset; bytes past the + // end read as zero and are masked away with the rest of the window. + const window = (data[byte] << 16) | (data[byte + 1] << 8) | data[byte + 2]; + state.position = start + bits; + return (window >> (24 - (start & 7) - bits)) & ((1 << bits) - 1); }; state = { position: 0, data: new Uint8Array(0), dataLen: 0, bytes, read, views }; return state; From e05175e62e7a1691002984cbcb1c7c3da1d9b5c5 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:23:39 -0600 Subject: [PATCH 22/37] encoder: run the Reed-Solomon remainder four coefficients a word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The products table is also packed four bytes to an Int32 (coefficient j in byte j & 3 of word j >> 2), so each data byte shifts the remainder down one byte across at most eight words and XORs one packed row, instead of up to thirty byte operations. The byte table stays for the decoder's remainder check; the table-less fallback had no callers. Before → after (leave-one-out, min of alternating rounds, raw output): node v3 5.9 µs → 5.3 µs, v8 16.9 → 15.7, v18 50.8 → 45.0 bun v3 3.7 µs → 3.5 µs, v8 11.3 → 10.8, v18 31.9 → 29.6 --- src/index.ts | 49 ++++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index fd3f125..c3f7c9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -181,50 +181,53 @@ function rsGenerator(eccWords: number): Uint8Array { return gen; } -type RsCache = { gen: Uint8Array; mul: Uint8Array }; +type RsCache = { gen: Uint8Array; mul: Uint8Array; mul32: Int32Array }; const RS_CACHE: (RsCache | undefined)[] = []; // Generator and all coefficient*feedback products, shared by every symbol -// with the same parity length; entries are generated lazily. +// with the same parity length; entries are generated lazily. `mul32` holds +// the same products four to a word (coefficient j in byte j & 3 of word +// j >> 2) for the word-wise remainder loop. function rsCached(eccWords: number): RsCache { let cached = RS_CACHE[eccWords]; if (cached !== undefined) return cached; const gen = rsGenerator(eccWords); const { exp: EXP, log: LOG } = GF256; const mul = new Uint8Array(256 * eccWords); + const stride = (eccWords + 3) >>> 2; + const mul32 = new Int32Array(256 * stride); for (let f = 1; f < 256; f++) { const lf = LOG[f]; const off = f * eccWords; for (let j = 0; j < eccWords; j++) { const c = gen[j]; - if (c) mul[off + j] = EXP[LOG[c] + lf]; + if (c) mul32[f * stride + (j >>> 2)] |= (mul[off + j] = EXP[LOG[c] + lf]) << (8 * (j & 3)); } } - return (RS_CACHE[eccWords] = { gen, mul }); + return (RS_CACHE[eccWords] = { gen, mul, mul32 }); } -// Reed-Solomon parity via LFSR remainder. -function rsEcc(data: Uint8Array, gen: Uint8Array, mul?: Uint8Array): Uint8Array { - const { exp: EXP, log: LOG } = GF256; +const RS_TMP = /* @__PURE__ */ new Int32Array(8); +// Reed-Solomon parity via LFSR remainder, four coefficients a word: each +// data byte shifts the remainder down one byte across the words and XORs +// in the packed products row of the feedback byte. +function rsEcc(data: Uint8Array, gen: Uint8Array, mul32: Int32Array): Uint8Array { const eccWords = gen.length; - const res = new Uint8Array(eccWords); - if (mul !== undefined) { - const last = eccWords - 1; - for (let i = 0; i < data.length; i++) { - const off = (data[i] ^ res[0]) * eccWords; - for (let j = 0; j < last; j++) res[j] = res[j + 1] ^ mul[off + j]; - res[last] = mul[off + last]; - } - return res; - } + const stride = mul32.length >>> 8; + const last = stride - 1; + const w = RS_TMP.fill(0, 0, stride); for (let i = 0; i < data.length; i++) { - const f = data[i] ^ res[0]; - res.copyWithin(0, 1); - res[eccWords - 1] = 0; - if (f) { - for (let j = 0; j < eccWords; j++) if (gen[j]) res[j] ^= EXP[LOG[gen[j]] + LOG[f]]; + let cur = w[0]; + const off = (data[i] ^ (cur & 0xff)) * stride; + for (let k = 0; k < last; k++) { + const next = w[k + 1]; + w[k] = ((cur >>> 8) | (next << 24)) ^ mul32[off + k]; + cur = next; } + w[last] = (cur >>> 8) ^ mul32[off + last]; } + const res = new Uint8Array(eccWords); + for (let j = 0; j < eccWords; j++) res[j] = w[j >>> 2] >>> (8 * (j & 3)); return res; } @@ -319,7 +322,7 @@ function encodeData( for (let i = 0, pos = 0; i < numBlocks; i++) { const len = blockLen + (i < shortBlocks ? 0 : 1); blocks.push(bytes.subarray(pos, pos + len)); - eccs.push(rsEcc(blocks[i], rs.gen, rs.mul)); + eccs.push(rsEcc(blocks[i], rs.gen, rs.mul32)); pos += len; } const res = new Uint8Array(bytes.length + words * numBlocks); From b5bdc58893b6964df76dcaffe0f3367cbaa6a68f Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:23:45 -0600 Subject: [PATCH 23/37] decoder: fold the Reed-Solomon remainder four coefficients at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the encoder's packed products table and keep the LFSR register in an Int32Array, so each block byte shifts and folds the register one word at a time instead of one byte. Coefficient j lives in byte j & 3 of word j >> 2; the top word's spare bytes shift in zeros and fold zero products. Before → after (decodeGrid, clean symbol, bun): v10 8.6 µs → 8.0 µs v18 30.5 µs → 27.5 µs v40 86.2 µs → 63.8 µs --- src/decode.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index e1c6522..3775bc2 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -122,7 +122,6 @@ const cap = (value: number, min?: number, max?: number) => { const { exp: EXP, log: LOG } = GF256; const mul = (a: number, b: number) => (a && b ? EXP[LOG[a] + LOG[b]] : 0); const inv = (a: number) => EXP[255 - LOG[a]]; - type Luma = { width: number; height: number; data: Uint8Array }; export type _QRPlane = readonly [xShift: 0 | 1, yShift: 0 | 1, bytes: 1 | 2 | 3 | 4]; export type _QRInputFormat = { step: 1 | 2 | 3 | 4; bits: 8 | 10 | 12 }; @@ -1242,6 +1241,7 @@ export class _QRScanner { private readonly codewords = new Uint8Array(BYTES[40 - 1]); private readonly tmp32 = new Uint32Array(4 * 16 * 3 + 16); private readonly tmp64 = new Float64Array(7 * 7 * 2 + (7 * 7 - 3) * 4); + private readonly remainder = new Int32Array(8); private readonly payload = Payload.create(BYTES[40 - 1]); private readonly image: Luma; private readonly input: Image; @@ -2245,17 +2245,21 @@ export class _QRScanner { // The generator divides an intact block: a zero LFSR remainder, computed exactly // as the encoder does from its products table, settles the common case without // syndromes (a zero remainder and all-zero syndromes are the same condition). - const products = rsCached(words).mul; - const rem = next; - const last = words - 1; - fun.fill(0, rem, rem + words); + // Coefficient j lives in byte j & 3 of word j >> 2; the top word's spare bytes shift + // in zeros and fold zero products, so they stay zero. + const products = rsCached(words).mul32; + const rem = this.remainder; + const stride = (words + 3) >> 2; + const last = stride - 1; + rem.fill(0, 0, stride); for (let i = 0; i < length; i++) { - const base = (blockBytes[offset + i] ^ fun[rem]) * words; - for (let j = 0; j < last; j++) fun[rem + j] = fun[rem + j + 1] ^ products[base + j]; - fun[rem + last] = products[base + last]; + const base = (blockBytes[offset + i] ^ (rem[0] & 0xff)) * stride; + for (let j = 0; j < last; j++) + rem[j] = ((rem[j] >>> 8) | (rem[j + 1] << 24)) ^ products[base + j]; + rem[last] = (rem[last] >>> 8) ^ products[base + last]; } let dirty = 0; - for (let j = 0; j < words; j++) dirty |= fun[rem + j]; + for (let j = 0; j < stride; j++) dirty |= rem[j]; if (!dirty) { corrected = true; break correct; From 8b0149591c7fdde7ccf0971eef224704deb99c8c Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:23:51 -0600 Subject: [PATCH 24/37] decoder: derive syndromes from the remainder register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The register holds x^words * C(x) mod g(x), which agrees with the block at every generator root up to the factor alpha^(i * words), so a damaged block's syndromes come from its `words` register coefficients instead of a Horner pass over the whole block (28 bytes instead of ~148 for a version 40 block). Verified against the direct syndromes for every parity length on random blocks. Before → after (decodeGrid with 6/12/30/80 flipped modules, bun): 15.3 / 43.2 / 64.5 / 187 µs → 9.5 / 28.2 / 41.3 / 117 µs node: 17.5 / 57.3 / 84.0 / 246 µs → 12.1 / 37.7 / 49.7 / 141 µs --- src/decode.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 3775bc2..ce79b22 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -2264,11 +2264,14 @@ export class _QRScanner { corrected = true; break correct; } + // The register holds x^words * C(x) mod g(x), which agrees with the block at every + // generator root up to the factor alpha^(i * words), so the syndromes come from its + // `words` coefficients instead of the whole block. + for (let k = 0; k < words; k++) fun[next + k] = (rem[k >> 2] >> ((k & 3) * 8)) & 0xff; for (let i = 0; i < words; i++) { let value = 0; - for (let j = 0; j < length; j++) - value = mul(value, EXP[i]) ^ blockBytes[offset + j]; - fun[syndromes + i] = value; + for (let k = 0; k < words; k++) value = mul(value, EXP[i]) ^ fun[next + k]; + fun[syndromes + i] = mul(value, EXP[255 - ((i * words) % 255)]); } fun.fill(0, sigma, sigma + words + 1); fun.fill(0, previous, previous + words + 1); From bd31694ac85f774b2b98fc043aa21d175f511d8e Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:23:58 -0600 Subject: [PATCH 25/37] encoder: cache SVG path commands per output width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of an SVG encode was spent formatting numbers and concatenating a fresh command string per dark module. The two common relative moves (same row, next row) with the h-1 return are kept in a table keyed by (dy, dx) for the current output width, filled on first use, so each dark module costs one lookup and one concatenation. Absolute moves and the x < 10 H form keep the inline path; bits are read from the packed row directly. Output is byte-identical. Before → after (leave-one-out, min of alternating rounds, svg output): node v1 10.2 µs → 6.3 µs, v3 20.7 → 10.7, v8 59.6 → 27.8, v18 188 → 77 bun v1 4.4 µs → 3.6 µs, v3 8.0 → 5.9, v8 24.3 → 17.1, v18 76.6 → 71.5 --- src/index.ts | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index c3f7c9c..7874f2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -853,28 +853,49 @@ const digits = (n: number): number => n < 10 ? 1 : n < 100 ? 2 : n < 1000 ? 3 : n < 10000 ? 4 : String(n).length; const chars = (d: number): number => (d < 0 ? 1 + digits(-d) : digits(d)); +// Finished path commands for the two common relative moves (same row, next +// row) with the `h-1` return, keyed by (dy, dx) for one output width: the +// coordinates never change between symbols of the same size, so the number +// formatting is paid once per distinct move. +let svgCache: { W: number; cmds: string[] } | undefined; + function renderSvg(r: Raster, optimize: boolean): string { - const W = r.W; + const { m, W, map } = r; + const { words, v } = m; + if (svgCache === undefined || svgCache.W !== W) svgCache = { W, cmds: new Array(4 * W) }; + const cmds = svgCache.cmds; let out = ``; let pathData = ''; let prevX = 0; let prevY = 0; let hasPrev = false; for (let y = 0; y < W; y++) { + const my = map[y]; + if (my < 0) continue; + const base = my * words; for (let x = 0; x < W; x++) { - if (!dark(r, x, y)) continue; + const mx = map[x]; + if (mx < 0 || !((v[base + (mx >>> 5)] >>> (mx & 31)) & 1)) continue; if (!optimize) { out += ``; continue; } // The shorter move wins, relative on ties; only the winner is built. - let mv: string; - if (hasPrev) { - const dx = x - prevX; - const dy = y - prevY; - mv = chars(dx) + chars(dy) <= digits(x) + digits(y) ? `m${dx} ${dy}` : `M${x} ${y}`; - } else mv = `M${x} ${y}`; - pathData += `${mv}h1v1${x < 10 ? `H${x}` : 'h-1'}Z`; + const dx = x - prevX; + const dy = y - prevY; + let cmd: string; + if (hasPrev && x >= 10 && dy <= 1 && chars(dx) + 1 <= digits(x) + digits(y)) { + const k = (2 * dy + 1) * W + dx; + cmd = cmds[k]; + if (cmd === undefined) cmd = cmds[k] = `m${dx} ${dy}h1v1h-1Z`; + } else { + let mv: string; + if (hasPrev) { + mv = chars(dx) + chars(dy) <= digits(x) + digits(y) ? `m${dx} ${dy}` : `M${x} ${y}`; + } else mv = `M${x} ${y}`; + cmd = `${mv}h1v1${x < 10 ? `H${x}` : 'h-1'}Z`; + } + pathData += cmd; prevX = x; prevY = y; hasPrev = true; From 8c4b7337956a4b76efb75f72069c51db56914aed Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:24:05 -0600 Subject: [PATCH 26/37] encoder: emit four ASCII glyphs per concatenation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The half-block renderer appended one glyph per cell. Cell codes are packed two bits each and a 256-entry table of glyph quads is appended once per four cells; the two module rows of a text line are hoisted and read from the packed words. Output is byte-identical. Before → after (leave-one-out, min of alternating rounds, ascii output): node v1 3.5 µs → 2.9 µs, v3 6.8 → 5.4, v8 18.4 → 15.6, v18 55.7 → 44.7 bun v1 2.4 µs → 2.3 µs, v3 4.1 → 3.9, v8 12.3 → 12.1, v18 34.7 → 33.7 --- src/index.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7874f2d..7e551bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -819,15 +819,42 @@ function renderRaw(r: Raster): boolean[][] { return res; } +// Half-block glyphs by (upper, lower) darkness, and every four-cell +// sequence of them, so a line grows four glyphs per concatenation. +const GLYPH = ['█', '▀', '▄', ' ']; +const QUAD: string[] = /* @__PURE__ */ (() => { + const t: string[] = []; + for (let i = 0; i < 256; i++) + t.push(GLYPH[i >> 6] + GLYPH[(i >> 4) & 3] + GLYPH[(i >> 2) & 3] + GLYPH[i & 3]); + return t; +})(); + function renderAscii(r: Raster): string { - const W = r.W; + const { m, W, map } = r; + const { words, v } = m; let out = ''; for (let y = 0; y < W; y += 2) { + const my0 = map[y]; + const my1 = y + 1 < W ? map[y + 1] : -2; // past the bottom edge reads dark + const b0 = my0 * words; + const b1 = my1 * words; + let acc = 0; + let n = 0; for (let x = 0; x < W; x++) { - const first = dark(r, x, y); - const second = y + 1 >= W ? true : dark(r, x, y + 1); - out += !first && !second ? '█' : !first && second ? '▀' : first && !second ? '▄' : ' '; + const mx = map[x]; + let g = my1 === -2 ? 1 : 0; + if (mx >= 0) { + if (my0 >= 0 && (v[b0 + (mx >>> 5)] >>> (mx & 31)) & 1) g |= 2; + if (my1 >= 0 && (v[b1 + (mx >>> 5)] >>> (mx & 31)) & 1) g |= 1; + } + acc = (acc << 2) | g; + if (++n === 4) { + out += QUAD[acc]; + acc = 0; + n = 0; + } } + for (let i = 0; i < n; i++) out += GLYPH[(acc >> (2 * (n - 1 - i))) & 3]; out += NL; } return out; From 056e097afecfb836340ec8ebf5d5dea08650fa86 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:24:13 -0600 Subject: [PATCH 27/37] encoder: copy GIF pixel rows whole, then spread them into LZW chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel rows are block-copied back to back into the unused tail of the output buffer, and each 126-byte chunk is then moved forward behind its two-byte header with copyWithin. Every chunk lands at or before its source, so the moves never clobber pixels still to be copied, and the per-pixel byte loop disappears. Output is byte-identical. Before → after (leave-one-out, min of alternating rounds, gif output): node v1 3.9 µs → 3.4 µs, v3 6.7 → 6.1, v8 19.3 → 16.8, v18 54.2 → 45.9 bun v1 2.3 µs → 2.3 µs, v3 3.8 → 3.7, v8 11.4 → 10.9, v18 31.5 → 28.6 --- src/index.ts | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7e551bb..f1c98b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -959,34 +959,37 @@ function renderGif(r: Raster): Uint8Array { u16(W); out[p++] = 0x00; out[p++] = 0x07; - // Pixels are emitted from a per-module-row 0/1 buffer, rebuilt only when - // the module row changes (border and scale-repeated output rows reuse it), - // and block-copied in spans bounded by the LZW chunk boundaries. The span - // copy is the load-bearing part: a per-pixel emit loop costs more than the - // bit extraction it wraps, so a row buffer alone measures as no win. + // Pixel rows are built once per module row (border and scale-repeated + // output rows reuse the buffer) and block-copied back to back into the + // unused tail of the output, then spread forward chunk by chunk: every + // chunk lands at or before its source, so the moves never clobber + // pixels still to be copied. const { m, map } = r; + const { words, v } = m; const row = new Uint8Array(W); + const src = out.length - 4 - pixels; let prevMy = -2; - for (let y = 0, i = 0; y < W; y++) { + for (let y = 0, q = src; y < W; y++, q += W) { const my = map[y]; if (my !== prevMy) { prevMy = my; row.fill(0); - if (my >= 0) for (let x = 0; x < W; x++) if (map[x] >= 0) row[x] = matGet(m, map[x], my); - } - for (let x = 0; x < W;) { - if (i % N === 0) { - const rem = pixels - i; - out[p++] = (rem < N ? rem : N) + 1; - out[p++] = 0x80; // LZW clear code + if (my >= 0) { + const base = my * words; + for (let x = 0; x < W; x++) { + const mx = map[x]; + if (mx >= 0) row[x] = (v[base + (mx >>> 5)] >>> (mx & 31)) & 1; + } } - const n = Math.min(N - (i % N), W - x); - // A byte loop: a subarray view per span costs more than the copy. - for (let k = 0; k < n; k++) out[p + k] = row[x + k]; - p += n; - x += n; - i += n; } + out.set(row, q); + } + for (let i = 0, s = src; i < pixels; i += N, s += N) { + const n = pixels - i < N ? pixels - i : N; + out[p++] = n + 1; + out[p++] = 0x80; // LZW clear code + out.copyWithin(p, s, s + n); + p += n; } if (tail === 0) { out[p++] = 1; From 2e86b2aa1efeea2b0c9bd150798154df0256fb73 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:24:21 -0600 Subject: [PATCH 28/37] encoder: validate numeric and alphanumeric text through the value table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alphabet check scanned a 45-character string per input character. Test ALNUM_VAL against the mode's alphabet size instead; the error message is built only on failure from the code point at the offending index, which is the same character the string iterator yielded (63 error cases verified identical). Before → after (leave-one-out, min of alternating rounds, raw output): node v1 3.2 µs → 3.1 µs, v8 18.6 → 17.1, v18 54.7 → 49.6 bun v1 2.1 µs → 2.0 µs, v8 12.6 → 10.8, v18 36.2 → 29.6 --- src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index f1c98b0..527885c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1045,9 +1045,13 @@ export function encodeQR( const encoding = opts.encoding !== undefined ? opts.encoding : detectType(text); if (!LENGTH_BITS[encoding]) err(`invalid encoding=${encoding}`); if (encoding !== 'byte') { - const alpha = encoding === 'numeric' ? ALPHANUMERIC.slice(0, 10) : ALPHANUMERIC; - for (const ch of text) { - if (!alpha.includes(ch)) err(`Unknown letter: "${ch}". Allowed: ${alpha}`); + const limit = encoding === 'numeric' ? 10 : ALPHANUMERIC.length; + for (let i = 0; i < text.length; i++) { + const v = ALNUM_VAL[text.charCodeAt(i)]; // undefined past 127 + if (!(v >= 0 && v < limit)) { + const ch = String.fromCodePoint(text.codePointAt(i)!); + err(`Unknown letter: "${ch}". Allowed: ${ALPHANUMERIC.slice(0, limit)}`); + } } } if (opts.mask !== undefined && (asNum(opts.mask, 'opts.mask') < 0 || opts.mask > 7)) From 2197c0b741e6432ebbdf03c11ac0814c2ccc9561 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:24:29 -0600 Subject: [PATCH 29/37] encoder: fill raw rows from the packed words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Border rows are stored without map or bit lookups, and module rows read the column map once per cell with the row's word base hoisted, instead of a per-cell helper that re-tested both coordinates. The boolean[][] allocation itself is the floor: filled, sliced and push-built rows all measured slower than plain indexed stores. Before → after (leave-one-out, min of alternating rounds, raw output): node v1 3.4 µs → 3.1 µs, v3 6.2 → 5.7, v8 18.1 → 17.1, v18 52.8 → 49.9 bun v3 3.9 µs → 3.6 µs, v8 11.7 → 11.4, v18 32.3 → 31.7 --- src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 527885c..ac1947d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -809,11 +809,21 @@ const CTRL = [10, 27]; // [newline, ESC] const NL = /* @__PURE__ */ String.fromCharCode(CTRL[0]); function renderRaw(r: Raster): boolean[][] { - const W = r.W; + const { m, W, map } = r; const res: boolean[][] = new Array(W); + const { words, v } = m; for (let y = 0; y < W; y++) { + const my = map[y]; const row: boolean[] = new Array(W); - for (let x = 0; x < W; x++) row[x] = dark(r, x, y); + if (my < 0) { + for (let x = 0; x < W; x++) row[x] = false; + } else { + const base = my * words; + for (let x = 0; x < W; x++) { + const mx = map[x]; + row[x] = mx >= 0 && ((v[base + (mx >>> 5)] >>> (mx & 31)) & 1) === 1; + } + } res[y] = row; } return res; From 3e956fd207f64708320d830d85f30eabdcfe7b00 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:24:37 -0600 Subject: [PATCH 30/37] encoder: place data bits two at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zigzag fills a two-module column, so consecutive placement positions usually share a word with the second bit one below the first. A per-pair table (word << 6 | shift << 1 | 1) lets one OR place a 2-bit value; a pair that straddles a word or a function pattern falls back to the single-bit positions. Before → after (steps 1-6 vs this, node, raw output): v1 2.80 µs → 2.75 µs, v8 15.7 → 15.2, v18 44.8 → 44.5 random alphanumeric payloads: 2.6-3.2% faster --- src/index.ts | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index ac1947d..979b4b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -581,6 +581,7 @@ type SymCache = { ver: number; tpl: Int32Array; pos: Uint16Array; + pair: Int32Array; planes: Int32Array[]; planesT: Int32Array[]; work: [Mat, Mat, Mat, Mat]; @@ -674,6 +675,18 @@ function buildSymCache(ver: number): SymCache { if (y + dir < 0 || y + dir >= size) break; } } + // The zigzag fills a two-module column, so consecutive positions mostly + // sit side by side in one word: such a pair is placed as one 2-bit OR. + // Entries are (wordIndex << 6 | shift << 1 | 1), or 0 where the pair + // straddles a word or a function pattern. + const pair = new Int32Array(n >>> 1); + for (let i = 0; i + 1 < n; i += 2) { + const a = posBuf[i]; + const b = posBuf[i + 1]; + if (a >>> 5 === b >>> 5 && (a & 31) === (b & 31) + 1) { + pair[i >>> 1] = ((a >>> 5) << 6) | ((b & 31) << 1) | 1; + } + } const planesT = planes.map((p) => { const t = mat(size); transposeMat(p, t); @@ -683,6 +696,7 @@ function buildSymCache(ver: number): SymCache { ver, tpl: m.v, pos: posBuf.slice(0, n), + pair, planes: planes.map((p) => p.v), planesT, work: [mat(size), mat(size), mat(size), mat(size)], @@ -701,14 +715,24 @@ function drawSymbol( test = false ): Mat { if (symCache === undefined || symCache.ver !== ver) symCache = buildSymCache(ver); - const { tpl, pos, planes, planesT, work } = symCache; + const { tpl, pos, pair, planes, planesT, work } = symCache; const [m, t, cand, candT] = work; m.v.set(tpl); const need = Math.min(8 * data.length, pos.length); // trailing remainder bits stay 0 - for (let i = 0; i < need; i++) { - if (data[i >>> 3] & (0x80 >>> (i & 7))) { - const p = pos[i]; - m.v[p >>> 5] |= 1 << (p & 31); + for (let i = 0; i < need; i += 2) { + const two = (data[i >>> 3] >>> (6 - (i & 7))) & 3; + if (two === 0) continue; + const pr = pair[i >>> 1]; + if (pr & 1) m.v[pr >>> 6] |= two << ((pr >>> 1) & 31); + else { + if (two & 2) { + const p = pos[i]; + m.v[p >>> 5] |= 1 << (p & 31); + } + if (two & 1) { + const p = pos[i + 1]; + m.v[p >>> 5] |= 1 << (p & 31); + } } } let mask = maskIdx; From d3f57b7622769d6be67d4535b4eebd1240d9e9dc Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:30:52 -0600 Subject: [PATCH 31/37] decoder: wipe each luma arena once in clean() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reflective sweep still visits every typed-array field, but layer zero's luma is the scanner's own arena and every layer's lumaWords is a view over its luma, so those aliases were zero-filled two and three times over. Skip them by identity; nothing else changes. Before → after (bun, min of alternating rounds): clean(), 1080p scanner 105 µs → 51 µs raster v1 (116 px) decode −7% --- src/decode.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index ce79b22..b4faab8 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -1477,11 +1477,17 @@ export class _QRScanner { this.payload.bytes.fill(0); // Lifecycle wipe, not per-frame: every typed-array field on the scanner and its layers is // a zero-target arena, so sweep them reflectively — new arenas cannot be forgotten here. - // Object.values allocates; acceptable outside the frame loop. Layer zero's luma aliases - // the scanner's, so the double fill is harmless. + // Object.values allocates; acceptable outside the frame loop. The two aliases are skipped + // by identity: lumaWords views its layer's luma, and layer zero's luma is the scanner's. for (const v of Object.values(this)) if (ArrayBuffer.isView(v)) (v as Uint8Array).fill(0); for (const layer of this.layers as ScannerLayer[]) { - for (const v of Object.values(layer)) if (ArrayBuffer.isView(v)) (v as Uint8Array).fill(0); + for (const v of Object.values(layer)) + if ( + ArrayBuffer.isView(v) && + v !== layer.lumaWords && + (v !== layer.luma || v !== this.luma) + ) + (v as Uint8Array).fill(0); layer.blockHeight = 0; layer.blockWidth = 0; layer.height = 0; From 80bc6c32d621eccba33e49645bd920815160ec74 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:30:59 -0600 Subject: [PATCH 32/37] decoder: slide the 5x5 threshold smoother along each block row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoother is clamped two blocks inside each edge, so per block row the five column sums are built once and rolled one column at a time: five loads per block instead of twenty-five. Integer sums in a different order give the same threshold bit for bit, and the index arithmetic is unchanged, so the tiny-grid behaviour stays as it was. Before → after (bun, bitmap stage, min of alternating rounds): raster v1 layer 0 −18% 1080p layers 0..3 −5 to −14% --- src/decode.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index b4faab8..23a12f9 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -1041,16 +1041,33 @@ const scanRows = { const lumaWords = (layer.width & 3) === 0 ? layer.lumaWords : undefined; for (let y = from; y < to; y++) { const yPos = cap(y * block, 0, maxY); - // The historical 5x5 smoother improves perspective coverage. + // The historical 5x5 smoother improves perspective coverage. Its window is clamped + // two blocks inside either edge, so it slides one column at a time between them: + // each step drops the column sum leaving the window and adds the one entering it. const top = cap(y, 2, bHeight - 3); + const row = bWidth * (top - 2); + let c0 = blocks[row] + blocks[row + bWidth] + blocks[row + 2 * bWidth]; + c0 += blocks[row + 3 * bWidth] + blocks[row + 4 * bWidth]; + let c1 = blocks[row + 1] + blocks[row + 1 + bWidth] + blocks[row + 1 + 2 * bWidth]; + c1 += blocks[row + 1 + 3 * bWidth] + blocks[row + 1 + 4 * bWidth]; + let c2 = blocks[row + 2] + blocks[row + 2 + bWidth] + blocks[row + 2 + 2 * bWidth]; + c2 += blocks[row + 2 + 3 * bWidth] + blocks[row + 2 + 4 * bWidth]; + let c3 = blocks[row + 3] + blocks[row + 3 + bWidth] + blocks[row + 3 + 2 * bWidth]; + c3 += blocks[row + 3 + 3 * bWidth] + blocks[row + 3 + 4 * bWidth]; + let c4 = blocks[row + 4] + blocks[row + 4 + bWidth] + blocks[row + 4 + 2 * bWidth]; + c4 += blocks[row + 4 + 3 * bWidth] + blocks[row + 4 + 4 * bWidth]; + let sum = c0 + c1 + c2 + c3 + c4; for (let x = 0; x < bWidth; x++) { const xPos = cap(x * block, 0, maxX); - const left = cap(x, 2, bWidth - 3); - let sum = 0; - for (let yy = -2; yy <= 2; yy++) { - const row = bWidth * (top + yy) + left; - sum += - blocks[row - 2] + blocks[row - 1] + blocks[row] + blocks[row + 1] + blocks[row + 2]; + if (x > 2 && x <= bWidth - 3) { + const col = row + x + 2; + c0 = c1; + c1 = c2; + c2 = c3; + c3 = c4; + c4 = blocks[col] + blocks[col + bWidth] + blocks[col + 2 * bWidth]; + c4 += blocks[col + 3 * bWidth] + blocks[col + 4 * bWidth]; + sum = c0 + c1 + c2 + c3 + c4; } const average = sum / 25; const cut = Math.floor(average); From 34be497792110f14690f494beb5393f7c641fbe7 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:31:06 -0600 Subject: [PATCH 33/37] decoder: fold three-byte RGB input a word at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three words carry four RGB pixels; copyTriples mirrors copyWords with the same alignment and stride gate and a byte tail, so a tight RGB frame no longer goes through the per-channel byte loop. Verified mismatch-free on two million random pixels. Before → after (bun, min of alternating rounds): 1080p RGB conversion 1.43 ms → 0.81 ms 1080p RGB decode −8 to −10% --- src/decode.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/decode.ts b/src/decode.ts index 23a12f9..64fa20a 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -599,6 +599,24 @@ const copyWords = (out: Uint8Array, data: Image['data'], byteStart: number, n: n out[i] = ((p & 255) + ((p >>> 7) & 510) + ((p >>> 16) & 255)) >> 2; } }; +// Luma of three-byte pixels from their words: three words carry four pixels, so a pixel's +// channels come from the word or word pair that holds them; the tail stays byte-wise. +const copyTriples = (out: Uint8Array, data: Image['data'], byteStart: number, n: number) => { + const words = new Int32Array(data.buffer, byteStart, (3 * n) >> 2); + let i = 0; + let w = 0; + for (; i + 3 < n; i += 4, w += 3) { + const a = words[w]; + const b = words[w + 1]; + const c = words[w + 2]; + out[i] = ((a & 255) + ((a >>> 7) & 510) + ((a >>> 16) & 255)) >> 2; + out[i + 1] = ((a >>> 24) + ((b & 255) << 1) + ((b >>> 8) & 255)) >> 2; + out[i + 2] = (((b >>> 16) & 255) + ((b >>> 23) & 510) + (c & 255)) >> 2; + out[i + 3] = (((c >>> 8) & 255) + ((c >>> 15) & 510) + (c >>> 24)) >> 2; + } + for (let src = byteStart - data.byteOffset + i * 3; i < n; i++, src += 3) + out[i] = (data[src] + 2 * data[src + 1] + data[src + 2]) >> 2; +}; const copyLuma = ( out: Uint8Array, maxSize: Size, @@ -622,6 +640,15 @@ const copyLuma = ( copyWords(out, data, data.byteOffset + offset, width * height); return; } + if ( + step === 3 && + LITTLE_ENDIAN && + stride === width * 3 && + ((data.byteOffset + offset) & 3) === 0 + ) { + copyTriples(out, data, data.byteOffset + offset, width * height); + return; + } if (step === 1 && stride === width) { out.set(data.subarray(offset, offset + width * height)); return; From d3a5667cc24d2f6741241cb9a86fd830a9cae6a8 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:31:44 -0600 Subject: [PATCH 34/37] decoder: grow finder records on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit patterns and inverted were sized to one record per 7x7 cell of the maximum frame (1.6 MB and 0.4 MB for a 1080p one-shot scanner), allocated up front and then touched again by clean(). Start at 64 records and double inside find(), the only writer, up to the same one-per-cell ceiling of the staged frame, with the same "finder storage exhausted" error beyond it. The batch test's capacity expectation follows the new initial size. Before → after (bun, min of alternating rounds): constructor, 1080p 21.5 µs → 16.4 µs clean(), 1080p 79 µs → 51 µs 1080p decode −4 to −6%, 720p −4% --- src/decode.ts | 25 ++++++++++++++++++++----- test/decode-batch.test.ts | 8 ++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 64fa20a..05ec737 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -471,7 +471,7 @@ type ScannerLayer = _QRLayer & { readonly plane: Plane; readonly context: Ctx; found: boolean; - readonly inverted: Uint8Array; + inverted: Uint8Array; readonly sets: Float64Array; setCount: number; setCursor: number; @@ -910,6 +910,21 @@ const edgePitch = (layer: ScannerLayer, first: Pattern, second: Pattern, inverte const secondPitch = pitch(second); return firstPitch && secondPitch ? (firstPitch + secondPitch) / 2 : 0; }; +// Double a layer's finder records, up to one per 7x7 cell of the staged frame. +const growFinders = (layer: ScannerLayer): Float64Array => { + const centers = Math.ceil(layer.width / 7) * Math.ceil(layer.height / 7); + const count = layer.inverted.length; + if (count >= centers) + throw new Error(`finder storage exhausted at ${layer.width}x${layer.height}`); + const records = Math.min(centers, count * 2); + const patterns = new Float64Array(records * 4); + patterns.set(layer.patterns); + const inverted = new Uint8Array(records); + inverted.set(layer.inverted); + layer.patterns = patterns; + layer.inverted = inverted; + return patterns; +}; // Confidence (slot 3) stays behind: every consumer reads it straight from the record. const copyPattern = (layer: ScannerLayer, index: number, out: Pattern) => { const pos = index * 4; @@ -1198,7 +1213,7 @@ const scanRows = { if (cy < 0) break candidate; const refinedX = cross(layer, cx, Math.round(cy), 1, 0, limit, inverted); if (refinedX < 0) break candidate; - const patterns = layer.patterns; + let patterns = layer.patterns; const polarity = +inverted; for (let i = 0; i < layer.patternCount; i++) { const pos = i * 4; @@ -1218,8 +1233,7 @@ const scanRows = { } const index = layer.patternCount++; const pos = index * 4; - if (pos + 3 >= patterns.length) - throw new Error(`finder storage exhausted at ${layer.width}x${layer.height}`); + if (pos + 3 >= patterns.length) patterns = growFinders(layer); patterns[pos] = refinedX; patterns[pos + 1] = cy; patterns[pos + 2] = ms; @@ -1367,7 +1381,8 @@ export class _QRScanner { if (i && Math.min(width, height) < 64) break; const blockWidth = Math.ceil(width / 8); const blockHeight = Math.ceil(height / 8); - const centers = Math.ceil(width / 7) * Math.ceil(height / 7); + // Finder records start small and grow on demand, up to one per 7x7 cell. + const centers = Math.min(64, Math.ceil(width / 7) * Math.ceil(height / 7)); const luma = i ? new Uint8Array(width * height) : this.luma; const blocks = new Uint8Array(blockWidth * blockHeight); const cuts = new Int16Array(blockWidth * blockHeight); diff --git a/test/decode-batch.test.ts b/test/decode-batch.test.ts index 37a7bc9..c6f68e4 100644 --- a/test/decode-batch.test.ts +++ b/test/decode-batch.test.ts @@ -284,9 +284,9 @@ it('_QRScanner sizes every pyramid arena from its rectangular layer dimensions', patterns: patterns.length, })), [ - { bitmap: 16 * 256, blocks: 64 * 32, cuts: 64 * 32, luma: 512 * 256, patterns: 74 * 37 * 4 }, - { bitmap: 8 * 128, blocks: 32 * 16, cuts: 32 * 16, luma: 256 * 128, patterns: 37 * 19 * 4 }, - { bitmap: 4 * 64, blocks: 16 * 8, cuts: 16 * 8, luma: 128 * 64, patterns: 19 * 10 * 4 }, + { bitmap: 16 * 256, blocks: 64 * 32, cuts: 64 * 32, luma: 512 * 256, patterns: 64 * 4 }, + { bitmap: 8 * 128, blocks: 32 * 16, cuts: 32 * 16, luma: 256 * 128, patterns: 64 * 4 }, + { bitmap: 4 * 64, blocks: 16 * 8, cuts: 16 * 8, luma: 128 * 64, patterns: 64 * 4 }, ] ); const skinny = new _QRScanner({ maxSize: { width: 4096, height: 1 } }); @@ -298,7 +298,7 @@ it('_QRScanner sizes every pyramid arena from its rectangular layer dimensions', luma: luma.length, patterns: patterns.length, })), - [{ bitmap: 128, blocks: 512, cuts: 512, luma: 4096, patterns: 586 * 4 }] + [{ bitmap: 128, blocks: 512, cuts: 512, luma: 4096, patterns: 64 * 4 }] ); }); From 16c02b1d7f3c5f653af691ed4f85812c96efb534 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:32:09 -0600 Subject: [PATCH 35/37] decoder: size version scratch to the symbol actually attempted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module grid, function map, codeword and payload arenas were always allocated for Version 40 (two 31 KB maps, two 3.7 KB byte arrays and a 3,707-slot view list per scanner), which dominated a small one-shot decode. They start empty and reserve() grows them where the symbol size is committed, the one point every consumer is downstream of; the payload bytes follow the first byte segment's symbol and the view cache resets when they regrow. A reusable scanner pays each growth once. Before → after (bun, min of alternating rounds): raster v1, 58 px 18.1 µs → 14.3 µs raster v1, 116 px 33.7 µs → 29.8 µs raster v8 −5%, raster v18 −4% --- src/decode.ts | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 05ec737..64db90f 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -244,11 +244,12 @@ type PayloadState = { views: Uint8Array[]; }; const Payload = { - create(capacity: number): PayloadState { - const bytes = new Uint8Array(capacity); - // One prefix view per length, created on first use: a scanner that - // never decodes a byte segment of that length never allocates it. - const views = new Array(capacity + 1); + create(): PayloadState { + // Segment bytes sized by the first byte segment's symbol, and one prefix view per + // length created on first use: a scanner that never decodes a byte segment of that + // length never allocates it. + const bytes = new Uint8Array(0); + const views: Uint8Array[] = []; let state: PayloadState; const read = (bits: number) => { const start = state.position; @@ -332,7 +333,11 @@ const Payload = { res = ''; } else { const encoding = ECI_ENCODINGS[eci]; - if (!encoding || length >= state.views.length) return FAIL.data; + if (!encoding) return FAIL.data; + if (state.bytes.length < dataLen) { + state.bytes = new Uint8Array(dataLen); + state.views.length = 0; + } const view = state.views[length] ?? (state.views[length] = new Uint8Array(state.bytes.buffer, 0, length)); @@ -1294,13 +1299,14 @@ export class _QRScanner { width: number; height: number; luma: Uint8Array; - private grid = new Uint8Array(177 * 177); - private readonly tmp8 = new Uint8Array(177 * 177); - private readonly codewords = new Uint8Array(BYTES[40 - 1]); + // Version-sized scratch, grown by reserve() to the largest symbol attempted. + private grid = new Uint8Array(0); + private tmp8 = new Uint8Array(0); + private codewords = new Uint8Array(0); private readonly tmp32 = new Uint32Array(4 * 16 * 3 + 16); private readonly tmp64 = new Float64Array(7 * 7 * 2 + (7 * 7 - 3) * 4); private readonly remainder = new Int32Array(8); - private readonly payload = Payload.create(BYTES[40 - 1]); + private readonly payload = Payload.create(); private readonly image: Luma; private readonly input: Image; private inFlight = false; @@ -1463,6 +1469,15 @@ export class _QRScanner { this.mapQuad(out); } + // Grow the module grid, function map and codeword scratch to one symbol size: a scanner + // that only ever meets small symbols never pays for Version 40. + private reserve(size: number): void { + if (this.grid.length >= size * size) return; + this.grid = new Uint8Array(size * size); + this.tmp8 = new Uint8Array(size * size); + this.codewords = new Uint8Array(BYTES[(size - 17) / 4 - 1]); + } + // Fill the reusable alignment-position prefix for one QR version and return its length. private setAlignments(ver: number): number { if (ver === 1) return 0; @@ -2535,6 +2550,7 @@ export class _QRScanner { ) continue; this.decodedSize = size; + this.reserve(size); // A located bottom-right alignment pattern upgrades the affine BR estimate to perspective. const f = 1 - (3.5 - 0.5) / (size - 7); const brEstX = tl.x + (tr.x - tl.x + bl.x - tl.x) * f; From 023913defb92844074e5683b0ae1b538280fd83b Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 20:46:29 -0600 Subject: [PATCH 36/37] decoder: opt-in nativeLimit skips the full-resolution finder search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A camera frame is searched coarse-to-fine, and a symbol big enough to scan is normally found on the half-resolution layer, but a miss still pays for binarizing and scanning every native pixel. `nativeLimit` skips the finder search on the native layer when its shorter side exceeds the limit; modules are still sampled from native luma, and a frame too small to have a half layer is always searched. QRCanvas forwards the option and `nativeEvery` lets every n-th frame search native regardless, so a small symbol on a large frame is still found within a few frames. The default (Infinity) leaves every result unchanged. 2592x2160 frame, default → nativeLimit: 1080 (bun, M-series): blank miss 8.1 ms → 4.4 ms noise miss 27.4 ms → 9.2 ms symbol hit 3.4 ms → 3.4 ms --- src/decode.ts | 28 +++++++++++++++++++++++++++- src/dom.ts | 23 ++++++++++++++++++++++- test/decode.test.ts | 27 +++++++++++++++++++++++++++ test/dom.test.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/decode.ts b/src/decode.ts index 64db90f..8e5b516 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -69,6 +69,12 @@ export type DecodeOpts = { effort?: number; /** Milliseconds available to optional retries; defaults to one 60-FPS frame budget. */ timeLimit?: number; + /** + * Skip the finder search on the native layer when its shorter side exceeds this many + * pixels: symbols are then found on the half-resolution layer and their modules still + * sampled from native luma. Defaults to Infinity (every layer is searched). + */ + nativeLimit?: number; /** Custom byte-to-text decoder used for byte segments; receives the active ECI designator. */ textDecoder?: (bytes: Uint8Array, eci?: number) => string; /** @@ -475,6 +481,8 @@ type ScannerLayer = _QRLayer & { readonly lumaWords: Int32Array | undefined; readonly plane: Plane; readonly context: Ctx; + // Finder search runs on this layer; false on a native layer past nativeLimit. + search: boolean; found: boolean; inverted: Uint8Array; readonly sets: Float64Array; @@ -527,6 +535,16 @@ const validateOpts = (opts: DecodeOpts): void => { (typeof opts.timeLimit !== 'number' || !Number.isFinite(opts.timeLimit) || opts.timeLimit < 0) ) throw new TypeError(`invalid opts.timeLimit=${opts.timeLimit} (${typeof opts.timeLimit})`); + if ( + opts.nativeLimit !== undefined && + opts.nativeLimit !== Infinity && + (typeof opts.nativeLimit !== 'number' || + !Number.isFinite(opts.nativeLimit) || + opts.nativeLimit < 0) + ) + throw new TypeError( + `invalid opts.nativeLimit=${opts.nativeLimit} (${typeof opts.nativeLimit})` + ); for (const name of ['textDecoder', 'pointsOnDetect', 'imageOnResult', 'imageOnBitmap'] as const) if (opts[name] !== undefined && typeof opts[name] !== 'function') throw new TypeError(`invalid opts.${name}=${opts[name]} (${typeof opts[name]})`); @@ -1326,6 +1344,8 @@ export class _QRScanner { private blocked = 0; private readonly effort: number; private readonly timeLimit: number; + /** Native-layer search gate (see DecodeOpts.nativeLimit); settable between frames. */ + nativeLimit: number; private retryStart = 0; private retries = 0; private points?: FinderPoints; @@ -1369,6 +1389,7 @@ export class _QRScanner { ); this.effort = init.effort === undefined ? 1 : init.effort; this.timeLimit = init.timeLimit === undefined ? 1000 / 60 : init.timeLimit; + this.nativeLimit = init.nativeLimit === undefined ? Infinity : init.nativeLimit; this.opts = Object.freeze({ ...init, effort: this.effort, @@ -1425,6 +1446,7 @@ export class _QRScanner { oy: 0, fine, }, + search: false, found: false, inverted: new Uint8Array(centers), setCount: 0, @@ -1518,6 +1540,9 @@ export class _QRScanner { const layer = this.layers[i] as ScannerLayer; const used = !i || Math.min(aw, ah) >= 64; layer.used = used; + // A frame too small for a half layer (shorter side under 128) always searches native. + layer.search = + used && (i > 0 || Math.min(aw, ah) <= this.nativeLimit || Math.min(aw, ah) < 128); layer.width = used ? aw : 0; layer.height = used ? ah : 0; layer.words = used ? Math.ceil(aw / 32) : 0; @@ -1571,6 +1596,7 @@ export class _QRScanner { layer.setCount = 0; layer.setCursor = 0; layer.used = false; + layer.search = false; layer.found = false; layer.setsReady = false; } @@ -2781,7 +2807,7 @@ export class _QRScanner { ) break walk; const layer = layers[i]; - if (!layer.used) continue; + if (!layer.used || !layer.search) continue; /** * The frame reader has already written grayscale luma. Keeping thresholding on that plane * avoids packed-color conversion in the dominant camera path. diff --git a/src/dom.ts b/src/dom.ts index eba8033..456391d 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -157,7 +157,7 @@ export type _QRScannerLike = Pick< _QRScanner, 'addImage' | 'clean' | 'decode' | 'luma' | 'processImage' > & - Partial>; + Partial>; export type _QRScannerConstructor = new (opts: QRScannerOpts) => _QRScannerLike; /** `QRCanvas` drawing and decode options. */ @@ -184,6 +184,18 @@ export type QRCanvasOpts = { effort?: number; /** Milliseconds available to optional scanner retries. */ timeLimit?: number; + /** + * Skip the full-resolution finder search on frames whose shorter side exceeds this many + * pixels; symbols are found on the half-resolution layer and still sampled from native + * luma. Unset searches every layer. + */ + nativeLimit?: number; + /** + * With `nativeLimit`: every this-many-th frame searches full resolution regardless, so a + * small symbol on a large frame is still found within a few frames. One searches native + * on every frame the limit allows. + */ + nativeEvery: number; /** Draw the terminal failed QR hypothesis as a red data region. */ drawFailed: boolean; /** @@ -286,6 +298,7 @@ export class QRCanvas { private inputWidth = 0; private inputHeight = 0; private frameSource?: 'VideoFrame' | 'canvas'; + private frames = 0; private main: CanvasWithContext; private overlay?: CanvasWithContext; private resultQR?: CanvasWithContext; @@ -307,6 +320,7 @@ export class QRCanvas { cropToSquare: true, decodeAll: false, async: false, + nativeEvery: 1, drawFailed: false, ...opts, }; @@ -325,6 +339,7 @@ export class QRCanvas { }; if (this.opts.effort !== undefined) decoder.effort = this.opts.effort; if (this.opts.timeLimit !== undefined) decoder.timeLimit = this.opts.timeLimit; + if (this.opts.nativeLimit !== undefined) decoder.nativeLimit = this.opts.nativeLimit; if (this.overlay) decoder.pointsOnDetect = (points, result) => { if (Date.now() - this.lastDetect > this.opts.overlayTimeout) { @@ -649,6 +664,12 @@ export class QRCanvas { size?: Size ): QRCanvasResult | Promise | undefined { if (this.pending) return; + this.frames++; + if (this.opts.nativeLimit !== undefined) { + const every = this.opts.nativeEvery; + this.scanner.nativeLimit = + every > 1 && this.frames % every === 0 ? Infinity : this.opts.nativeLimit; + } this.bitmapDrawn = false; this.overlayDrawn = false; this.overlayBatch.length = 0; diff --git a/test/decode.test.ts b/test/decode.test.ts index 2fbdbc6..cca6a78 100644 --- a/test/decode.test.ts +++ b/test/decode.test.ts @@ -327,6 +327,7 @@ it('decodeQR validates its complete public image and option surface', () => { ['effort zero', img, { effort: 0 }], ['effort fractional', img, { effort: 1.5 }], ['timeLimit negative', img, { timeLimit: -1 }], + ['nativeLimit negative', img, { nativeLimit: -1 }], ['width type', { ...img, width: String(img.width) }, {}], ['width range', { ...img, width: 0 }, {}], ['height integer', { ...img, height: img.height + 0.5 }, {}], @@ -1868,4 +1869,30 @@ for (const category of listFiles(DETECTION_PATH, true)) { }); } +it('decodeQR nativeLimit searches large frames on the half layer only', () => { + const text = 'NATIVE LIMIT'; + const place = (scale: number, side: number) => { + const symbol = matrixToImage(encodeQR(text, 'raw', { border: 4 }), scale); + const data = new Uint8Array(side * side * 4).fill(255); + const x0 = (side - symbol.width) >> 1; + const y0 = (side - symbol.height) >> 1; + for (let y = 0; y < symbol.height; y++) + data.set( + symbol.data.subarray(y * symbol.width * 4, (y + 1) * symbol.width * 4), + ((y0 + y) * side + x0) * 4 + ); + return { width: side, height: side, data }; + }; + // Four pixels per module survive the 2x2 box filter; the limit below the frame side + // skips native search and the symbol is still decoded from the half layer. + deepStrictEqual(readQR(place(4, 512), { nativeLimit: 256 }), text); + // One pixel per module needs the native search: the same limit misses, and the + // default (Infinity) or a limit at the frame side finds it. + throws(() => readQR(place(1, 512), { nativeLimit: 256 })); + deepStrictEqual(readQR(place(1, 512)), text); + deepStrictEqual(readQR(place(1, 512), { nativeLimit: 512 }), text); + // A frame too small for a half layer is always searched natively. + deepStrictEqual(readQR(place(1, 100), { nativeLimit: 0 }), text); +}); + it.runWhen(import.meta.url); diff --git a/test/dom.test.ts b/test/dom.test.ts index a24682c..b741060 100644 --- a/test/dom.test.ts +++ b/test/dom.test.ts @@ -443,6 +443,39 @@ it('QRCanvas accepts an internal reusable scanner constructor', () => { } }); +it('QRCanvas forwards nativeLimit and forces native search every nativeEvery-th frame', () => { + const previousCreate = document.createElement.bind(document); + const seen: number[] = []; + class Scanner { + luma = SHARED_SCANNER_LUMA; + nativeLimit = Infinity; + constructor(opts: { nativeLimit?: number }) { + seen.push(opts.nativeLimit ?? -1); + } + addImage() { + seen.push(this.nativeLimit); + } + processImage() {} + decode() { + return ['SCANNER']; + } + clean() {} + } + document.createElement = ((name: string) => + name === 'canvas' + ? (new FakeCanvas() as any) + : previousCreate(name)) as typeof document.createElement; + try { + const gated = new QRCanvas({}, { nativeLimit: 720 }, Scanner); + for (let i = 0; i < 3; i++) gated.drawImage({} as CanvasImageSource, 4, 5); + const every = new QRCanvas({}, { nativeLimit: 720, nativeEvery: 3 }, Scanner); + for (let i = 0; i < 6; i++) every.drawImage({} as CanvasImageSource, 4, 5); + deepStrictEqual(seen, [720, 720, 720, 720, 720, 720, 720, Infinity, 720, 720, Infinity]); + } finally { + document.createElement = previousCreate; + } +}); + it('QRCanvas routes optional async decoding through the reusable scanner', async () => { const previousCreate = document.createElement.bind(document); const calls: unknown[] = []; From 4b86f34eb54091cdd1b31bafbabb01adb8105847 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:39:39 -0600 Subject: [PATCH 37/37] changelog: note the decoder, encoder and DOM changes --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be9d428..564ff81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog for qr +## Unreleased + +- Decoder: 3-5x faster on small symbols and 1.5-2x on camera frames with identical results: word-wise luma conversion, pyramid and binarizer, finder runs walked straight off the packed row with exact early-outs, call-free grid sampling, packed codeword extraction, Reed-Solomon over packed words with syndromes taken from the remainder, and per-call arenas sized to the symbol instead of Version 40 +- Decoder: opt-in `nativeLimit` (and `nativeEvery` in `QRCanvas`) skips the full-resolution finder search on large camera frames +- Encoder: byte-identical output 10-30% faster for `raw`, `ascii`, `gif` and `data-url`, up to 3x for `svg` +- DOM: fix the native VideoFrame path being disabled for a stream started before its first frame; do not overwrite the luma arena while an async decode reads it; mute through the property for mobile autoplay + ## 0.7.0 (2026-08-31) - Decoder: new architecture, focusing on camera latency. 2x more accurate than previous version on BoofCV