How a QR Code Is Found and Decoded in an Image
A step-by-step walkthrough of how a browser-based decoder finds and reads a QR code, from raw pixels through finder patterns, perspective correction, masking, and Reed-Solomon error correction, grounded in loopaloo's jsQR-based QR Code Reader.
A QR code looks like noise, but it is a remarkably disciplined structure. Every QR symbol carries a small set of fixed landmarks whose only job is to let a decoder find the code, figure out how it is rotated and scaled, and rebuild the grid of black and white squares (called modules) before a single bit of your data is read. Most of the bytes in a QR code are not your URL or WiFi password at all; a large fraction are redundancy that lets a scanner reconstruct the message even when part of the symbol is dirty, glare-washed, or hidden under a logo.
This post follows the path a decoder takes from a raw camera frame to a decoded string. The QR Code Reader on loopaloo runs entirely in your browser using the jsqr library (version ^1.4.0 per the project package.json), so the same pipeline described here is what executes locally on each video frame or uploaded image. I will be precise about what the code in src/components/tools/image/QRCodeReader.tsx actually does and where the heavy lifting happens inside jsqr itself.
The short version: locate three corner squares, lock onto the grid they imply, read a handful of metadata modules, undo a masking pattern, then run error correction over the result. Each step depends on the one before it, which is why a blurry or skewed image fails early rather than producing garbage.
What the decoder is handed
Before any QR logic runs, the tool has to turn a picture into a flat array of pixels. In the reader, both the camera path and the file-upload path converge on the same trick: draw the source onto a <canvas>, then call getImageData.
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert'
});
imageData.data is a Uint8ClampedArray of RGBA bytes, four per pixel, in row-major order. That is the only input jsQR needs: the raw pixel buffer plus its width and height. Everything else is computed.
Both canvases are created with { willReadFrequently: true }, a hint to the browser that the canvas will be read back repeatedly so it should keep the backing store somewhere cheap to copy from. This matters most on the camera path, where the reader runs jsQR once per animation frame via requestAnimationFrame, stopping the loop the instant a code is found.
One subtle detail: the camera path passes inversionAttempts: 'dontInvert', while the still-image path passes inversionAttempts: 'attemptBoth'. A live camera feed gives you many frames per second, so spending extra work inverting each frame (to catch white-on-black codes) is wasteful; you will likely get a clean dark-on-light frame soon anyway. A single uploaded image gets only one shot, so the reader tells jsQR to also try the inverted interpretation before giving up.
Binarization: from grayscale to black and white
jsQR does not work on color. Its first internal step is to convert the RGBA buffer to grayscale and then to a pure binary image, where every pixel is decided to be a module-on (dark) or module-off (light). This matters more than it sounds. A photo of a QR code has gradients, shadows, and uneven lighting, so a single global threshold ("anything darker than 128 is black") fails the moment one corner is in shadow.
The library uses a local, adaptive threshold: it divides the image into a grid of small regions and computes a separate threshold per region based on the average brightness around each pixel. A pixel under the shadowed corner is compared against its dark neighbors, not against the bright opposite corner. The output of this stage is a clean bitmap of true and false values that the geometry stage can reason about.
Finder patterns: the three corner squares
The three nested squares in the corners are the finder patterns, and they are the heart of detection. Each one is a 7x7 module block: a solid 3x3 center, a one-module light ring, and a one-module dark ring around that. The decisive property is the ratio of dark and light runs along any line passing through the center: 1:1:3:1:1.
A decoder scans the binarized image row by row, measuring runs of consecutive same-colored pixels. When it finds a run sequence whose lengths match that 1:1:3:1:1 proportion (within tolerance), it has a candidate finder pattern crossing that row. It then verifies the same ratio holds vertically and diagonally through the candidate center. That ratio is scale-invariant: it holds whether the code fills the frame or is a thumbnail, because it is about proportions, not absolute pixel counts.
| Element | Size (modules) | Role |
|---|---|---|
| Finder pattern | 7x7, three of them | Locate, orient, and scale the symbol |
| Separator | 1-module white border | Isolate each finder from data |
| Alignment pattern | 5x5, count depends on version | Correct perspective distortion |
| Timing patterns | alternating row and column | Establish the exact module pitch |
Three finder patterns, not four, is a deliberate choice. Three points uniquely define an orientation: the decoder works out which corner is missing and therefore which way is "up." The arrangement of the three located centers tells it the rotation, so a code photographed upside down or sideways still decodes. This is exactly why the reader can succeed regardless of how you hold your phone.
Alignment patterns and the module grid
Finder patterns give you the three corners. For small codes that is almost enough, but bigger symbols are physically larger, and a phone held at an angle introduces perspective distortion: the far edge of the code is rendered smaller than the near edge, so the grid is not a perfect square in the image. Straight grid math would drift and start sampling the wrong module halfway across.
Alignment patterns fix this. They are smaller 5x5 squares (with the same nested look) placed at known interior coordinates. Version 1 codes (the smallest, 21x21 modules) have none; larger versions add more of them in a regular layout. The decoder locates these interior anchors and uses them, together with the finder centers, to compute a perspective transform mapping image coordinates to grid coordinates. Once that transform exists, the decoder can ask "what is the color at module (row, col)?" and sample the correct pixel even on a warped photo.
The timing patterns, the alternating dark-light line running between the finders, let the decoder count modules precisely and confirm the grid pitch it inferred. Together these landmarks turn a skewed photograph into a clean matrix of true and false cells.
Format and version information
With the grid sampled, the decoder still does not know how to interpret the cells. Two pieces of metadata come first.
Version tells the decoder the symbol size. Versions run from 1 (21x21 modules) up to 40 (177x177). For versions 7 and above there is a dedicated version information block near the corners; for smaller versions the size is implied by the geometry. Get this wrong and every subsequent coordinate is off.
Format information is the more important of the two. It is a 15-bit field, stored twice (once near the top-left finder, once split across the other two), that encodes the error correction level and the mask pattern. Storing it twice, and protecting it with its own BCH error-correcting code, means the decoder can almost always recover the format even if one copy is damaged. Without it you cannot un-mask the data, so the format bits are defended harder than the payload.
The error correction level is one of four:
| Level | Approx. recovery capacity |
|---|---|
| L | up to ~7% of codewords |
| M | up to ~15% |
| Q | up to ~25% |
| H | up to ~30% |
Masking: why QR codes look random
If you encoded a URL straight into the grid, you might get large solid blocks of black or white, or accidental shapes that resemble finder patterns. Both confuse scanners. To avoid this, the encoder XORs the data region with one of eight mask patterns, simple formulas over the module coordinates such as "invert this cell if (row + col) is even." The mask that produces the most balanced, least confusing layout is chosen, and its index is recorded in the format information.
Decoding reverses this. The decoder reads the mask index from the format bits, regenerates the same pattern, and XORs it back out of the data region (the timing, finder, and alignment areas are never masked, so they are skipped). What remains is the real codeword stream. This is purely deterministic; there is no guessing once the mask index is known.
encode: data_bits XOR mask(row, col) -> stored module
decode: stored module XOR mask(row, col) -> data_bits
Reed-Solomon: why a logo can sit on top
The unmasked stream is not raw bytes yet. QR codes protect data with Reed-Solomon error correction, the same family of codes used on CDs and in deep-space transmission. The encoder splits the message into data codewords and appends error-correction codewords computed over them. Reed-Solomon operates on whole bytes (technically symbols in a finite field, GF(256)), which is why it shrugs off a smear that wipes out a contiguous patch: a damaged region corrupts a bounded number of codewords, and as long as the count stays under the code's correction capacity, the original message is recovered exactly.
This is the real reason you can drop a logo into the middle of a QR code. The logo destroys some modules, but if the symbol was generated at error correction level Q or H, there is enough redundancy that the corrupted codewords fall within the recoverable budget. There is nothing magic about the logo; the code simply tolerates a certain number of wrong codewords, and the logo eats into that budget. Cover too much, or stack a logo on top of dirt and glare, and you blow past the limit and decoding fails. Larger codes also interleave their codewords into blocks, so damage is spread across blocks rather than overwhelming one, which raises the practical tolerance for a single blemish.
Only after Reed-Solomon succeeds does the decoder read the data codewords: a mode indicator (numeric, alphanumeric, byte, or kanji), a length, and the payload bytes. jsQR returns that decoded string, and the reader hands it to parseQRData, which classifies it by prefix as a URL (https://), mailto:, tel:, sms:/smsto:, WIFI:, geo:, a vCard (BEGIN:VCARD), a calendar event (BEGIN:VEVENT/BEGIN:VCALENDAR), or plain text.
Why scans fail, in pipeline order
Because each stage feeds the next, you can map common failures to the step that breaks:
- Blur or low resolution: binarization cannot resolve individual modules, so finder ratios never match. The reader simply reports no code and keeps scanning the next frame.
- Extreme angle or fold: finder patterns are found but the perspective transform is unreliable and sampling lands between modules. Alignment patterns help, but only up to a point.
- Glare or heavy occlusion: the grid samples fine, but too many codewords are wrong and Reed-Solomon exceeds its correction budget.
- Inverted colors: handled by the
attemptBothsetting on image uploads, which retries with light and dark swapped.
The reader's user-facing advice ("ensure the QR code is clearly visible, well-lit, and not blurry") maps directly onto the first two failure modes: give binarization and finder detection a clean image and the rest of the pipeline almost always follows.
Try it
If you want to watch this pipeline run, the QR Code Reader on loopaloo decodes from an uploaded image or a live camera feed entirely in your browser, with no upload to a server. And if you want to see the encoding side, the mask selection and error-correction level that make all of the above possible, the QR Code Generator builds codes you can then read back. Generating a code at level H and then covering part of it before scanning is a quick, hands-on way to see Reed-Solomon recovery actually work.
Related Tools
Related Articles
Try Our Free Tools
200+ browser-based tools for developers and creators. No uploads, complete privacy.
Explore All Tools