编码流水线:从字节到模块
QR 码不是图像格式 —— 它是以二维矩阵呈现的信道编码比特流。ISO/IEC 18004 定义的完整编码流水线严格按以下顺序执行:
输入数据
→ 模式分析与分段优化
→ 比特流编码
→ 纠错码字生成(GF(2⁸) 上的 Reed-Solomon)
→ 码字交织
→ 模块放置(蛇形路径)
→ 掩码处理(8 个候选评分,选择最优)
→ 格式信息与版本信息编码
→ 最终矩阵
每个阶段都有精确的算法要求。跳过或错误实现任何步骤都会产生违反标准的符号,可能无法解码。
符号解剖
版本 N 的 QR 码是一个 (17 + 4N) × (17 + 4N) 模块的方阵。版本 1 为 21×21;版本 40 为 177×177。
功能图案(保留区域)
| 图案 | 位置 | 尺寸 | 用途 |
|---|---|---|---|
| 定位图案 | 三个角落(左上、右上、左下) | 7×7 + 1 模块分隔带 | 位置检测 |
| 校正图案 | 网格位置(版本 ≥ 2) | 5×5 | 几何畸变校正 |
| 时序图案 | 第 6 行和第 6 列 | 1 模块宽 | 模块坐标校准 |
| 格式信息 | 定位图案相邻 | 15 位 × 2 份 | 纠错级别 + 掩码图案 |
| 版本信息 | 定位图案相邻(版本 ≥ 7) | 18 位 × 2 份 | 版本号 |
定位图案:1:1:3:1:1 比率
███████ 穿过中心的任意扫描线上
█ █ 暗:亮:暗:亮:暗模块的比率
█ ███ █ 始终为 1:1:3:1:1。此特性具有
█ ███ █ 旋转不变性和尺度无关性 ——
█ ███ █ 无论距离、角度或透视畸变如何,
█ █ 扫描器都能检测到它。
███████
扫描器在图像中搜索水平、垂直和对角方向上匹配此比率的线段。候选定位图案被聚类为形成一致三角形的三元组,从而确定符号的位置和方向。
数据编码模式
| 模式 | 指示符 | 字符集 | 编码效率 |
|---|---|---|---|
| 数字 | 0001 | 0–9 | 10 位 / 3 位数字 |
| 字母数字 | 0010 | 0–9, A–Z, 空格, $%*+-./: | 11 位 / 2 字符 |
| 字节 | 0100 | 任意(ISO 8859-1 或通过 ECI 的 UTF-8) | 8 位 / 字节 |
| 汉字 | 1000 | Shift JIS 双字节 | 13 位 / 字符 |
模式优化
单个 QR 码可以包含多个模式段。编码器必须解决一个优化问题:哪种分段方案使总比特长度最小?
输入: "ABC123def"
朴素方案: 全部使用字节模式 → 9 × 8 = 72 位
最优方案: 字母数字("ABC123") + 字节("def")
= (6 字符 × 5.5 位) + 模式开销 + (3 × 8) ≈ 57 位
最优分段取决于版本(决定字符计数指示符长度),通常用动态规划求解。
ECI(扩展信道解释)
ECI 机制(模式指示符 0111)允许通过数字标识符指定任意字符集编码:
| ECI ID | 字符集 |
|---|---|
| 000003 | ISO 8859-1(Latin 1) |
| 000020 | Shift JIS |
| 000026 | UTF-8 |
没有 ECI 时,字节模式数据被假定为 ISO 8859-1。为了可靠的 Unicode 支持,编码器应在 UTF-8 字节段前发出 ECI 26。
数据放置:蛇形路径
编码完成后,数据比特必须放置到矩阵中。放置算法遵循特定的蛇形路径:
模块列按两列一组从右向左处理:
从右下角开始:
← 列对 (n-1, n)
↑ 向上移动,在列对内右左交替
← 列对 (n-3, n-2)
↓ 向下移动,在列对内右左交替
← 列对 (n-5, n-4)
↑ 再次向上
... 持续直到所有数据模块被填充
第 6 列(时序图案)被完全跳过。
在每个两列条带内,放置在右列和左列之间交替,向上或向下移动。功能图案、格式信息和版本信息区域被跳过 —— 只有数据模块接收比特。
剩余比特
所有数据和纠错码字放置完毕后,某些版本会有剩余模块。这些"剩余比特"填充 0,根据应用的掩码变为深色或浅色。
GF(2⁸) 上的 Reed-Solomon 纠错
有限域运算
QR 码纠错在伽罗瓦域 GF(2⁸) 中运算,使用不可约多项式 x⁸ + x⁴ + x³ + x² + 1(0x11D)。每个非零元素都可以表示为 α(本原元,α = 2)的幂:
GF(2⁸) = {0, α⁰, α¹, α², ..., α²⁵⁴}
加法:字节值的异或
乘法:指数相加后对 255 取模(使用对数/反对数表)
示例:
α³ × α⁵ = α⁸
α²⁵⁰ × α¹⁰ = α²⁶⁰ mod ²⁵⁵ = α⁵
生成多项式构造
对于 n 个纠错码字,生成多项式为:
G(x) = (x − α⁰)(x − α¹)(x − α²)...(x − α^(n−1))
10 个纠错码字的示例:
G(x) = (x − 1)(x − α)(x − α²)...(x − α⁹)
此多项式对每种纠错配置计算一次并存储为系数表。
编码过程
def rs_encode(data_codewords: list[int], ec_count: int) -> list[int]:
"""生成 Reed-Solomon 纠错码字。"""
generator = compute_generator_polynomial(ec_count)
# 消息多项式 = 数据左移 ec_count 个位置
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):] # 余数 = 纠错码字
纠错能力
给定 2t 个纠错码字,RS 码可以纠正:
- 最多 t 个符号错误(位置未知)
- 最多 2t 个擦除(位置已知)
- 任意满足 2e + r ≤ 2t 的组合(e = 错误数,r = 擦除数)
| 纠错级别 | 数据比例 | 纠错比例 | 符号恢复能力 |
|---|---|---|---|
| L | ~80% | ~20% | ~7% 的模块 |
| M | ~68% | ~32% | ~15% 的模块 |
| Q | ~55% | ~45% | ~25% 的模块 |
| H | ~45% | ~55% | ~30% 的模块 |
掩码:关键的视觉质量步骤
为什么需要掩码
如果不应用掩码,编码数据可能产生以下问题:
- 类似定位图案的模式(混淆扫描器)
- 大面积均匀区域(使模块边界模糊)
- 不平衡的明暗比率(导致扫描器阈值误判)
8 种掩码图案
每种掩码由条件函数 f(i, j) 定义,其中 i = 行,j = 列。当 f(i, j) = 0 时,模块被反转:
| 掩码 | 条件 f(i, j) = 0 | 视觉模式 |
|---|---|---|
| 000 | (i + j) mod 2 = 0 | 棋盘格 |
| 001 | i mod 2 = 0 | 水平条纹 |
| 010 | j mod 3 = 0 | 垂直条纹(每第 3 列) |
| 011 | (i + j) mod 3 = 0 | 对角条纹 |
| 100 | (i/2 + j/3) mod 2 = 0 | 大棋盘格 |
| 101 | (i×j) mod 2 + (i×j) mod 3 = 0 | 复杂图案 |
| 110 | ((i×j) mod 2 + (i×j) mod 3) mod 2 = 0 | 变体复杂图案 |
| 111 | ((i+j) mod 2 + (i×j) mod 3) mod 2 = 0 | 另一种复杂图案 |
掩码仅应用于数据模块 —— 功能图案永远不被修改。
惩罚评分
生成所有 8 个掩码版本,每个版本用 4 条惩罚规则评分:
规则 1:行/列中相邻同色模块。对连续 5+ 个同色模块的游程:惩罚 = N₁ + (计数 − 5)。N₁ = 3。
规则 2:同色的 2×2 块。惩罚 = N₂ × 此类块的数量。N₂ = 3。
规则 3:匹配类定位图案序列 10111010000 或 00001011101 的行/列模式。每次出现惩罚 = N₃。N₃ = 40。
规则 4:偏离 50% 暗模块比率。惩罚 = N₄ × k,其中 k = ⌊|百分比 − 50| / 5⌋。N₄ = 10。
def evaluate_mask(matrix: list[list[int]]) -> int:
"""计算掩码后 QR 矩阵的总惩罚分数。"""
penalty = 0
n = len(matrix)
# 规则 1:连续同色模块
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)
# 规则 2:同色 2×2 块
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
# 规则 3:类定位图案
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
# 规则 4:明暗比率
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
总惩罚最低的掩码被选中,并编码在格式信息比特中。
QR 码安全威胁
QR 码是被信任的执行向量 —— 用户扫描时不会检查载荷。这创造了多个攻击面:
QRLjacking(QR 登录劫持)
许多服务使用"扫码登录"(微信、WhatsApp Web、Discord)。攻击流程:
- 攻击者捕获服务的登录 QR 码
- 通过社会工程向受害者展示
- 受害者用已认证的设备扫描
- 攻击者的会话获得受害者的认证
缓解措施:限时 QR 会话(< 2 分钟)、扫描后要求确认、授权前显示会话详情。
钓鱼 URL 注入
合法: https://bank.example.com/transfer
攻击: https://bаnk.example.com/transfer (西里尔字母 'а')
https://bank-example.com/transfer
https://bank.example.com.evil.com/transfer
公共场所的印刷 QR 码可以被贴纸覆盖,贴纸包含恶意 URL。受害者看到看似合法的场景(银行海报、停车收费器)但扫描的是攻击者的码。
缓解措施:扫描器应用在打开前显示完整 URL,高亮域名不匹配,对重定向发出警告。
通过 QR 的载荷注入
QR 码可以编码任意协议方案:
tel:+1900XXXXXXX— 付费电话sms:+XXXX?body=...— 未授权短信WIFI:T:WPA;S:...;P:...;;— 连接恶意接入点BEGIN:VEVENT— 日历垃圾注入
通过 QR 的 SQL 注入
如果后端处理 QR 扫描数据时未做净化:
QR 载荷: '; DROP TABLE users; --
来自 QR 码的任何输入必须被视为不可信的用户输入,应用与表单提交相同的验证。
缓解架构
扫描器 → URL 预览(显示完整域名)→
用户确认 →
安全浏览检查(Google Safe Browsing API 或类似服务)→
着陆页的内容安全策略 →
不自动执行 tel:/sms:/wifi: 协议方案
Micro QR 与 rMQR:紧凑变体
Micro QR 码(ISO/IEC 18004 附录)
用于标准 QR 码过大的场景:
| 特性 | QR Code | Micro QR |
|---|---|---|
| 定位图案 | 3 个 | 1 个 |
| 最小尺寸 | 21×21(版本 1) | 11×11(M1) |
| 版本 | 1–40 | M1–M4 |
| 纠错级别 | L/M/Q/H | M1:仅检测;M2:L/M;M3:L/M/Q;M4:L/M/Q/H |
| 最大数字容量 | 7,089 | 35 |
Micro QR 通过以下方式实现更小尺寸:
- 仅使用一个定位图案
- 消除校正图案
- 减少格式信息冗余
rMQR(矩形微型 QR,ISO/IEC 23941:2022)
用于窄标签空间的矩形变体:
- 宽高比从 1:2 到 1:14
- 尺寸从 7×43 到 17×139 模块
- 单定位图案 + 校正图案
- 支持全部 4 个纠错级别
典型应用:药品标签、电子元器件标记、方形符号放不下的窄 PCB 丝印区域。
扫描器流水线:从像素到数据
从摄像头图像解码 QR 码涉及多阶段图像处理流水线:
阶段 1:二值化
使用自适应阈值将灰度图像转换为黑白(非全局阈值,全局阈值在光照不均时会失败):
对每个像素 (x, y):
local_mean = 周围窗口内像素的均值(如 21×21)
threshold = local_mean - C(C 通常为 5-10)
binary[x][y] = 1 if pixel[x][y] < threshold else 0
阶段 2:定位图案检测
扫描行和列以查找 1:1:3:1:1 比率:
对每条水平扫描线:
跟踪连续同色像素的游程长度
当找到 5 个连续游程时:
检查比率是否近似 1:1:3:1:1(误差 ±50% 以内)
如果是:记录中心点为候选
对垂直扫描线和 45° 对角线重复
候选被聚类并验证:三个候选形成一致的直角三角形即构成检测到的符号。
阶段 3:几何变换
计算透视变换(单应性矩阵),将检测到的定位图案中心和校正图案映射到理想网格:
源点:图像坐标中检测到的定位/校正中心
目标:理想模块网格坐标
H = compute_homography(src_points, dst_points) // 3×3 矩阵
阶段 4:模块采样
使用单应性矩阵采样每个模块中心:
对理想网格中的每个 (row, col):
(x, y) = H⁻¹ × (col + 0.5, row + 0.5)
module_value = binary_image[round(y)][round(x)]
阶段 5:格式解码 → 去掩码 → RS 解码
- 读取格式信息比特,应用 BCH 纠错
- 提取纠错级别和掩码图案
- 从数据模块移除掩码
- 将码字反交织为数据 + 纠错块
- 对每个块应用 Reed-Solomon 纠错
- 拼接已纠正的数据码字
- 解析模式指示符并解码字符数据
结构化追加:多符号编码
ISO 18004 定义了结构化追加模式,将单个符号容纳不下的数据分割到最多 16 个 QR 码中:
头部(模式指示符 0011):
- 符号位置(4 位):0–15
- 总符号数(4 位):0–15
- 校验字节(8 位):所有符号中全部数据字节的异或
每个符号可以独立扫描且顺序不限。解码器累积所有部分并在交付组合数据前验证校验字节。
应用场景:编码大型 vCard、多页文档或超出单符号容量的证书数据。
代码示例
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('');
}
// 示例:以字母数字模式编码 "HELLO"
const bitstream = '0010' // 模式指示符
+ '000001001' // 字符计数(版本 1 为 9 位)
+ encodeAlphanumericMode('HELLO'); // 数据位
Python:GF(2⁸) 上的 Reed-Solomon
GF_EXP = [0] * 512
GF_LOG = [0] * 256
def init_gf_tables():
"""使用多项式 0x11D 初始化 GF(2⁸) 对数和反对数表。"""
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]:
"""计算 n 个纠错码字的生成多项式。"""
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]:
"""编码数据并返回纠错码字。"""
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()
# 示例:版本 1-M 有 16 个数据码字,10 个纠错码字
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}")
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
}
可靠扫描的设计约束
静默区
规范要求符号周围至少 4 模块宽的静默区(白色边框)。没有它,相邻图形可能被解释为符号的一部分,导致解码失败。
模块对比度
扫描器二值化需要深色和浅色模块之间有足够的对比度:
- 最低:反射率差异 40%
- 推荐:深色模块 < 40% 反射率,浅色模块 > 60%
- 避免渐变、半透明或亮度相近的颜色(红绿配色)
物理尺寸公式
最小模块尺寸 ≥ 扫描器分辨率 × 2(奈奎斯特准则)
基于摄像头的扫描:
模块像素数 = (物理尺寸 × 相机分辨率) / 扫描距离
最低要求:每个模块 2-3 像素才能可靠检测
经验法则:
最小 QR 码尺寸 (mm) ≈ 扫描距离 (mm) / 10
30cm 扫描 → 最小 3cm
2m 扫描 → 最小 20cm
Logo 覆盖预算
在 QR 码中心覆盖 Logo 时:
- 使用 H 级纠错(30% 恢复能力)
- Logo 不应超过数据区域的 ~25%(留出安全余量)
- Logo 不得覆盖定位图案、时序图案或格式信息
- 添加 Logo 后务必使用多种扫描器实现验证
版本选择算法
1. 计算每种候选模式分段的数据比特长度
2. 对每个版本 V(1–40):
a. 查找 V 在目标纠错级别下的总数据码字
b. 计算可用数据比特 = 码字数 × 8
c. 如果可用比特 ≥ 所需比特:V 足够
3. 返回最小足够版本
模块数 = 17 + 4V
V=1: 21×21(M 级共 26 码字,16 数据 + 10 纠错)
V=10: 57×57(M 级共 346 码字,213 数据 + 133 纠错)
V=40: 177×177(M 级共 3706 码字,2334 数据 + 1372 纠错)
参考文献
- ISO/IEC 18004:2015 — 信息技术 自动识别和数据采集技术 QR 码条码符号规范
- ISO/IEC 23941:2022 — rMQR(矩形微型 QR 码)
- 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(Denso Wave 官方文档)
- Thonky.com QR Code Tutorial — 逐步编码教程(社区参考)