Computer vision · QR structure · error correction

Reading a QR code sideways

The code looked ruined by perspective. It was not. A one-axis stretch gave OpenCV enough geometry to find the grid, after which the QR structure could do its job.

An illustrative QR-like pattern seen almost edge-on, with a faint square outline behind it and a small red accent.
Almost edge-on. Conceptual illustration of a QR-like pattern compressed by perspective, with a faint outline suggesting its frontal geometry.

The code was still there

Once the geometry was corrected, OpenCV read the code consistently. The destination does not matter to the experiment, so I have left it and the original photograph out. The interesting part is how much of the symbol survived in a shape the detector initially rejected.

The photograph did not destroy the code. It presented the code in a geometry that the first detector pass refused to believe.

That distinction is the whole story. An apparently unreadable code can retain its information while falling outside a detector's geometric expectations. Instead of trying to sharpen the pixels into submission, the successful path was to make the shape believable again.

Why the first scans failed

A QR symbol is square in its own plane. The original frame showed this one almost edge-on. Its average projected width was about 102 pixels and its average height about 412 pixels. Dividing by 29 modules gives an approximate sampling pitch of 3.5 pixels horizontally and 14.2 pixels vertically.

InputOperationOutcomeInterpretation
Full 709 × 1536 frameOpenCV detection and decodeFailedNo QR quadrilateral returned.
210 × 620 cropOpenCV detection and decodeFailedLess background did not fix the geometry.
Approximate four-corner warpMap a hand-picked quadrilateral to 600 × 600FailedThe corner estimates and interpolation were not accurate enough.
Crop variantsGrayscale, gamma, Otsu, and adaptive thresholdingFailedContrast was not the main bottleneck.
Crop stretched 2.5× horizontallyBicubic resize, then the standard detectorDecodedThe finder geometry entered the detector's workable range.

This was more severe than ordinary perspective. The large corner targets appeared tall and narrow, and the triangle formed by their centres was far outside the shape expected from a moderately tilted code. Blur and compression mattered more because the horizontal module pitch was already small.

The image still preserved favourable conditions: all three finder patterns were visible, the quiet zone was mostly intact, contrast was adequate, and the data region was not materially covered. Recovery depended on that surviving structure.

The recovery path

The successful sequence was deliberately simple. Isolate the symbol, test a conventional perspective correction, and then inspect the geometry instead of repeatedly sharpening the same distorted pixels. The decisive clue was the ratio between horizontal and vertical module size.

+--------------+   +----------------+   +----------------+
| isolate the  |-->| test detector  |-->| inspect module |
| symbol       |   | and hand warp  |   | geometry       |
+--------------+   +----------------+   +-----------o----+
                                                    |
                                                    v
+----------------+   +----------------+   +---------o------+
| decode and     |<--| detector maps  |<--| stretch x-axis |
| verify grid    |   | remaining skew |   | only           |
+----------------+   +----------------+   +----------------+

A coarse sweep tested horizontal scale factors from 2.0 to 6.0. The first tested success was 2.5×. A later 0.05-step sweep on OpenCV 4.13.0 decoded continuously from 2.35× through 5.65×. This is not a universal threshold. It is a measurement from one image and one detector build.

The broad success band matters. The recovery did not depend on discovering one magical scale factor. Rescaling moved the finder patterns into a wide region where the detector could estimate their centres and finish the projective correction itself.

Why horizontal stretching worked

Bicubic enlargement did not invent missing modules. It redistributed existing pixel intensities over more horizontal samples. Upscaling cannot recover a transition after adjacent modules have collapsed into the same source pixel, but it can transform a recognisable compressed pattern into a shape that a particular detector will analyse.

The source had roughly 3.5 horizontal pixels per module. At 2.5×, that became about 8.8 pixels per module. The vertical pitch remained around 14.2. The symbol was still not square, but the finder targets became less eccentric. Once their centres were found, the projective transform handled the remaining skew and returned a rectified, binarised grid.

source x-pitch ≈ 102 / 29 = 3.52 px/module
after 2.5× stretch ≈ 8.79 px/module
y-pitch remains ≈ 412 / 29 = 14.21 px/module

This also explains why the hand-built perspective warp failed. It relied on approximate outer corners. A small corner error becomes a systematic grid-phase error across 29 rows and columns. Stretch-first detection let the reader use strong internal landmarks to estimate the grid more accurately than four manually selected border points.

