Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 21 additions & 1 deletion src/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions test/decode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);