The Encoding Pipeline: From Bytes to Modules

A QR Code is not an image format — it is a channel-coded bitstream rendered as a 2D matrix. The full encoding pipeline defined in ISO/IEC 18004 proceeds in strict order:

code
Input data
  → Mode analysis & segment optimization
  → Bit stream encoding
  → Error correction codeword generation (Reed-Solomon over GF(2⁸))
  → Codeword interleaving
  → Module placement (serpentine path)
  → Masking (8 candidates scored, best selected)
  → Format & version information encoding
  → Final matrix

Each stage has precise algorithmic requirements. Skipping or misimplementing any step produces a symbol that violates the standard and may not decode.

Symbol Anatomy

A Version-N QR Code is a square matrix of (17 + 4N) × (17 + 4N) modules. A Version 1 symbol is 21×21; Version 40 is 177×177.

Function Patterns (Reserved Regions)

Pattern Location Size Purpose
Finder pattern Three corners (top-left, top-right, bottom-left) 7×7 each + 1-module separator Position detection
Alignment pattern Grid positions (Version ≥ 2) 5×5 each Geometric distortion correction
Timing pattern Row 6 and Column 6 1 module wide Module coordinate calibration
Format information Adjacent to finders 15 bits × 2 copies EC level + mask pattern
Version information Adjacent to finders (Version ≥ 7) 18 bits × 2 copies Version number

The Finder Pattern: 1:1:3:1:1 Ratio

code
███████   The ratio of dark:light:dark:light:dark modules
█     █   scanned through any line crossing the center is
█ ███ █   always 1:1:3:1:1. This property is rotationally
█ ███ █   invariant and scale-independent — the scanner
█ ███ █   detects it regardless of distance, angle, or
█     █   perspective distortion.
███████

The scanner searches the image for lines matching this ratio in horizontal, vertical, and diagonal directions. Candidate finder patterns are grouped into triples that form a consistent triangle, establishing the symbol's position and orientation.

Data Encoding Modes

Mode Indicator Character set Efficiency
Numeric 0001 0–9 10 bits / 3 digits
Alphanumeric 0010 0–9, A–Z, space, $%*+-./: 11 bits / 2 chars
Byte 0100 Any (ISO 8859-1 or UTF-8 via ECI) 8 bits / byte
Kanji 1000 Shift JIS double-byte 13 bits / character

Mode Optimization

A single QR Code can contain multiple mode segments. The encoder must solve an optimization problem: which segmentation minimizes total bit length?

code
Input: "ABC123def"
Naive:  all Byte mode → 9 × 8 = 72 bits
Optimal: Alphanumeric("ABC123") + Byte("def")
         = (6 chars × 5.5 bits) + mode overhead + (3 × 8) = ~57 bits

The optimal segmentation depends on version (which determines character count indicator length) and is typically solved with dynamic programming.

ECI (Extended Channel Interpretation)

The ECI mechanism (mode indicator 0111) allows encoding in any character set by specifying a numeric identifier:

ECI ID Character Set
000003 ISO 8859-1 (Latin 1)
000020 Shift JIS
000026 UTF-8

Without ECI, byte-mode data is assumed to be ISO 8859-1. For reliable Unicode support, encoders should emit ECI 26 before UTF-8 byte segments.

Data Placement: The Serpentine Path

After encoding, data bits must be placed into the matrix. The placement algorithm follows a specific serpentine path:

code
   Module columns are processed in pairs, right to left:
   
   Starting at bottom-right corner:
   
   ←  Column pair (n-1, n)
       ↑ upward, alternating right-left within pair
   
   ←  Column pair (n-3, n-2)
       ↓ downward, alternating right-left within pair
   
   ←  Column pair (n-5, n-4)
       ↑ upward again
   
   ... continuing until all data modules are filled
   
   Column 6 (timing pattern) is skipped entirely.

Within each two-column strip, the placement alternates between the right and left column, moving either upward or downward. Function patterns, format info, and version info regions are skipped — only data modules receive bits.

Remainder Bits

After all data and error correction codewords are placed, some versions have leftover modules. These "remainder bits" are filled with 0 and become dark or light based on the applied mask.

Reed-Solomon Error Correction over GF(2⁸)

Finite Field Arithmetic

QR Code error correction operates in the Galois field GF(2⁸) with the irreducible polynomial x⁸ + x⁴ + x³ + x² + 1 (0x11D). Every non-zero element can be expressed as a power of α (the primitive element, α = 2):

