MostlyQR

Do photo QR codes actually scan? Our decode-test results

A QR code that is a photograph sounds like it must scan worse. We measured ours with a real decoder — blurred, shrunk, and read the way a reader reads.

Max Čelar··Printing & scanning

What’s new here: Decode-test results for our own photo QR implementation, measured with jsQR against a plain control code: zero of 4,225 modules mis-read at every reader aperture from 0.10 to 0.45 modules, identical 3x downscale tolerance, and a blur tolerance of 4-5 pixels against the plain code's 6 — the specific, small cost of turning a code into a picture.

A QR code that is a photograph looks like it must be a trade: pretty, but you lose some scans. That intuition is right about most implementations and wrong about how it has to work — and “some scans” is not a number anyone publishes.

Here are ours, from the test suite that had to pass before we would ship the feature at all.

# What a photo QR code is, and what it is not

Two very different things get called the same thing.

AI QR art — the diffusion-model kind — generates an image conditioned on a QR code and then hopes the result still decodes. Sometimes it does. The failure mode is invisible until someone in a dark bar points a three-year-old phone at it.

A dithered photo code is the other approach and the one measured here. The image is reduced to one bit — black or white per sub-pixel — by error diffusion, with one crucial constraint: at every module centre the dither is forced to the value the encoder demands, and the error that forcing creates (up to about 95% of a pixel’s worth, for a white module in a dark region) is pushed out into the neighbouring sub-pixels that have not been thresholded yet. The picture is made of the space between the module centres.

That distinction is the whole article. It means the code does not spend its error correction on decoration: every module still carries the encoder’s value, so the full redundancy is intact for creases, glare, a thumb over one corner — the things error correction actually exists for.

# The test

Everything below is reproducible from our repository, offline:

  • Encoder: our own packages/qr, error correction H, density floor minVersion: 12 → symbol version 12, 65 modules across, 10 pixels per module, 4-module quiet zone.
  • Styles: halftone (a true dithered photograph) and mosaic (only the modules are drawn, sized by local darkness), against a plain undithered code as the control — same payload, same version, same scale.
  • Images: procedural test images, including a diagonal gradient (every tone appears) and flat mid-grey, which is the worst case because every forced dot disagrees with its surroundings.
  • Decoder: jsQR 1.4.0, a real QR decoder, given the actual pixels.
  • Degradations: nearest-neighbour downscale, box blur followed by re-thresholding (a defocused camera), and an aperture read.

The suite this is built on is packages/qr/test/dither.test.js. It ran 39/39 passing on 2026-08-31, and a code that fails to decode is a failing test rather than a warning — that rule is why the feature exists in the shape it does. The tables below are printed by draft/mostlyqr/seo/benchmarks/photo.js, which runs the same styles through the same degradations; nothing here was transcribed.

# Zero misread modules, at every aperture

The measurement that matters most is not “did it decode” but “did the reader see the right value in every cell”, because a code that decodes only because error correction rescued it is a code that will fail when something else goes wrong.

A conforming reader does not sample one pixel per module. It averages a small circular aperture at each module centre and thresholds that average. So we did the same, at aperture radii of 0.10, 0.25, 0.36 and 0.45 modules (0.5 would be the entire module), and counted how many of the 4,225 modules would be read wrong:

Style Misread modules, aperture 0.10 / 0.25 / 0.36 / 0.45
plain code (control) 0 / 0 / 0 / 0
mosaic photo 0 / 0 / 0 / 0
halftone photo (gradient) 0 / 0 / 0 / 0
halftone photo (flat mid-grey, worst case) 0 / 0 / 0 / 0

Not one module, in any style, at any aperture, including the worst-case image. The photo code and the plain code present the reader with the same values. The error correction is untouched and still fully available.

# What it costs: about one step of blur

Untouched error correction does not mean identical. The dither does change what the pixels look like between the centres, and a badly out-of-focus camera averages those pixels together. So we pushed each style until it broke:

Style Max downscale that still decoded Max blur radius that still decoded
plain code (control) 6 px
mosaic photo 5 px
halftone photo (flat mid-grey) 6 px
halftone photo (gradient) 4 px

Downscaling — the code rendered smaller than native — is identical across every style: each one survived 3× and each one first failed at 4×. (Nearest- neighbour downscaling is not monotonic — some of them decoded again at 5×, where the sampling grid happened to land more kindly — so the figure we report is the first failure, not the last success.)

Defocus is where the cost shows up, and only for the halftone style on the hardest image: a third less margin than the plain control. The halftone of a flat mid-grey field matched the plain code exactly, so this is an image-dependent cost rather than a property of the technique. In plain terms: a photo code needs the camera to be about one step closer to focused than a plain code does. That is a real cost and it is a small one, and it is the honest answer to “do they scan?” — not “yes” and not “no” but “yes, with about one step less focus tolerance”.