Geometry was the rescue. Thresholding and sharpening helped inspection, but neither produced the successful decode.

What the QR format contributed

A QR code is not an arbitrary checkerboard. It is a constrained coordinate system with repeated landmarks, a known traversal order, and redundant metadata. Those rules give a reader several ways to ask where the symbol is, how large each module is, and which modules carry data rather than fixed structure.

+-------------------------------+
| +------+-- timing --+------+  |
| |finder|o-o-o-o-o-o-|finder|  |
| +--o---+            +------+  |
|    |                          |
|    o timing column            |
|    |       data + parity      |
| +--o---+             +-----+  |
| |finder| format bits |align|  |
| +------+             +-----+  |
+-------------------------------+

Three finder patterns

The nested targets in the top-left, top-right, and bottom-left corners establish orientation. Their black-and-white run sequence is distinctive. Three centres let the reader infer rotation, rough scale, and the missing fourth corner. All three remained visible in the source, which is the primary reason recovery was possible.

Timing and alignment

The alternating timing row and column provide a module clock. Version 3 also contains one 5 × 5 alignment target near the lower-right region, centred at coordinate 22 on both axes. This smaller target helps refine sampling when the symbol is viewed obliquely or printed on a distorted surface.

Format information

Two copies of a 15-bit format word sit beside the finder patterns. The underlying five information bits identify the error-correction level and one of eight masks. A BCH code protects those bits, and the copies occupy separate regions. This metadata must be recovered before the reader can unmask and interpret the data modules.

Masking

Before printing, the encoder applies one of eight deterministic masks to data and parity modules. This is not encryption. Masking avoids long runs, large solid blocks, and other patterns that could confuse thresholding or finder logic. The decoder reads the mask number from the format word and reverses it.

Version 3, level M, mask 6

OpenCV's rectified result was exactly 29 × 29 modules. QR versions start at 21 × 21 and add four modules per side with every version, so the dimension identifies Version 3:

dimension = 21 + 4 × (version - 1)
29 = 21 + 4 × (3 - 1)

Using the decoded text locally, I re-encoded Version 3 under all four error-correction levels and all eight masks. Only one candidate matched every module in the rectified grid: error-correction level M with mask pattern 6.

The five logical format bits are 00 110: level M followed by mask index 6. After BCH protection and the QR format mask, the printed format word is 100111110010111. These bits describe how to interpret the symbol, not what its payload says.

Mask 6 flips a data module when this expression is zero, where r and c are row and column indices:

((r × c) mod 2 + (r × c) mod 3) mod 2 = 0

The codeword layout

Version 3-M uses one block of 70 codewords: 44 for framing, content, a terminator, and padding, followed by 26 Reed-Solomon parity codewords.

+--------------------------------------+-----------------------+
| 44 data codewords                    | 26 parity codewords   |
| framing + payload + padding          | Reed-Solomon symbols  |
+--------------------------------------+-----------------------+

What error correction did here

QR payload protection uses Reed-Solomon coding over byte-sized symbols. With 26 parity symbols, a decoder can theoretically correct up to 13 unknown erroneous codewords in this one block. That is a byte-level bound, not a promise that 13 arbitrary square patches may be painted over. One blurred region can damage bits belonging to several codewords, while a visually large mark may fall mostly on fixed patterns or repeated metadata.

Level M is conventionally described as providing roughly 15% restoration capability. That percentage is a practical class label. The block structure, distribution of damage, finder integrity, and sampling quality determine whether a particular photograph decodes.

The decisive obstacle in this image was localisation and geometry, not missing data. Once the symbol was rescaled, the rectified output matched its local re-encoding in all 841 module positions.

Does that prove Reed-Solomon corrected zero bytes? Not quite. OpenCV's public detectAndDecode values include decoded text, corner points, and an optional rectified symbol, but not an error-count result. The exact module match is strong evidence that the final sampled grid was already clean. Reed-Solomon was probably a validation and safety layer rather than the main rescue mechanism, but the API does not expose enough evidence for a categorical claim.

Format-word BCH protection

The 15-bit format word is a BCH-protected encoding of five information bits. Implementations can recover it when a small number of bits are wrong, and the word is printed twice. That protection helps the decoder learn the correction level and mask before Reed-Solomon processing begins.

Repeated geometric landmarks

Finder, timing, and alignment patterns are not repaired by payload parity. They are fixed structural patterns. If the reader cannot localise the grid, the parity bytes remain inaccessible. Here, x-axis stretching helped the detector reach the stage where error correction could even be attempted.