code
GF(2⁸) = {0, α⁰, α¹, α², ..., α²⁵⁴}

Addition: XOR of byte values
Multiplication: add exponents mod 255 (using log/antilog tables)

Example:
  α³ × α⁵ = α⁸
  α²⁵⁰ × α¹⁰ = α²⁶⁰ mod ²⁵⁵ = α⁵

Generator Polynomial Construction

For n error correction codewords, the generator polynomial is:

code
G(x) = (x − α⁰)(x − α¹)(x − α²)...(x − α^(n−1))

Example for 10 EC codewords:

code
G(x) = (x − 1)(x − α)(x − α²)...(x − α⁹)

This polynomial is computed once per EC configuration and stored as a coefficient table.

Encoding Process

python
def rs_encode(data_codewords: list[int], ec_count: int) -> list[int]:
    """Generate Reed-Solomon error correction codewords."""
    generator = compute_generator_polynomial(ec_count)
    
    # message polynomial = data shifted up by ec_count positions
    message = data_codewords + [0] * ec_count
    
    for i in range(len(data_codewords)):
        if message[i] == 0:
            continue
        coeff = gf_log[message[i]]
        for j in range(len(generator)):
            message[i + j] ^= gf_exp[(coeff + gf_log[generator[j]]) % 255]
    
    return message[len(data_codewords):]  # remainder = EC codewords

Error Correction Capacity

Given 2t error correction codewords, RS codes can correct:

  • Up to t symbol errors (unknown positions)
  • Up to 2t erasures (known positions)
  • Any combination where 2e + r ≤ 2t (e = errors, r = erasures)
EC Level Data ratio EC ratio Symbol recovery
L ~80% ~20% ~7% of modules
M ~68% ~32% ~15% of modules
Q ~55% ~45% ~25% of modules
H ~45% ~55% ~30% of modules

Masking: The Critical Visual Quality Step

Why Masking Exists

Without masking, encoded data could produce patterns that:

  • Resemble finder patterns (confusing scanners)
  • Create large uniform regions (making module boundaries ambiguous)
  • Produce imbalanced dark/light ratios (causing scanner threshold errors)

The 8 Mask Patterns

Each mask is defined by a condition function f(i, j) where i = row, j = column. If f(i, j) = 0, the module is inverted:

Mask Condition f(i, j) = 0 Visual pattern
000 (i + j) mod 2 = 0 Checkerboard
001 i mod 2 = 0 Horizontal stripes
010 j mod 3 = 0 Vertical stripes (every 3rd col)
011 (i + j) mod 3 = 0 Diagonal stripes
100 (i/2 + j/3) mod 2 = 0 Larger checkerboard
101 (i×j) mod 2 + (i×j) mod 3 = 0 Complex pattern
110 ((i×j) mod 2 + (i×j) mod 3) mod 2 = 0 Modified complex
111 ((i+j) mod 2 + (i×j) mod 3) mod 2 = 0 Another complex

Masking applies only to data modules — function patterns are never modified.

Penalty Scoring

All 8 masked versions are generated, and each is scored with 4 penalty rules:

Rule 1: Adjacent modules in a row/column with same color. Penalty = N₁ + (count − 5) for runs of 5+ consecutive same-color modules. N₁ = 3.

Rule 2: 2×2 blocks of same color. Penalty = N₂ × count of such blocks. N₂ = 3.

Rule 3: Patterns matching the finder-like sequence 10111010000 or 00001011101 in rows/columns. Penalty = N₃ per occurrence. N₃ = 40.

Rule 4: Deviation from 50% dark module ratio. Penalty = N₄ × k, where k = ⌊|percentage − 50| / 5⌋. N₄ = 10.

