From 16d1df5940944c9740cb47ed1d149b18d4529edd Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 21:50:41 -0600 Subject: [PATCH] decoder: read mirror images by retrying on the transposed grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mirror image locates like any symbol, since finders and timing are symmetric, and fails only at the format and data bits. When the sampled grid does not decode, transpose it in place and try once more: the transpose of a mirrored symbol is the upright one. ISO/IEC 18004 expects a reader to read mirror images, and a camera plane can arrive mirrored with no metadata saying so (iOS WebKit hands VideoFrame.copyTo a mirrored rear-camera plane and reports neither flip nor rotation). The retry runs only after a failed read. BoofCV accuracy is unchanged (57.5%, every category identical); a 1080p frame with no symbol costs the same (blank 2.73 → 2.70 ms, noise 8.37 → 8.21 ms); raster and photo decodes are within noise. --- CHANGELOG.md | 1 + src/decode.ts | 22 +++++++++++++++++++++- test/decode.test.ts | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) 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);