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:
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
███████ 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?
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:
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):
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:
G(x) = (x − α⁰)(x − α¹)(x − α²)...(x − α^(n−1))
Example for 10 EC codewords:
G(x) = (x − 1)(x − α)(x − α²)...(x − α⁹)
This polynomial is computed once per EC configuration and stored as a coefficient table.
Encoding Process
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.
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:
- Attacker captures the service's login QR code
- Presents it to victim through social engineering
- Victim scans with their authenticated device
- 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
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 callssms:+XXXX?body=...— unauthorized messagesWIFI:T:WPA;S:...;P:...;;— connect to rogue access pointBEGIN:VEVENT— calendar spam injection
SQL Injection via QR
If a backend processes QR-scanned data without sanitization:
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
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):
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:
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:
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:
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
- Read format information bits, apply BCH error correction
- Extract EC level and mask pattern
- Remove mask from data modules
- De-interleave codewords into data + EC blocks
- Apply Reed-Solomon error correction to each block
- Concatenate corrected data codewords
- 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:
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
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⁸)
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
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
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
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)