python
def evaluate_mask(matrix: list[list[int]]) -> int:
    """Calculate total penalty score for a masked QR matrix."""
    penalty = 0
    n = len(matrix)
    
    # Rule 1: consecutive same-color modules
    for row in matrix:
        penalty += score_consecutive_run(row)
    for col in range(n):
        column = [matrix[row][col] for row in range(n)]
        penalty += score_consecutive_run(column)
    
    # Rule 2: 2×2 same-color blocks
    for i in range(n - 1):
        for j in range(n - 1):
            if matrix[i][j] == matrix[i][j+1] == matrix[i+1][j] == matrix[i+1][j+1]:
                penalty += 3
    
    # Rule 3: finder-like patterns
    pattern_a = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]
    pattern_b = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]
    for row in matrix:
        penalty += count_pattern(row, pattern_a) * 40
        penalty += count_pattern(row, pattern_b) * 40
    for col in range(n):
        column = [matrix[row][col] for row in range(n)]
        penalty += count_pattern(column, pattern_a) * 40
        penalty += count_pattern(column, pattern_b) * 40
    
    # Rule 4: dark/light ratio
    dark = sum(sum(row) for row in matrix)
    total = n * n
    percent = (dark * 100) // total
    k = abs(percent - 50) // 5
    penalty += k * 10
    
    return penalty

The mask with the lowest total penalty is selected and encoded in the format information bits.

QR Code Security Threats

QR Codes are trusted execution vectors — users scan them without inspecting the payload. This creates several attack surfaces:

QRLjacking (QR Login Jacking)

Many services use "scan to login" (WeChat, WhatsApp Web, Discord). The attack:

  1. Attacker captures the service's login QR code
  2. Presents it to victim through social engineering
  3. Victim scans with their authenticated device
  4. Attacker's session receives victim's authentication

Mitigation: Time-limited QR sessions (< 2 minutes), require confirmation after scan, display session details before authorizing.

Phishing URL Injection

code
Legitimate: https://bank.example.com/transfer
Attack:     https://bаnk.example.com/transfer  (Cyrillic 'а')
            https://bank-example.com/transfer
            https://bank.example.com.evil.com/transfer

Printed QR Codes in public spaces can be overlaid with stickers containing malicious URLs. The victim sees a legitimate-looking context (bank poster, parking meter) but scans an attacker's code.

Mitigation: Scanner apps should display the full URL before opening, highlight domain mismatches, and warn on redirects.

Payload Injection via QR

QR Codes can encode arbitrary schemes:

  • tel:+1900XXXXXXX — premium-rate calls
  • sms:+XXXX?body=... — unauthorized messages
  • WIFI:T:WPA;S:...;P:...;; — connect to rogue access point
  • BEGIN:VEVENT — calendar spam injection

SQL Injection via QR

If a backend processes QR-scanned data without sanitization:

code
QR payload: '; DROP TABLE users; --

Any input from a QR Code must be treated as untrusted user input with the same validation applied to form submissions.

Mitigation Architecture

code
Scanner → URL preview (show full domain) →
  User confirmation →
    Safe browsing check (Google Safe Browsing API / similar) →
      Content Security Policy on landing page →
        No auto-execute of tel:/sms:/wifi: schemes

Micro QR and rMQR: Compact Variants

Micro QR Code (ISO/IEC 18004 Annex)

For applications where a full QR Code is too large:

Feature QR Code Micro QR
Finder patterns 3 1
Minimum size 21×21 (Version 1) 11×11 (M1)
Versions 1–40 M1–M4
EC levels L/M/Q/H M1: detection only; M2: L/M; M3: L/M/Q; M4: L/M/Q/H
Max numeric capacity 7,089 35

Micro QR achieves smaller size by:

  • Using only one finder pattern
  • Eliminating alignment patterns
  • Reducing format information redundancy

rMQR (Rectangular Micro QR, ISO/IEC 23941:2022)

A rectangular variant for narrow label spaces:

  • Aspect ratios from 1:2 to 1:14
  • Sizes from 7×43 to 17×139 modules
  • Single finder pattern + alignment patterns
  • Supports all 4 EC levels

Typical use case: pharmaceutical labels, electronic component marking, narrow PCB silkscreen areas where a square symbol doesn't fit.

The Scanner Pipeline: From Pixels to Data

Decoding a QR Code from a camera image involves a multi-stage image processing pipeline:

Stage 1: Binarization

Convert the grayscale image to black/white using adaptive thresholding (not global thresholding, which fails under uneven lighting):

code
For each pixel (x, y):
  local_mean = mean of pixels in surrounding window (e.g., 21×21)
  threshold = local_mean - C  (C is typically 5-10)
  binary[x][y] = 1 if pixel[x][y] < threshold else 0

Stage 2: Finder Pattern Detection

Scan rows and columns for the 1:1:3:1:1 ratio:

code
For each horizontal scanline:
  Track run lengths of consecutive same-color pixels
  When 5 consecutive runs are found:
    Check if ratios approximate 1:1:3:1:1 (within ±50%)
    If yes: record center point as candidate
    
