diff --git a/CHANGELOG.md b/CHANGELOG.md index 564ff81..430a5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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 +- Decoder: read mirror images, by retrying a failed read on the transposed grid; iOS WebKit hands the rear-camera plane mirrored with no metadata saying so - 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 diff --git a/src/decode.ts b/src/decode.ts index 8e5b516..6d95108 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -2174,7 +2174,27 @@ export class _QRScanner { } // Parse format/function bits, deinterleave and correct codewords, then decode the payload. - private decodeGrid(size: number, _ctx: Ctx): Attempt { + // A mirror image locates like any symbol, since finders and timing are symmetric, and + // fails only at the format and data bits, so a failed read is retried on the transposed + // grid, which is the upright symbol. The symbology expects a reader to read mirror images; + // a camera plane can arrive mirrored with no metadata saying so. + private decodeGrid(size: number, ctx: Ctx): Attempt { + const result = this.decodeOriented(size, ctx); + if (!(result instanceof Error)) return result; + const grid = this.grid; + for (let y = 1; y < size; y++) + for (let x = 0; x < y; x++) { + const a = y * size + x; + const b = x * size + y; + const t = grid[a]; + grid[a] = grid[b]; + grid[b] = t; + } + const mirrored = this.decodeOriented(size, ctx); + return mirrored instanceof Error ? result : mirrored; + } + + private decodeOriented(size: number, _ctx: Ctx): Attempt { let decoded: Attempt = FAIL.format; if (!checkVersion(this.grid, size)) decoded = FAIL.version; else { diff --git a/test/decode.test.ts b/test/decode.test.ts index cca6a78..9be49a4 100644 --- a/test/decode.test.ts +++ b/test/decode.test.ts @@ -1895,4 +1895,25 @@ it('decodeQR nativeLimit searches large frames on the half layer only', () => { deepStrictEqual(readQR(place(1, 100), { nativeLimit: 0 }), text); }); +it('decodeQR reads mirror images', () => { + // ISO/IEC 18004 expects a reader to read mirror images: a camera plane can arrive mirrored + // with no metadata saying so. Mirror a raster and a photo left to right. + const mirror = (img: { width: number; height: number; data: Uint8Array }) => { + const data = new Uint8Array(img.data.length); + const bpp = img.data.length / (img.width * img.height); + for (let y = 0; y < img.height; y++) + for (let x = 0; x < img.width; x++) + for (let c = 0; c < bpp; c++) { + const from = (y * img.width + img.width - 1 - x) * bpp + c; + data[(y * img.width + x) * bpp + c] = img.data[from]; + } + return { width: img.width, height: img.height, data }; + }; + const text = 'MIRROR IMAGE 2026'; + const raster = matrixToImage(encodeQR(text, 'raw', { border: 4 }), 4); + deepStrictEqual(readQR(mirror(raster)), text); + const photo = readImage('detection/blurred/image007.jpg'); + deepStrictEqual(readQR(mirror(photo)), readQR(photo)); +}); + it.runWhen(import.meta.url);