Error correction can repair wrong codewords. It cannot repair a detector that never finds the coordinate system.

What would have defeated it

The recovery worked because the image was hostile in one dominant way, horizontal geometry, while retaining most of the actual symbol information. Several changes could have made the same technique fail.

Fewer than a few usable pixels per module

Upscaling can spread samples. It cannot separate modules already averaged into the same source pixel. Strong compression, motion blur, or a more distant camera could erase the horizontal transitions irreversibly.

Loss of a finder pattern or quiet zone

Some readers tolerate cropped borders, but heavy background interference or a covered finder target can prevent localisation. Reed-Solomon does not reconstruct finder patterns.

Specular glare across the data region

A bright reflection across many rows could create a burst of codeword errors beyond the block's capacity, even if much of the symbol remained visible.

Damage concentrated in metadata

The format word is duplicated and protected, but not infinitely. If both copies became unreadable, the decoder would not know the mask or correction level. Version 3 has no separate version-information blocks, so its dimension must also be estimated correctly.

Treat recovered content as untrusted. Decoding reveals data; it does not establish that following or executing that data is safe.

Reproduce the method

The essential recovery fits in a few lines. Point the script at a similarly oblique frame and adjust the crop coordinates for that image.

import cv2

image = cv2.imread("source-frame.jpg")

# NumPy image slicing is [y1:y2, x1:x2].
crop = image[280:900, 180:390]

# Correct the dominant anisotropic distortion first.
stretched = cv2.resize(
    crop,
    None,
    fx=2.5,
    fy=1.0,
    interpolation=cv2.INTER_CUBIC,
)

detector = cv2.QRCodeDetector()
data, points, straight = detector.detectAndDecode(stretched)

assert data
assert points is not None
assert straight.shape == (29, 29)

To identify the QR parameters, force the known grid size, compare every correction-level and mask combination against the rectified grid, and keep the exact match:

import numpy as np
import qrcode
from qrcode import constants

observed = straight < 128  # black modules are True
levels = {
    "L": constants.ERROR_CORRECT_L,
    "M": constants.ERROR_CORRECT_M,
    "Q": constants.ERROR_CORRECT_Q,
    "H": constants.ERROR_CORRECT_H,
}

matches = []
for name, level in levels.items():
    for mask in range(8):
        qr = qrcode.QRCode(
            version=3,
            error_correction=level,
            border=0,
            box_size=1,
            mask_pattern=mask,
        )
        qr.add_data(data, optimize=0)
        qr.make(fit=False)
        candidate = np.array(qr.get_matrix(), dtype=bool)
        differences = np.count_nonzero(candidate != observed)
        if differences == 0:
            matches.append((name, mask))

assert matches == [("M", 6)]

The test system used OpenCV 4.13.0 and Python 3.13.5. Another detector version may succeed at a different scale factor, or decode a similarly distorted source without preprocessing.

Sources

The measurements, crop coordinates, scale sweep, and module comparison came from the original frame. The QR structure and OpenCV behaviour were checked against these primary and first-party sources.

  1. DENSO WAVE, “What is a QR Code?”, an overview of QR Code features, directional reading, and damage resistance. Read the overview.
  2. DENSO WAVE, “Information capacity and versions”, covering dimensions, module terminology, and version growth. Read the version guide.
  3. DENSO WAVE, “Error correction feature”, covering L/M/Q/H levels and Reed-Solomon restoration principles. Read the error-correction guide.
  4. DENSO WAVE, “QR Code Standardization”, outlining the ISO/IEC 18004 standard. Read the standards overview.
  5. OpenCV 4.13, QRCodeDetector, documenting detection, decoding, quadrilateral points, and rectified output. Read the OpenCV documentation.
  6. ZXing, Version.java, including the Version 3 alignment centre and error-correction block tables. Read the source.
  7. ZXing, Decoder.java and BitMatrixParser.java, showing format parsing, unmasking, codeword traversal, and Reed-Solomon correction. Read the decoder and read the parser.
  8. I. S. Reed and G. Solomon, “Polynomial Codes Over Certain Finite Fields” (1960), the foundational Reed-Solomon paper. Open the DOI record.

The parameter match used a standards-conformant Python QR encoder and a brute-force comparison across 32 combinations. OpenCV does not reveal how many Reed-Solomon symbols it corrected, which is why I treat geometry as the dominant explanation rather than claiming that parity did nothing.