Repeat for vertical scanlines and 45° diagonals

Candidates are clustered and verified: three candidates forming a consistent right-angle triangle constitute a detected symbol.

Stage 3: Geometric Transformation

Compute the perspective transformation (homography) that maps the detected finder pattern centers and alignment patterns to an ideal grid:

code
Source points: detected finder/alignment centers in image coordinates
Destination: ideal module grid coordinates

H = compute_homography(src_points, dst_points)  // 3×3 matrix

Stage 4: Module Sampling

Using the homography, sample each module center:

code
For each (row, col) in the ideal grid:
  (x, y) = H⁻¹ × (col + 0.5, row + 0.5)
  module_value = binary_image[round(y)][round(x)]

Stage 5: Format Decoding → Unmask → RS Decode

  1. Read format information bits, apply BCH error correction
  2. Extract EC level and mask pattern
  3. Remove mask from data modules
  4. De-interleave codewords into data + EC blocks
  5. Apply Reed-Solomon error correction to each block
  6. Concatenate corrected data codewords
  7. Parse mode indicators and decode character data

Structured Append: Multi-Symbol Encoding

ISO 18004 defines Structured Append mode, where data too large for a single symbol is split across up to 16 QR Codes:

code
Header (mode indicator 0011):
  - Symbol position (4 bits): 0–15
  - Total symbols (4 bits): 0–15
  - Parity byte (8 bits): XOR of all data bytes across all symbols

Each symbol can be scanned independently and in any order. The decoder accumulates all parts and verifies the parity byte before delivering the combined data.

Use cases: encoding large vCards, multi-page documents, or certificate data that exceeds single-symbol capacity.

Code Examples

JavaScript: Manual Bit Stream Construction

javascript
function encodeNumericMode(digits) {
  const groups = [];
  for (let i = 0; i < digits.length; i += 3) {
    const group = digits.slice(i, i + 3);
    const value = parseInt(group, 10);
    const bits = group.length === 3 ? 10 : group.length === 2 ? 7 : 4;
    groups.push(value.toString(2).padStart(bits, '0'));
  }
  return groups.join('');
}

function encodeAlphanumericMode(text) {
  const charMap = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
  const groups = [];
  for (let i = 0; i < text.length; i += 2) {
    if (i + 1 < text.length) {
      const val = charMap.indexOf(text[i]) * 45 + charMap.indexOf(text[i + 1]);
      groups.push(val.toString(2).padStart(11, '0'));
    } else {
      groups.push(charMap.indexOf(text[i]).toString(2).padStart(6, '0'));
    }
  }
  return groups.join('');
}

// Example: encoding "HELLO" in alphanumeric mode
const bitstream = '0010'              // mode indicator
  + '000001001'                       // character count (9 bits for Version 1)
  + encodeAlphanumericMode('HELLO');  // data bits

Python: Reed-Solomon over GF(2⁸)

python
GF_EXP = [0] * 512
GF_LOG = [0] * 256

def init_gf_tables():
    """Initialize GF(2⁸) log and antilog tables with polynomial 0x11D."""
    x = 1
    for i in range(255):
        GF_EXP[i] = x
        GF_LOG[x] = i
        x <<= 1
        if x & 0x100:
            x ^= 0x11D
    for i in range(255, 512):
        GF_EXP[i] = GF_EXP[i - 255]

def gf_mul(a: int, b: int) -> int:
    if a == 0 or b == 0:
        return 0
    return GF_EXP[GF_LOG[a] + GF_LOG[b]]

def gf_poly_mul(p: list[int], q: list[int]) -> list[int]:
    result = [0] * (len(p) + len(q) - 1)
    for i, a in enumerate(p):
        for j, b in enumerate(q):
            result[i + j] ^= gf_mul(a, b)
    return result

def rs_generator(n: int) -> list[int]:
    """Compute generator polynomial for n EC codewords."""
    g = [1]
    for i in range(n):
        g = gf_poly_mul(g, [1, GF_EXP[i]])
    return g

def rs_encode(data: list[int], ec_count: int) -> list[int]:
    """Encode data and return EC codewords."""
    gen = rs_generator(ec_count)
    msg = data + [0] * ec_count
    for i in range(len(data)):
        coeff = msg[i]
        if coeff == 0:
            continue
        for j in range(len(gen)):
            msg[i + j] ^= gf_mul(coeff, gen[j])
    return msg[len(data):]