The mosaic style sits between the two, which makes sense: it draws only the modules, so there is less ink between the centres for a blur to smear.

# Why density is the real requirement

The first version of this feature was technically correct and looked wrong, for a reason worth knowing if you use any photo-QR tool:

https://mqr.sh/AB12CD at error correction H — which is what a photo code forces — encodes to a 29-module grid. That is 841 cells for a whole photograph, and the result reads as noise rather than as a picture: people conclude the technique is broken when the real problem is that there is nowhere for the image to live. Forcing a denser symbol fixes it:

Minimum version Symbol Modules across Cells available for the image
none (what the payload needs) v3 29 841
8 v8 49 2,401
12 (our default) v12 65 4,225

Five times the cells at version 12. Every serious image-QR tool forces this up for the same reason, and the floor must never shrink a symbol below what the payload actually needs — data wins over density, always.

The consequence for printing is direct, and it is the reason this article sits in the same cluster as how many modules your payload needs: a 65-module code needs 73 module-widths of paper including its quiet zone. At a 0.5mm module that is 36.5mm — so a photo code is a poster, a menu, a packaging panel or a screen. It is not a business card.

# What the test does not cover

Stated plainly, because a benchmark with unstated limits is worse than none:

  • One decoder, synthetic pixels. jsQR is a real decoder but it is not a phone camera stack, and the input is clean rendered output rather than a photograph of a printed thing.
  • No print, no glare, no perspective, no crumpling. Every one of those costs margin, and they cost a photo code and a plain code differently.
  • No colour. These are 1-bit black and white codes.
  • Our own implementation. Nothing here says anything about anyone else’s photo or AI-art QR codes. If they overwrite modules and lean on error correction to repair the damage, they will behave nothing like this, and the fair test is the one above run against them.

# When to use one

  • Yes: posters, menus under glass, packaging panels, screens, exhibition graphics, anything printed large.
  • Probably: an album cover, a book jacket, a large product label — test it.
  • No: business cards, paper flyers that get folded, anything under about 35mm, anything scanned in bad light at distance.

And if what you want is not a whole photograph but your logo in the middle of an otherwise ordinary code, that is a different technique with a much smaller budget — we measured how big a logo can get too, and the answer is smaller than the internet thinks.

MostlyQR’s photo codes are the implementation measured here. The same dithering core runs in the browser preview and in the server-rendered PNG, so what you see in the builder is what the printer gets. The builder’s own note says “best on screens and large prints — a photo code is a harder read on small or crumpled paper”, which after these numbers seems like the right thing for it to say.

Frequently asked questions

Do photo QR codes scan reliably?

In our own testing, yes — with one measurable cost. Reading our photo codes the way a conforming reader does, by averaging a circular aperture at each module centre, not one of 4,225 modules was misread at any aperture we tested. They tolerated the same 3x downscale as a plain code. The only measured difference was defocus, and only on the hardest image: the plain control survived a 6-pixel blur radius, the mosaic style 5, and the halftone style 4 on a gradient (6 on flat grey). That is roughly one step of camera focus, not a different category of reliability.

Do artistic QR codes use up the error correction?

Many implementations do, which is why the technique has a bad reputation. Ours does not. Instead of overwriting whole modules with image data and relying on Reed-Solomon to repair the difference, it forces a dot at each module centre to the value the encoder demands and diffuses the resulting error into the surrounding sub-pixels. The picture is paid for out of the space between module centres, so the full error-correction budget is still there for the real world — for creases, glare and a thumb over the corner.

Can I print a photo QR code on a flyer?

We would not. Our own builder says "best on screens and large prints", and we mean it: a photo code needs a dense symbol (65 modules across in the test below, against 29 for the same short link unforced), so at flyer size each module gets very small very quickly. Posters, menus under glass, packaging panels and screens are the right places. A paper flyer that gets folded into a pocket is not.

Why does my photo QR code look like noise instead of a picture?

Almost always because the symbol is too coarse. A short link at error correction H encodes to a 29-module grid — 841 cells, far too few to carry a recognisable image, so the picture has nowhere to live. Forcing a denser symbol fixes it: version 8 gives 49 modules and version 12 gives 65 (4,225 cells, five times as many), and our builder defaults to version 12 for photo codes for exactly this reason. Data always wins, so a longer payload still gets a bigger symbol.

Sources
  1. jsQR — the decoder used in the test
  2. The published description of the dithered-QR technique this implementation was built from
  3. QR Code standardization (ISO/IEC 18004) — Denso Wave
More in this topic