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 diff --git a/src/decode.ts b/src/decode.ts index c9a3291..8e5b516 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'; @@ -68,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; /** @@ -96,7 +103,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; @@ -109,6 +116,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); @@ -120,7 +128,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 }; @@ -243,22 +250,23 @@ type PayloadState = { views: Uint8Array[]; }; const Payload = { - create(capacity: number): PayloadState { - const bytes = new Uint8Array(capacity); - const views = new Array(capacity + 1); - for (let i = 0; i < views.length; i++) views[i] = new Uint8Array(bytes.buffer, 0, i); + 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; 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; @@ -331,10 +339,17 @@ 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)); 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; } @@ -462,10 +477,14 @@ 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; + // Finder search runs on this layer; false on a native layer past nativeLimit. + search: boolean; found: boolean; - readonly inverted: Uint8Array; + inverted: Uint8Array; readonly sets: Float64Array; setCount: number; setCursor: number; @@ -516,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]})`); @@ -572,6 +601,45 @@ 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; + } +}; +// 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, @@ -586,14 +654,32 @@ 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 === 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; + } 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); @@ -637,15 +723,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, @@ -662,9 +755,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; } @@ -674,10 +776,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; @@ -699,17 +801,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; @@ -826,6 +933,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; @@ -874,6 +996,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; @@ -900,13 +1047,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); @@ -935,34 +1102,79 @@ 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. + // 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; - for (let xx = -2; xx <= 2; xx++) sum += blocks[row + xx]; + 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; - 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) >>> 0; - layer.bitmap[word] = ((layer.bitmap[word] & ~lowMask) | ((value << shift) >>> 0)) >>> 0; + 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; layer.bitmap[word + 1] = - ((layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift))) >>> 0; + (layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift)); } pos += layer.width; + word += layer.words; } } } @@ -971,7 +1183,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; @@ -979,10 +1195,25 @@ 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; + 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 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; + shift += len; + if (first < span || x >= width) break; + word = bitmap[++at]; + shift = 0; + } r0 = r1; r1 = r2; r2 = r3; @@ -992,7 +1223,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; @@ -1003,7 +1236,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; @@ -1023,8 +1256,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; @@ -1085,12 +1317,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 payload = Payload.create(BYTES[40 - 1]); + private readonly remainder = new Int32Array(8); + private readonly payload = Payload.create(); private readonly image: Luma; private readonly input: Image; private inFlight = false; @@ -1110,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; @@ -1153,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, @@ -1171,20 +1408,24 @@ 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); // 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, 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, @@ -1205,6 +1446,7 @@ export class _QRScanner { oy: 0, fine, }, + search: false, found: false, inverted: new Uint8Array(centers), setCount: 0, @@ -1249,6 +1491,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; @@ -1289,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; @@ -1322,11 +1576,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; @@ -1336,6 +1596,7 @@ export class _QRScanner { layer.setCount = 0; layer.setCursor = 0; layer.used = false; + layer.search = false; layer.found = false; layer.setsReady = false; } @@ -2011,25 +2272,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; } @@ -2070,18 +2348,37 @@ 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; - 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; + // 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). + // 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] ^ (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]; } - if (!hasError) { + let dirty = 0; + for (let j = 0; j < stride; j++) dirty |= rem[j]; + if (!dirty) { 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 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); fun[sigma] = 1; @@ -2170,21 +2467,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; } @@ -2251,6 +2576,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; @@ -2481,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 fda06e4..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; /** @@ -230,6 +242,7 @@ type ScannedFrame = { }; type CanvasReader = { + busy(): boolean; clean(): void; crop: boolean; luma: Uint8Array; @@ -285,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; @@ -306,6 +320,7 @@ export class QRCanvas { cropToSquare: true, decodeAll: false, async: false, + nativeEvery: 1, drawFailed: false, ...opts, }; @@ -324,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) { @@ -406,6 +422,7 @@ export class QRCanvas { }; this.scanner = new _scanner(decoder); this.reader = { + busy: () => !!this.pending, clean: () => { this.generation++; this.task?.abort(); @@ -647,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; @@ -966,6 +989,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; } /** @@ -1034,7 +1059,11 @@ export class QRCamera { this.videoFrame = false; return this.draw(canvas, fullSize); } - if (this.reading) return; + // 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; + // 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; diff --git a/src/index.ts b/src/index.ts index 81a1549..979b4b6 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); @@ -336,7 +339,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 +356,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); @@ -361,31 +374,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 +433,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 +446,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,26 +461,38 @@ 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; + // 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 >>> 0) + popcnt(m1 >>> 0); + 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; @@ -487,13 +516,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 +532,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 +579,11 @@ 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[]; + pair: Int32Array; + planes: Int32Array[]; + planesT: Int32Array[]; work: [Mat, Mat, Mat, Mat]; }; let symCache: SymCache | undefined; @@ -645,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); @@ -654,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)], @@ -672,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; @@ -780,25 +833,62 @@ 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; } +// 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; @@ -818,25 +908,58 @@ 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)); + +// 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 prev: { x: number; y: number } | undefined; + 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; } - 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. + 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 += `${mv}h1v1${x < 10 ? `H${x}` : 'h-1'}Z`; - prev = { x, y }; + pathData += cmd; + prevX = x; + prevY = y; + hasPrev = true; } } if (optimize) out += ``; @@ -870,33 +993,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); - out.set(row.subarray(x, x + n), p); - 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; @@ -952,9 +1079,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)) @@ -1049,6 +1180,7 @@ export { formatBits as _formatBits, maskBits as _maskBits, popcnt as _popcnt, + rsCached as _rsCached, versionBits as _versionBits, }; 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 }] ); }); 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 22f1db6..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[] = []; @@ -595,6 +628,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 +1573,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 }, });