init_gf_tables()

# Example: Version 1-M has 16 data codewords, 10 EC codewords
data_codewords = [32, 91, 11, 120, 209, 114, 220, 77, 67, 64, 236, 17, 236, 17, 236, 17]
ec = rs_encode(data_codewords, 10)
print(f"EC codewords: {ec}")

Go: Mask Evaluation

go
package main

import "math"

type MaskFunc func(i, j int) bool

var masks = [8]MaskFunc{
    func(i, j int) bool { return (i+j)%2 == 0 },
    func(i, j int) bool { return i%2 == 0 },
    func(i, j int) bool { return j%3 == 0 },
    func(i, j int) bool { return (i+j)%3 == 0 },
    func(i, j int) bool { return (i/2+j/3)%2 == 0 },
    func(i, j int) bool { return (i*j)%2+(i*j)%3 == 0 },
    func(i, j int) bool { return ((i*j)%2+(i*j)%3)%2 == 0 },
    func(i, j int) bool { return ((i+j)%2+(i*j)%3)%2 == 0 },
}

func penaltyRule4(matrix [][]int) int {
    n := len(matrix)
    dark := 0
    for i := 0; i < n; i++ {
        for j := 0; j < n; j++ {
            if matrix[i][j] == 1 {
                dark++
            }
        }
    }
    total := n * n
    percent := float64(dark) * 100.0 / float64(total)
    k := int(math.Abs(percent-50)) / 5
    return k * 10
}

func selectBestMask(dataMatrix [][]int, isFunction [][]bool) int {
    n := len(dataMatrix)
    bestMask := 0
    bestPenalty := math.MaxInt64

    for m := 0; m < 8; m++ {
        masked := applyMask(dataMatrix, isFunction, masks[m], n)
        penalty := calculateTotalPenalty(masked)
        if penalty < bestPenalty {
            bestPenalty = penalty
            bestMask = m
        }
    }
    return bestMask
}

Design Constraints for Reliable Scanning

Quiet Zone

The specification requires a minimum 4-module-wide quiet zone (white border) around the symbol. Without it, adjacent graphics may be interpreted as part of the symbol, causing decode failure.

Module Contrast

Scanner binarization requires sufficient contrast between dark and light modules:

  • Minimum: 40% difference in reflectance
  • Recommended: dark modules < 40% reflectance, light modules > 60%
  • Avoid gradients, translucency, or colors with similar luminance (red-on-green)

Physical Size Formula

code
Minimum module size ≥ scanner resolution × 2  (Nyquist criterion)

For camera-based scanning:
  Module pixels = (physical size × camera resolution) / scanning distance
  Minimum: 2-3 pixels per module for reliable detection

Rule of thumb:
  Minimum QR size (mm) ≈ scanning distance (mm) / 10
  30cm scan → 3cm minimum
  2m scan → 20cm minimum

Logo Overlay Budget

When overlaying a logo on the center of a QR Code:

  • Use EC level H (30% recovery)
  • Logo must not exceed ~25% of the data area (leaving safety margin)
  • Logo must not overlap finder patterns, timing patterns, or format information
  • Always verify with multiple scanner implementations after adding a logo

Version Selection Algorithm

code
1. Calculate data bit length for each candidate mode segmentation
2. For each version V (1–40):
   a. Look up total data codewords for V at desired EC level
   b. Calculate available data bits = codewords × 8
   c. If available bits ≥ required bits: V is sufficient
3. Return minimum sufficient version

Modules = 17 + 4V
V=1: 21×21 (26 total codewords at M level, 16 data + 10 EC)
V=10: 57×57 (346 total codewords at M level, 213 data + 133 EC)
V=40: 177×177 (3706 total codewords at M level, 2334 data + 1372 EC)

References

  • ISO/IEC 18004:2015 — Information technology — Automatic identification and data capture techniques — QR Code bar code symbology specification
  • ISO/IEC 23941:2022 — rMQR (Rectangular Micro QR Code)
  • Reed, I. S. & Solomon, G. (1960). "Polynomial Codes Over Certain Finite Fields." Journal of the Society for Industrial and Applied Mathematics
  • Denso Wave — QR Code.com (official Denso Wave documentation)
  • Thonky.com QR Code Tutorial — step-by-step encoding walkthrough (community reference)