编码流水线:从字节到模块

QR 码不是图像格式 —— 它是以二维矩阵呈现的信道编码比特流。ISO/IEC 18004 定义的完整编码流水线严格按以下顺序执行:

code
输入数据
  → 模式分析与分段优化
  → 比特流编码
  → 纠错码字生成(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 比率

code
███████   穿过中心的任意扫描线上
█     █   暗:亮:暗:亮:暗模块的比率
█ ███ █   始终为 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 码可以包含多个模式段。编码器必须解决一个优化问题:哪种分段方案使总比特长度最小?

code
输入: "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。

数据放置:蛇形路径

编码完成后,数据比特必须放置到矩阵中。放置算法遵循特定的蛇形路径:

code
   模块列按两列一组从右向左处理:
   
   从右下角开始:
   
   ←  列对 (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)的幂:

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

加法:字节值的异或
乘法:指数相加后对 255 取模(使用对数/反对数表)

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

生成多项式构造

对于 n 个纠错码字,生成多项式为:

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

10 个纠错码字的示例:

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

此多项式对每种纠错配置计算一次并存储为系数表。

编码过程

python
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。

python
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)。攻击流程:

  1. 攻击者捕获服务的登录 QR 码
  2. 通过社会工程向受害者展示
  3. 受害者用已认证的设备扫描
  4. 攻击者的会话获得受害者的认证

缓解措施:限时 QR 会话(< 2 分钟)、扫描后要求确认、授权前显示会话详情。

钓鱼 URL 注入

code
合法: 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 扫描数据时未做净化:

code
QR 载荷: '; DROP TABLE users; --

来自 QR 码的任何输入必须被视为不可信的用户输入,应用与表单提交相同的验证。

缓解架构

code
扫描器 → 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:二值化

使用自适应阈值将灰度图像转换为黑白(非全局阈值,全局阈值在光照不均时会失败):

code
对每个像素 (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 比率:

code
对每条水平扫描线:
  跟踪连续同色像素的游程长度
  当找到 5 个连续游程时:
    检查比率是否近似 1:1:3:1:1(误差 ±50% 以内)
    如果是:记录中心点为候选
    
对垂直扫描线和 45° 对角线重复

候选被聚类并验证:三个候选形成一致的直角三角形即构成检测到的符号。

阶段 3:几何变换

计算透视变换(单应性矩阵),将检测到的定位图案中心和校正图案映射到理想网格:

code
源点:图像坐标中检测到的定位/校正中心
目标:理想模块网格坐标

H = compute_homography(src_points, dst_points)  // 3×3 矩阵

阶段 4:模块采样

使用单应性矩阵采样每个模块中心:

code
对理想网格中的每个 (row, col):
  (x, y) = H⁻¹ × (col + 0.5, row + 0.5)
  module_value = binary_image[round(y)][round(x)]

阶段 5:格式解码 → 去掩码 → RS 解码

  1. 读取格式信息比特,应用 BCH 纠错
  2. 提取纠错级别和掩码图案
  3. 从数据模块移除掩码
  4. 将码字反交织为数据 + 纠错块
  5. 对每个块应用 Reed-Solomon 纠错
  6. 拼接已纠正的数据码字
  7. 解析模式指示符并解码字符数据

结构化追加:多符号编码

ISO 18004 定义了结构化追加模式,将单个符号容纳不下的数据分割到最多 16 个 QR 码中:

code
头部(模式指示符 0011):
  - 符号位置(4 位):0–15
  - 总符号数(4 位):0–15
  - 校验字节(8 位):所有符号中全部数据字节的异或

每个符号可以独立扫描且顺序不限。解码器累积所有部分并在交付组合数据前验证校验字节。

应用场景:编码大型 vCard、多页文档或超出单符号容量的证书数据。

代码示例

JavaScript:手动比特流构造

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

python
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:掩码评估

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%
  • 避免渐变、半透明或亮度相近的颜色(红绿配色)

物理尺寸公式

code
最小模块尺寸 ≥ 扫描器分辨率 × 2(奈奎斯特准则)

基于摄像头的扫描:
  模块像素数 = (物理尺寸 × 相机分辨率) / 扫描距离
  最低要求:每个模块 2-3 像素才能可靠检测

经验法则:
  最小 QR 码尺寸 (mm) ≈ 扫描距离 (mm) / 10
  30cm 扫描 → 最小 3cm
  2m 扫描 → 最小 20cm

Logo 覆盖预算

在 QR 码中心覆盖 Logo 时:

  • 使用 H 级纠错(30% 恢复能力)
  • Logo 不应超过数据区域的 ~25%(留出安全余量)
  • Logo 不得覆盖定位图案、时序图案或格式信息
  • 添加 Logo 后务必使用多种扫描器实现验证

版本选择算法

code
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 — 逐步编码教程(社区参考)