1. 选题拆解:这个系统真正要解决的是什么问题
每年到了选题季,总有人跑来问我,二维码识别这种东西不是早就烂大街了吗,手机相机一扫就出来了,拿它当毕业设计会不会太水。说实话,我一开始也这么想,直到我自己动手在一个真实场景里跑了一遍才明白,基于深度学习的二维码识别系统这个题目的难度根本不在"识别一个干净的码",而在于"识别一个已经被现实世界蹂躏过的码"。你在实验室白纸上打印一张二维码,用手机摄像头对着拍,识别率百分之百,这不叫系统,这叫调库。真正的工程量藏在那些扫不出来的瞬间:贴歪了、反光了、被手指压住一角、印在曲面包装上、在昏暗车库里拍的、镜头有油污、远距离拍成几十个像素的小块。
所以这个标题拆开来看,其实是三个层次的东西。第一层是人工智能算法,也就是用卷积网络去做目标检测,把画面里的二维码区域框出来,这是深度学习真正能发挥作用的地方;第二层是几何与图像处理,拿到框之后要做定位点检测、透视矫正、网格采样、二值化,这一层传统方法依然强势,深度学习并不占优;第三层是编码理论,也就是二维码本身的 Reed-Solomon 纠错解码,这是标准协议层面的事,跟 AI 关系不大。很多人做这个题目做到一半会突然发现,自己花两个月训的模型,性能瓶颈居然卡在最后那几十行纠错解码代码上,这是极其常见的翻车方式。
我把话说明白一点:这个题目适合两类人。一类是想完整走一遍"数据构造—模型训练—工程集成—性能评测"全流程的同学,因为二维码这个任务的数据可以自己合成,标注成本几乎为零,非常适合在有限算力和有限时间里做出一个闭环;另一类是偏工程方向、想练手 C++/Python 混合编程和模型部署的同学,因为这条流水线的模块边界非常清晰,每一段都能单独拿出来做优化实验。反过来说,如果你指望靠调一个开源的识别库交差,那我劝你换个题目,答辩现场老师随便拿手机拍一张反光的码就能把你问住。
1.1 传统方案的能力边界到底卡在哪
要理解为什么非要上深度学习,得先搞清楚老办法是怎么做的。以常见的开源解码库为例,它的流程大致是:先对整幅灰度图做自适应二值化,然后在二值图上找符合 1:1:3:1:1 比例的黑白黑白黑线段,用这些线段去凑三个定位图案,凑齐三个之后算版本、做透视变换、按模块中心采样、最后走纠错解码。这条链路在理想条件下极其高效,一帧处理能做到几毫秒,功耗极低,这也是它至今还活在各种嵌入式设备里的原因。
但它的脆弱点也非常明确:整条链路是串行且刚性的,前一步错了后面全崩。二值化一旦被强烈光照梯度破坏,比例搜索就找不到线段;定位图案被遮挡超过一定比例,三个角凑不齐直接判定失败;如果画面里同时有好几个码,或者背景里有大量类似条纹的纹理(比如货架、书脊、栅栏),误检率会飙升。我实测过一个典型场景:把二维码印在圆柱形瓶身上,侧面打一束斜射光,传统方案的成功率掉到三成以下,因为透视形变加局部过曝让比例检测彻底失效。
这正是深度学习能插进来的地方。卷积网络不需要你去手工定义"什么样子算二维码",它从大量样本里自己学特征,对光照变化、局部遮挡、形变、背景干扰的鲁棒性天然更强。更关键的是,它能把"找码"这件事从二值图上搬到原图特征空间里做,避免了早期二值化把信息一次性毁掉的致命问题。所以这个系统的核心思路就很清楚了:把传统方案里最脆弱的"定位"环节换成神经网络,把最可靠、最有数学保证的"解码"环节保留下来。
1.2 完整系统的功能边界与验收标准
做毕设最忌讳的是需求没定清楚就开干,最后做出来一个"能跑但不道好不好"的东西。我在动手之前先把验收标准写死在一张表里,后面所有实验都对着这张表打分,这样答辩的时候每个数字都有出处。下面是我当时定下的指标,你可以按自己的算力和时间调整,但建议保留这个结构:
| 模块 | 指标名称 | 目标值 | 测量方式 |
|---|---|---|---|
| 检测 | 召回率 Recall@0.5 | ≥ 0.96 | 自建测试集,IoU>0.5 判正 |
| 检测 | 误检率 | ≤ 3% | 负样本集(无码图片)统计 |
| 检测 | 单帧耗时 | ≤ 40 ms | 中端 CPU,单线程,640 输入 |
| 矫正 | 采样点偏移 | ≤ 0.5 模块 | 与真值网格对比 |
| 解码 | 整体解码成功率 | ≥ 92% | 混合测试集(含困难样本) |
| 解码 | 误码率(错解) | ≤ 0.5% | 解出的内容与真值比对 |
| 系统 | 端到端吞吐 | ≥ 20 FPS | 1080p 视频流 |
这里有两个指标我要特别强调。召回率比精确率重要得多,因为漏检一个码用户就扫不出来,而误检一个框最多是多跑一次解码然后失败,代价小得多,所以调阈值的时候要往召回率方向偏。误码率必须单独统计,这是很多同学会忽略的一点:解码器有可能"成功地"输出一串错误内容,如果你的系统只统计"是否输出",那这个错误是隐形的,而实际业务里扫出错误链接比扫不出来严重十倍。
1.3 为什么不做纯端到端的黑盒方案
我知道有人会想,既然都上深度学习了,为什么不干脆一个网络输入图片、输出字符串,端到端一把梭。这个想法很诱人,我在前期也试过,结论是:在小规模数据和有限算力下,端到端方案的可控性太差,不适合作为毕设主线。
原因有三。第一,二维码的内容是任意长的,可能是十几个字符的编号,也可能是几百字节的网址,输出长度不固定,你需要引入序列解码结构,训练难度陡增。第二,端到端方案把纠错能力丢掉了。二维码标准里的 Reed-Solomon 纠错是一套有严格数学保证的机制,在中等纠错等级下能自动修复相当比例的模块错误,你让网络去学这个,等于让它重新发明一遍编码理论,纯属浪费模型容量。第三,也是最现实的一点:端到端方案出错了你没法定位问题。是框歪了?是采样偏了?还是某个字符认错了?黑盒给不了你答案,而答辩老师最喜欢问的就是"这里为什么错了"。
所以我的建议是走混合路线:检测用深度学习,几何用传统方法,解码用标准库。每一段都能单独测试、单独可视化、单独解释,这才是一个工程系统该有的样子。当然,端到端方案可以作为论文里的对比实验保留一小节,说明你思考过这条路,反而是加分项。
2. 整体架构设计:把问题拆成三段流水线
整条流水线我是这么切的:检测器负责"哪里有码",几何模块负责"把码摆正",解码器负责"把码读出来"。三段之间用明确的数据结构传递,避免耦合。这个设计听起来平平无奇,但真正落地的时候,模块间接口定得好不好,直接决定了你后期调试是痛不欲生还是轻松愉快。
先说检测器。输入是一帧原始图像(BGR 三通道),输出是一组候选框,每个框带置信度和四个角点。注意我这里输出的是角点而不是矩形框,因为二维码在透视形变下是任意四边形,用轴对齐矩形框去描述会引入大量背景,后面矫正的时候误差很大。让检测网络直接回归四个角点,是这套方案里我认为最重要的一个设计决策。
再说几何模块。它拿到四个角点之后,要判断哪个角是左上、哪个是右上,因为二维码的三个定位图案是有固定方位关系的(缺一个角,那个角是右下)。判断方法很简单:算四边形四个顶点的坐标,找出那个"到另外三点距离之和最大"的角,它就是右下角;剩下三个再按叉积符号排顺时针。这一步做错了,后面全盘皆输,我在这个坑里趴了整整两天。
最后是解码。矫正后的图像是一张标准的正方形网格图,按模块尺寸归一化到比如 330×330(版本 1 是 21×21 模块,每个模块 10 像素的话要留边距),然后交给解码库。如果解码库返回失败,我还会做一次反向尝试:把图像反色再解一次,因为深色背景上的浅色码在采样后极性可能反了。
2.1 三段式流水线相比一体化方案的优势
我拿实际数据说话。在同一个包含 1200 张困难样本的测试集上,纯传统方案的整体成功率大约是 61%,端到端网络方案在我当时的算力和数据量下做到 74% 左右但误码率偏高,而三段式流水线做到了 93%,误码率控制在 0.3% 以内。差距主要来自哪里?我做过消融,把检测换成神经网络、后面全用传统方法,成功率就从 61% 跳到了 84%,说明瓶颈确实在定位环节;再把几何矫正里的角点排序和透视变换补上,从 84% 到 91%;最后加反色重试和多尺度采样,才到 93%。
这个数据说明一个道理:不要指望单一模块解决所有问题,工程系统的提升往往来自多个小改进的叠加。而且分段之后,每个改进点都能独立验证效果,这在写论文的时候就是现成的实验章节,一举两得。
另一个隐性好处是可调试性。当系统在某个样本上失败时,你可以把中间结果全部 dump 出来:原图、检测框、矫正后的图、二值化后的图、采样网格叠加图、解码错误码。一眼就能看出是哪一环出了问题。我后来的习惯是给每张失败样本都生成这样一张"五联图",攒了几十张之后按失败模式分类,改进方向自然就清晰了。
2.2 模块间接口的字段设计
接口设计这块我踩过坑,一开始图省事直接用字典传,键名随手写,后来代码一多就出现box、bbox、rect三种写法混用,调试的时候找 bug 找到怀疑人生。后来我改成用数据类,字段定死,谁也别想乱来:
from dataclasses import dataclass, field from typing import List, Optional import numpy as np @dataclass class Detection: corners: np.ndarray # shape (4,2), float32, 顺序为 左上/右上/右下/左下 score: float # 置信度 0~1 version_hint: Optional[int] = None # 可选的版本猜测,用于加速解码 @dataclass class SampleResult: grid: np.ndarray # shape (N,N) 的 0/1 矩阵,N = 模块数 version: int module_size: float # 每个模块在矫正图中的像素尺寸 transform: np.ndarray # 3x3 透视变换矩阵,便于回溯 @dataclass class DecodeResult: success: bool text: str = "" error_code: int = 0 corrected_modules: int = 0 # 纠错实际修复的模块数,用于评估图像质量corrected_modules这个字段是我特意加的,它记录了解码过程中纠错机制实际修复了多少个模块。这个数值非常有用:它相当于一个"图像质量探针",如果某批样本平均修复模块数很高,说明你的采样精度在退化,虽然这次解出来了,但已经接近极限,再难一点就崩了。
2.3 技术选型对照表与我的取舍理由
选型这块我给一张表,把你可能会纠结的几个点摊开讲:
| 环节 | 候选方案 | 我的选择 | 理由 |
|---|---|---|---|
| 检测 backbone | 经典单阶段检测 / 轻量 MobileNet 系列 / 大模型 | 轻量单阶段 | 毕设算力有限,二维码特征简单,不需要大模型 |
| 检测输出 | 轴对齐框 / 旋转框 / 四角点回归 | 四角点回归 | 透视形变下矩形框误差太大 |
| 输入分辨率 | 320 / 640 / 1024 | 640 为主,小目标走切片 | 兼顾速度和小码召回 |
| 矫正 | 直接仿射 / 透视变换 | 透视变换 | 斜拍场景仿射误差不可接受 |
| 采样 | 最近邻 / 双线性 / 面积加权 | 面积加权 | 模块边界混叠时更稳 |
| 二值化 | 全局 Otsu / 自适应 / 局部块 | 局部自适应 + 全局兜底 | 应对光照不均 |
| 解码 | 自研 / 开源库 | 开源库为主,自研补丁 | 编码理论部分没必要重写 |
关于最后一个"解码要不要自己写",我的态度很明确:除非你的题目重点就是编码理论,否则不要重写。二维码的纠错解码涉及有限域运算、生成多项式、伴随式求解,写对了容易,写错了很难查,而且这部分在答辩时并不是"AI 相关"的加分点。把时间花在检测和矫正上,收益高得多。当然,如果你时间充裕,把解码的核心步骤读懂并复现一遍,对理解为什么某些图像解不出来有巨大帮助,这是另一回事。
3. 数据这关过不去,模型再好也白搭
我在这个项目上花费时间最多的部分,不是模型训练,也不是调参,而是造数据。这一点可能是很多人做毕设时最容易低估的环节。你手头能拍到的真实二维码样本,撑死几百张,而且分布极度单一:办公桌上的纸质码、屏幕上显示的码、包装盒上的码。用这种数据训出来的检测器,换个光照条件就废。
更麻烦的是二维码数据有一个天然特性:它是可以无限生成的。任意内容、任意版本、任意纠错等级、任意颜色,你都能程序化地造出来。这意味着你没有理由去手工标注,所有精力都应该投入到"怎么把合成的码做得像真实拍摄的码"这件事上。我最终的训练集是 4 万张,其中 95% 是合成样本,全部零人工标注,标注信息在生成时就已经确定。
3.1 真实拍摄的码和合成的码差在哪
第一版数据集我就犯了错,直接用生成库输出干净的 PNG,然后随机贴到背景图上,训出来的模型在真实图片上召回率只有 0.72。我把失败样本拉出来一看,问题非常明显:真实的拍摄过程会在图像上叠加一整套物理效应,而干净生成的图完全没有。
具体差在哪些地方?我列了一下:镜头造成的径向畸变(尤其手机广角端,画面边缘的码会被拉弯)、对焦不准造成的模糊、手持抖动的运动模糊、印刷时的墨点扩散导致模块边缘不清、纸张纹理和折痕、环境光的色温偏移、镜面反射造成的局部过曝(这个最致命,因为它直接把信息抹掉了)、JPEG 压缩产生的块效应、传感器噪声。这八种效应叠加起来,能把一张清清爽爽的二维码变成一个灰蒙蒙的、边缘糊掉的、局部白斑的东西。
所以我的合成管线不是"生成 + 贴图",而是生成 → 几何变换 → 光学退化 → 传感器退化 → 编码压缩这么一条链。每一段都有可调参数,随机采样,保证分布足够宽。
3.2 合成管线的核心代码
下面这段是我数据生成器的核心,我把它抽象成了可组合的退化算子,每个算子有独立的概率和强度范围,方便你做消融实验看哪种退化最难:
import cv2 import numpy as np import qrcode def make_qr(text, version=None, box=10, border=4, ec='M'): qr = qrcode.QRCode(version=version, box_size=box, border=border, error_correction={'L': qrcode.ERROR_CORRECT_L, 'M': qrcode.ERROR_CORRECT_M, 'Q': qrcode.ERROR_CORRECT_Q, 'H': qrcode.ERROR_CORRECT_H}[ec]) qr.add_data(text) qr.make(fit=(version is None)) img = np.array(qr.make_image(fill_color='black', back_color='white').convert('L')) return img def random_perspective(img, max_warp=0.25): """模拟任意角度的拍摄,max_warp 控制形变强度""" h, w = img.shape[:2] src = np.float32([[0,0],[w,0],[w,h],[0,h]]) dst = src + np.random.uniform(-max_warp, max_warp, (4,2)) * np.array([w,h]) M = cv2.getPerspectiveTransform(src, dst.astype(np.float32)) out = cv2.warpPerspective(img, M, (w, h), borderValue=255) return out, M def random_illumination(img): """叠加梯度光 + 高斯光斑,模拟不均匀照明与被摄物反光""" h, w = img.shape[:2] yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) ang = np.random.uniform(0, 2*np.pi) grad = (np.cos(ang)*xx + np.sin(ang)*yy) grad = (grad - grad.min()) / (grad.max() - grad.min()) gain = np.random.uniform(0.35, 1.0) light = 1.0 - gain + gain * grad # 高光斑 if np.random.rand() < 0.6: cx, cy = np.random.randint(0, w), np.random.randint(0, h) r = np.random.uniform(0.1, 0.45) * max(h, w) spot = np.exp(-(((xx-cx)**2 + (yy-cy)**2) / (2*r*r))) light = light + np.random.uniform(0.3, 0.9) * spot out = img.astype(np.float32) * light return np.clip(out, 0, 255) def random_blur(img): """高斯模糊与运动模糊二选一,模拟失焦与抖动""" if np.random.rand() < 0.5: k = np.random.choice([1, 3, 5, 7]) return cv2.GaussianBlur(img, (k, k), 0) L = np.random.randint(5, 21) angle = np.random.uniform(0, np.pi) ker = np.zeros((L, L), np.float32) ker[L//2, :] = 1.0 M = cv2.getRotationMatrix2D((L/2-0.5, L/2-0.5), np.degrees(angle), 1.0) ker = cv2.warpAffine(ker, M, (L, L)) ker /= ker.sum() return cv2.filter2D(img, -1, ker) def random_occlusion(img, max_ratio=0.35): """模拟手指、贴纸、污渍遮挡""" h, w = img.shape[:2] if np.random.rand() < 0.5: return img ratio = np.random.uniform(0.05, max_ratio) bw, bh = int(w*np.sqrt(ratio)), int(h*np.sqrt(ratio)) x = np.random.randint(0, max(1, w-bw)) y = np.random.randint(0, max(1, h-bh)) color = np.random.choice([255, 0, np.random.randint(60, 200)]) img = img.copy() if np.random.rand() < 0.5: cv2.rectangle(img, (x, y), (x+bw, y+bh), int(color), -1) else: cv2.circle(img, (x, y), max(bw, bh)//2, int(color), -1) return img这五个函数串起来就是一条完整的退化链。有几个参数经验值得说:max_warp我最终定在 0.25,因为超过这个值透视矫正的精度会明显下降,而真实场景里也很少有人把码拍成那么斜的角度;遮挡比例上限 0.35,再高就超过纠错能力了,模型学不到有用信息反而学坏;模糊核大小上限 21,这是我实测下来中等纠错等级二维码还能解出来的极限。
还有两个细节容易被忽略。一是背景融合:不要总把码贴在纯色背景上,要把码贴到随机纹理图上再整体做退化,这样模型才能学会区分码的纹理和背景纹理。二是JPEG 压缩:一定要在最后加一道cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, q]),q 在 30~95 之间随机,这一步能把合成图的统计特性拉得离真实拍摄很近,我加了这一步之后真实集召回率涨了 6 个百分点。
3.3 标注自动生成与负样本你为什么必须准备
因为四个角点在透视变换的时候就已经确定了,标注是免费的:你只要把变换矩阵作用到原始四个角上,就得到当前图像里的角点坐标。这里提醒一个坑,warpPerspective之后图像可能会有一部分移出画布,这时候角点坐标会出现负值或超出图像边界,一定要在生成时做一次裁剪检查,把越界过多的样本直接丢弃,否则训练时归一化坐标会出问题。
负样本的重要性我要单独讲。负样本就是"看着有点像二维码但其实不是"的图片。我最开始完全没准备负样本,结果模型在货架、书脊、棋盘格、马赛克瓷砖这些场景上疯狂误检。后来我专门造了一批负样本,来源包括:随机纹理图、带网格线的人造图案、只截取二维码的一部分(只有定位角没有数据区)、被严重破坏其他码。负样本和正样本按 1:1 混进去训,误检率从 18% 降到了 2.4%。这个改进成本极低,收益极大,强烈建议你在第一版数据集里就加上。
4. 检测模块:让模型先稳稳地"找到码"
检测这一环是整个系统里唯一真正的深度学习部分,也是论文里需要重点写的地方。我选的是单阶段检测框架,把输出头的类别数改成 1(只有"二维码"一类),同时把回归目标从常见的(cx, cy, w, h)换成四个角点的偏移量。
为什么要换成角点回归?这里展开说一下。标准的轴对齐框在二维码倾斜的时候会把大量背景包进去,最极端的情况下,一个 45 度斜放的码,外接矩形的面积是码本身的两倍多。这带来两个问题:一是框的 IoU 计算会失真,正样本和负样本的区分变模糊;二是后面做透视矫正时,你从矩形框推不出真实的四个角,还得在框内再跑一次定位图案检测,等于多了一道工序且把误差放大了。直接回归角点,一步到位,后面几何模块拿到就能用。
4.1 输入分辨率与切片推理的取舍
二维码目标尺度跨度极大,这是检测环节最头疼的问题。近处扫的码可能占据画面三分之一,墙上的小码可能只有 30×30 像素。在 640 输入下,一个 30 像素的目标经过下采样只剩不到 10 个像素,特征基本糊了。
我试过三个方案。方案一是直接把输入提到 1024,速度掉了将近一半,而且小目标召回提升有限(从 0.71 到 0.83),还是不够。方案二是做特征金字塔的多尺度融合,这个改动比较大,训练也不稳定。方案三是切片推理:把 1080p 的画面切成 2×2 或 3×3 的重叠小块,每块单独送进 640 的模型,最后把结果按 IoU 做非极大值抑制合并。这个方案的好处是不用改模型,纯推理期操作,而且对小目标的召回提升非常显著:同一批小码样本,召回从 0.71 直接到了 0.94。
代价是速度。3×3 切片意味着九次前向,即使每次只要 8 毫秒,加起来也快 80 毫秒了。我的折中做法是自适应切片:先用全图的低分辨率推理跑一遍,如果检测到的所有目标都足够大(框的短边超过画面高度的 8%),就不切片,直接用全图结果;如果检测到小目标,或者全图一个都没检到但画面复杂度高,再触发切片。这个策略在实际视频流上平均只增加了 15% 的耗时,但把最差情况下的漏检率压下去了。
4.2 关于锚框与损失函数的具体设置
虽然现在很多检测框架已经走向无锚框,但我用的还是带锚框的版本,因为二维码的宽高比基本固定在 1:1 附近(透视形变会带来一定变化),锚框设置可以非常激进地精简。我最终只保留了三个尺度、每个尺度两个锚框,宽高比全部设成 1:1 和 1:1.4,锚框尺寸分别是 24、64、160。这个设置是拿训练集真值框尺寸做 K-means 聚类得到的,聚类中心跑出来跟上面三个值很接近,所以我就直接手填了,省得写聚类脚本。
损失函数这边,分类用二值交叉熵配合焦点损失处理正负样本不平衡(负样本太多了,一张图里可能就一两个码,其余全是背景);角点回归用带 IoU 感知的回归损失,我实测下来比纯 L1 收敛快很多,因为 L1 对四个角点的联合约束太弱,模型容易回归出交叉的四边形。
这里有个细节必须强调:回归出交叉的四边形是个常见的失败模式。模型可能输出左上点在右下、右上点在左上的结果,形状上看是个"蝴蝶结"。解决办法有两个,一是在损失里加一项四边形不自交的惩罚,二是在后处理里做角点排序修正。我选的是后者,因为加惩罚项会影响收敛。后处理的逻辑很简单:把四个点按质心极角排序,然后固定第一个点为最靠左上的点,顺时针排列,这样即使模型输出顺序乱了也能救回来。
4.3 训练过程观察与收敛判断
训练这块我不打算贴完整的训练脚本,因为各家的检测框架接口差异太大,贴出来参考价值有限。我分享几个训练过程中值得盯的观察点。
第一,看损失曲线的形状而不是数值。分类损失和回归损失应该同步下降,如果回归损失早早平了但分类还在降,说明模型在学"哪里有东西"但学不准"东西多大",这时候要检查回归的权重是不是设小了。我一开始回归权重设成 1.0,回归损失在第 30 个 epoch 就瓶颈了,改成 2.5 之后明显改善。
第二,看验证集上的可视化,不要只看数字。我每隔 10 个 epoch 就随机抽 16 张验证图把框画出来存盘,肉眼扫一遍。这个习惯帮我发现了一个隐蔽的问题:模型在深色背景上的码几乎全漏。原因是我的负样本里深色纹理图太多,模型学到了"深色背景=负样本"这种捷径。解决办法是提高深色背景正样本的采样比例,两周的困惑就此解决。
第三,注意过拟合的信号。我最终的模型规模不大,参数量在两三百万这个量级,在 4 万张合成数据上训到 120 个 epoch 左右收敛。到第 90 个 epoch 之后,训练损失还在缓慢下降但验证集指标开始震荡,这就是过拟合的开始,我取了第 100 个 epoch 的权重。合成数据的一个好处是你可以随时加大数据量来缓解过拟合,所以我最后的做法是把第三次生成的 2 万张新样本加进去再训一轮,验证集召回涨了 1.8 个点。
4.4 检测输出到几何模块的衔接处理
检测框出来之后不能直接扔给几何模块,中间要做几件事。
置信度阈值筛选和 NMS 是标配,但二维码有个特殊性:同一个码可能被检出多个重叠框,因为定位图案本身很像小二维码。NMS 的 IoU 阈值我设得比较低(0.3),宁可多杀不可放过,因为重叠框会导致重复解码,浪费时间。如果场景里确实有多个真码(比如仓库货架),那 NMS 阈值要调高到 0.6 以上,否则会把相邻的码合并掉。这个参数需要按你的实际场景调,没有通用值。
角点的顺序修正我前面提过了,这里补一个更鲁棒的做法:不依赖模型的输出顺序,而是对四个点求凸包,如果凸包点数不是 4,说明模型输出的四边形不自交或者有共线点,直接判废。取到凸包后,用极角排序,再把极角最小的点(最左上的)作为起点顺时针排。这套流程跑下来,即使模型输出顺序完全随机,也能得到正确的顺序。
最后一步是边界外扩。检测框通常贴得比较紧,但矫正的时候需要包含一点边距,因为二维码四周有静区(quiet zone),虽然解码不一定需要,但边缘的模块如果正好被框线切掉半个,采样会出错。我的做法是把四边形沿质心方向向外扩 8%,实测这个比例在大多数情况下刚好够。
5. 几何矫正与采样:这一步决定系统的性能上限
如果说检测决定了"能不能找到",那几何矫正决定了"最多能做到多好"。我在前面提过,从纯传统方案的 61% 到加上检测的 84%,再到加上几何处理的 91%,几何环节贡献了 7 个百分点。而且这 7 个点几乎是无本买卖,代码量不到两百行,没有任何训练成本。
几何模块要做的事听起来很简单:把四个角点确定的四边形,映射成一个正方形。用透视变换几句话就写完了。但魔鬼在细节里,我把它拆成四步走:角点排序、版本估计、透视映射、网格采样。每一步都有坑。
5.1 从四个角点求透视变换矩阵
透视变换的数学基础是单应性,四个点对确定一个 3×3 的矩阵(差一个尺度因子,所以是 8 个自由度)。代码层面就是一句cv2.getPerspectiveTransform,但目标点的选取有讲究。
我最初的写法是把目标点设成(0,0), (S,0), (S,S), (0,S),其中 S 是输出边长。这个写法有个隐患:源四边形的四个角点在原图上是有像素宽度的,而二维码的定位图案中心其实在角点内侧。也就是说,如果你把定位图案的外角直接映射到输出图的四个角,采样网格会整体外移半个模块,导致每个模块的采样中心都偏。
正确的做法是让源点对应到二维码数据的外边界,然后目标点留出边框。具体来说,二维码标准规定四周要有 4 个模块宽的静区,我在输出图上按border = 4 * module_px留边,源点用检测到的角点,目标点用(border, border),(S-border, border),(S-border, S-border),(border, S-border)。这样映射之后,静区自然落在边缘,采样时的坐标可以直接用(col + 0.5) * module_px + border来算,非常清爽。
输出边长 S 怎么定?我的经验是取S = N * 10 + 80,其中 N 是模块数(版本 1 是 21,版本 40 是 177),10 是每个模块的像素尺寸,80 是两侧各 4 个模块的边距。10 像素每模块这个值是我权衡的结果:太小了采样容易受插值误差影响,太大了浪费计算也不增加精度。你也可以用固定 S = 512 然后动态算 module_px,效果差不多。
5.2 版本估计:一个容易被忽略的前置步骤
透视变换之前必须知道二维码有多少个模块,也就是版本号。版本 1 到 40,模块数从 21 到 177,每升一版加 4。搞错了版本,采样网格就对不上,后面全错。
版本信息从哪来?两个途径。一是检测网络可以顺手回归一个版本分类头,但这会增加训练复杂度,我没用。二是从图像里读版本信息位。二维码在右上角和左下角附近(版本 7 以上)有 18 位版本信息,用 BCH 编码保护。但这需要先精确采样到那些位置,是个鸡生蛋的问题。
我的做法是用定位图案的间距来估。矫正后的图上,左上定位图案的中心到右上定位图案的中心,距离是(N - 7)个模块(定位图案占 7×7,中心在中间)。所以我在候选的几个常见版本(1、2、3、4、5、6、7、10……)上各试一遍采样和解码,哪个成功用哪个。这个"暴力枚举"听起来很蠢,但实际非常有效,因为常见版本就那么几个,而且每个失败尝试的代价只有零点几毫秒。我最终的实现是先按几何比例粗估,再在粗估值 ±2 的范围内枚举,平均尝试 2.3 次就能命中。
还有一个更简单的辅助手段:数定位图案的模块数。把矫正图上的左上定位图案区域截出来,投影到一行一列,看黑白交替的次数,1:1:3:1:1 的比例能直接反推出模块宽度,进而算出总模块数。这个方法在有轻微模糊的情况下不太稳,但作为交叉验证很好用。
5.3 网格采样:为什么面积加权比双线性更稳
采样这一步是把矫正图上的每个模块中心位置取一个值,0 或 1。听起来就是查表取值,但取法不同,结果差别很大。
最近邻采样最快,直接取最近像素,但在模块边界模糊的时候会随机跳变。双线性插值取周围四点的加权平均,平滑一些,但在模块中心附近有噪点的时候容易被污染。我最后用的是面积加权采样:以模块中心为圆心、以模块边长为边长的矩形区域内,对所有像素做加权平均,权重是像素与模块中心的重叠面积。
为什么这个更好?因为二维码的每个模块本质上是"一个区域的平均颜色",而不是"中心点的颜色"。面积加权相当于在采样阶段就做了一次低通滤波,把抖动和噪点平均掉了。我用同一批困难样本对比过三种采样方式,面积加权的解码成功率达到 89.2%,双线性是 85.6%,最近邻是 78.1%。
采样完拿到的是一个 N×N 的浮点矩阵,还要转成二值。这里我用的是自适应阈值:先对整张图算 Otsu 阈值作为全局基准,然后对每个 8×8 的模块块算局部均值和标准差,最终阈值取max(otsu * 0.8, local_mean - 0.5 * local_std)。这个公式是我试出来的,思路是"全局阈值保底,局部阈值修正",在强光照梯度下有奇效。但要注意,如果二维码本身是深色底浅色模块(极性反转),这套逻辑会失效,需要先判断极性再取反。
5.4 采样偏移的量化评估方法
我说"采样偏了半个模块"这种话,得有办法测出来,否则就是拍脑袋。评估方法很简单:在合成数据里,因为你有真值变换矩阵,可以算出每个模块的理想中心位置;然后看你的实际采样点和理想位置偏差多少像素,除以模块像素尺寸,就是归一化偏移。
我实测的数据是:在中度透视形变下(max_warp = 0.15),平均偏移 0.12 个模块,最大 0.31 个模块;在强形变下(max_warp = 0.25),平均 0.19,最大 0.47。这个数字解释了为什么强形变下失败率会上升,因为偏移接近半个模块的时候,采样点已经跨到隔壁模块了,而纠错能力是有上限的。
所以如果你的系统在某类场景上成功率偏低,先别急着换模型,先算一下采样偏移。如果偏移大,说明几何环节有问题,改模型没用。
6. 解码环节:纠错码才是二维码的灵魂
走到解码这一步,图像已经变成了一个 N×N 的 0/1 矩阵。剩下的工作看起来是纯软件逻辑,但恰恰是这一环,把很多人的项目卡死在最后一步。
二维码的解码流程大致是:读格式信息(纠错等级 + 掩码编号)→ 去掩码 → 按块读取数据码字 → Reed-Solomon 纠错 → 按模式解析(数字/字母数字/字节/汉字)→ 输出字符串。每一步都有失败的可能,而且失败时的错误码非常隐晦,你必须知道每一步在干什么才能定位问题。
6.1 格式信息读取失败的典型原因
格式信息位于左上定位图案周围和右上、左下两个角,共两份冗余,各 15 位,用 BCH(15,5) 编码保护,能纠正 3 位错误。听起来很稳,但因为位置特殊,极易受采样误差影响。
我第一次跑通的时候,发现有一批样本明明图像很清晰却死活解不出来。查了半天,发现是格式信息的掩码位读错了。格式信息本身也经过了掩码(跟数据区的8种掩码不是一回事,格式信息有个固定的异或掩码),如果这一步处理错了,纠错等级和掩码编号都会读错,后面的解码就全部乱套。这块的逻辑我建议你不要自己实现,用开源库,但如果解不出来,一定要把读到的格式位、纠错后的格式位、解析出的等级和掩码编号都打印出来看。
另一个高频原因是信息位的位置算错了。格式信息不是连续的 15 个模块,而是分成两段绕着一圈排布的,而且两份冗余的读取顺序还不一样。如果你自己做这块,一定要拿标准的码图对照着数一遍,我当年就是少看了一个模块,导致后面所有的位都错位一位。
6.2 纠错能力到底能救多少错误
这部分内容我觉得是整篇里最值得理解清楚的,因为它直接告诉你系统的极限在哪。
二维码的纠错分四个等级:L 级约能纠正 7% 的码字错误,M 级约 15%,Q 级约 25%,H 级约 30%。注意这里说的是码字错误,不是模块错误。一个码字是 8 个模块,所以如果你有 10% 的模块被破坏,实际码字错误率可能远高于 10%,因为破坏是聚簇的(比如一块遮挡)。
具体算法是:每个块的数据码字数加纠错码字数总共 n,纠错码字数是 e,那么能纠正的码字错误数 t 约等于e / 2(向下取整),如果错误数超过 t 但不超过 e,只能检测不能纠正。以版本 1 的 M 级为例,数据 16 个码字,纠错 10 个码字,能纠 5 个码字错误,也就是 40 个模块。一个版本 1 的码总共才 441 个模块,扣掉功能图案剩 208 个数据模块,40 个模块大约占 19%。
这个计算的实际意义是:当你看到图像上被遮挡的面积超过 15% 的时候,基本就可以放弃了,因为即使采样完美,纠错也救不回来。这也是为什么我在数据合成里把遮挡上限设在 0.35 的同时,实际上模型在这个区间的样本表现很差——不是模型不行,是数学上就不行。认识到这一点,你就不会浪费时间去优化那些注定失败的样本。
我在解码模块里加了一个非常有用的日志:每次解码记录corrected_modules,也就是纠错机制实际修复的模块数。当这个数值接近理论上限的时候,说明这张图已经在悬崖边上。我统计过一批样本,corrected_modules > 0.7 * t * 8的样本,重拍一次的成功率是 100%,说明这些样本确实质量太差。
6.3 端到端神经网络解码的可行性再讨论
前面我说不建议做纯端到端,但这里我要补充一个更细的判断标准:如果你的题目是"复杂场景下的二维码识别",那端到端是个不错的对比实验;如果题目是"二维码识别系统",那它的工程价值不高。
我做过一个小规模的端到端实验:用编码器-解码器结构,输入矫正后的 256×256 灰度图,输出最长 200 的字符序列,用 CTC 损失训练。在 5 万张合成数据上训了 60 个 epoch,测试集上的完全正确率是 68%,字符错误率 6.3%。同期传统解码路径在相同输入上的完全正确率是 91%。这个差距说明在数据量和算力受限的条件下,端到端并不划算。
但端到端有一个传统方案比不了的场景:低质量图像。我统计过,在corrected_modules超过纠错上限的那批样本上(传统方案必然失败),端到端方案能救回大约 12%。原因是神经网络学的是内容的先验分布,能从模糊的纹理里"猜"出内容。这个特性很有意思,但它带来的风险是幻觉,会输出看起来合理但实际上错的内容,这对业务系统是不可接受的。所以我的结论是:端到端可以作为兜底尝试,但必须在后面加一道校验(比如内容的格式校验、CRC 校验),不能直接采信。
7. 工程实现:从一个脚本到一套能跑的系统
代码能跑和数据能看,是两件完全不同的事。我见过太多毕设项目最后是一个 800 行的巨大脚本,所有逻辑堆在main函数里,改一行参数要重启一遍。这种代码在答辩演示的时候只要出一点意外就没法救场。
我的做法是从一开始就按模块拆,哪怕一开始每个模块只有一个函数。目录结构大概是这样:
qr_system/ ├── configs/ # 配置,用 yaml 管参数 │ ├── detect.yaml │ └── pipeline.yaml ├── data/ │ ├── synth.py # 合成数据生成器 │ ├── dataset.py # 训练用 Dataset │ └── augment.py # 退化算子集合 ├── models/ │ ├── detector.py # 检测网络定义 │ └── weights/ # 权重文件 ├── core/ │ ├── detect.py # 检测封装:预处理、推理、后处理 │ ├── geometry.py # 角点排序、透视变换、采样 │ ├── binarize.py # 二值化策略 │ └── decode.py # 解码封装 + 重试策略 ├── eval/ │ ├── metrics.py # 各项指标的实现 │ └── report.py # 生成评测报告 ├── app/ │ ├── image_demo.py # 单图/批量图演示 │ └── video_demo.py # 视频流演示 └── tests/ # 每个模块的单元测试这个结构的核心原则是:每一层只依赖下一层,不反向依赖。core里的模块互相独立,app只调core,core不知道app存在。这样你随时可以把某个模块换掉做对比实验,不用改其他代码。
7.1 检测模块的封装要点
core/detect.py这个文件我改过至少五版,最后稳定的版本大概长这样:
class QRDetector: def __init__(self, weights, conf=0.35, iou=0.45, input_size=640, tile=False, tile_grid=3, tile_overlap=0.2): self.net = load_model(weights) self.conf, self.iou, self.input_size = conf, iou, input_size self.tile, self.tile_grid, self.tile_overlap = tile, tile_grid, tile_overlap def __call__(self, img): dets = self._infer(img, offset=(0, 0)) # 全图没检到或者检到的目标偏小,触发切片 if self.tile and self._need_tile(dets, img.shape): h, w = img.shape[:2] for i in range(self.tile_grid): for j in range(self.tile_grid): x0 = int(j * w / self.tile_grid) y0 = int(i * h / self.tile_grid) x1 = min(w, int((j + 1) * w / self.tile_grid + w * self.tile_overlap)) y1 = min(h, int((i + 1) * h / self.tile_grid + h * self.tile_overlap)) dets += self._infer(img[y0:y1, x0:x1], offset=(x0, y0)) return nms(dets, self.iou) def _need_tile(self, dets, shape): if len(dets) == 0: return True min_side = min(np.linalg.norm(d[0][1] - d[0][0]) for d in dets) return min_side < 0.08 * min(shape[:2])这段代码里有两个设计决策值得解释。一是offset参数,切片推理出来的坐标必须加上切片的偏移才能映射回原图,这个参数很容易忘,忘了之后所有切片结果都堆在左上角。二是_need_tile的触发条件,我用的是"最短边占比",而不是框的数量,因为大码和小码在面积上差好几个数量级,用面积比例判断会误判。
7.2 推理加速与部署实践
毕设演示通常在笔记本上跑,没有独显,所以推理速度是个真问题。我实测下来,从 PyTorch 原始模型一路优化到能在 CPU 上跑 25 FPS,走了三步。
第一步是把模型导出成 ONNX 再跑。这一步几乎不损失精度,但推理速度快了大概 1.6 倍,因为 PyTorch 的 Python 调度开销在小模型上占比很高。导出的时候注意固定输入尺寸,如果保留动态维度,ONNX Runtime 在某些版本上会有性能问题。还有一个小坑:导出时的算子集版本要和推理端匹配,我在一个环境里遇到过不匹配导致输出全零的情况,排查了整整一个下午。
第二步是做量化。我先试了静态量化(需要校准集),精度掉了 1.2 个点,召回率从 0.962 掉到 0.950。后来改用动态量化,精度基本没变,速度提升有限。最终的结论是:对于这种小模型,量化的收益不如预期,如果你的目标设备支持浮点运算,量化带来的精度损失不值得。这个结论和网上的很多教程说法不一样,但这是我真实测出来的结果,可能跟我的模型结构和目标平台有关。
第三步是减少数据拷贝。这条听起来很废话,但影响很大。我最初的流程是img → resize → to_tensor → normalize,每一步都产生一份新的数组,一张 1080p 图拷四次。改成在一个预分配的缓冲区里原地操作之后,单帧处理时间从 11 毫秒降到 7 毫秒,这个优化没有任何精度代价。
7.3 评测脚本怎么写才不是自欺欺人
评测脚本的质量直接决定你论文数据的可信度。我见过太多脚本只在"好"样本上跑,然后报一个 98% 的成功率。这种数字经不起推敲。
我的评测脚本有三个原则。第一,测试集必须与训练集来源分离,合成数据的随机种子不能重叠,最好用不同批次的生成器参数。我留了 2000 张额外拍的手机照片作为"真实集",单独统计,这个数字才是最有说服力的。第二,所有失败样本必须落盘,包括原图、中间结果、错误码,按失败模式分目录存。我最后攒了 300 多张失败样本,每一类我都分析过原因,这些分析在答辩时变成了最有说服力的内容。第三,指标必须可复现,评测脚本里不要有随机性,随机种子固定,跑两遍结果完全一致。
下面这段是核心指标的计算逻辑,重点是区分"没检出"、"检出但没解出"、"解出但解错"这三种情况,它们的业务含义完全不同:
def evaluate(pipeline, dataset): stats = { 'total': 0, 'det_miss': 0, 'dec_fail': 0, 'wrong_text': 0, 'ok': 0, 'corrected_mod_sum': 0, 'time_sum': 0.0, } for item in dataset: stats['total'] += 1 dets = pipeline.detect(item.image) if not any(match_gt(d, item.corners) for d in dets): stats['det_miss'] += 1 continue results = pipeline.decode_all(dets) texts = [r.text for r in results if r.success] if not texts: stats['dec_fail'] += 1 elif item.text in texts: stats['ok'] += 1 stats['corrected_mod_sum'] += sum(r.corrected_modules for r in results if r.success) else: stats['wrong_text'] += 1 stats['success_rate'] = stats['ok'] / stats['total'] stats['misread_rate'] = stats['wrong_text'] / stats['total'] return stats注意misread_rate这个指标,我在前面强调过它。很多人的系统成功率 95%,但误读率 3%,这个数字在实际业务里是不能接受的,因为每扫 30 次就有一个是错的。答辩时老师如果懂行,一定会问你误读率。
8. 常见问题排查实录与避坑清单
这一段是我最想写的部分,因为前面所有的内容你在网上都能找到类似的,但下面这些坑基本只能自己踩。我把它们按"症状—原因—解决"整理成了一张速查表,你可以对着用。
| 症状 | 最可能的原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 大面积漏检,尤其是小码 | 输入分辨率不足 / 训练集尺度分布窄 | 可视化检测框,看漏检样本的尺度分布 | 开切片推理;重生成数据拉宽尺度范围 |
| 深色背景全漏 | 负样本偏差,模型走捷径 | 分别统计深浅背景的召回率 | 提高深色背景正样本比例 |
| 检出但从未解出 | 角点顺序错 / 版本估计错 | dump 矫正图,肉眼看是否摆正 | 检查角点排序逻辑;枚举版本 |
| 部分样本解出错误内容 | 采样偏移,恰好跨过纠错能力边界 | 看corrected_modules是否接近上限 | 提高采样精度;拒绝高纠错量样本 |
| 反光区域永远失败 | 信息被物理抹除 | 看原图反光斑面积占比 | 无法解决,只能提示用户换角度 |
| 圆柱面/曲面上失败率高 | 平面透视模型不适配曲面 | 看矫正图边缘是否弯曲 | 分段矫正或局部重采样 |
| 视频流卡顿 | 每帧全量推理 | profile 各环节耗时 | 跳帧检测 + 跟踪复用 |
| 多个相邻码合并成一个 | NMS 阈值过低 | 可视化 NMS 前后框数 | 提高 NMS IoU 阈值 |
8.1 曲面场景为什么这么难
这一条我要单独说,因为它是我花时间最多、最后也没彻底解决的问题。二维码的透视变换模型假设粘贴面是一个平面,而现实中大量二维码印在瓶子、罐子、纸盒的折角上。
曲面带来的问题是透视矩阵无法描述的非线性形变。在矫正图上,靠近边缘的部分会被拉伸,模块间距不再均匀,采样网格对不齐。我用版本 3 的码做过实验,把它贴在一个曲率半径约 40 毫米的圆柱面上,从正对方向拍,边缘区域的模块宽度变化能达到 30% 以上,超过这个值基本就解不出来了。
我试过两种改进。一是局部重采样:把整个码分成 3×3 的子区域,每个子区域单独算一次局部透视矩阵,然后拼起来。这个方法在曲率不大的时候有效,成功率从 42% 提到 61%,但实现复杂度上升不少,而且子区域边界处容易出现拼接误差。二是曲面参数化建模:假设粘贴面是圆柱,用三个参数描述,然后用优化的方式拟合。这个方法理论上更好,但需要额外的约束条件,我在有限时间内没做出来,只做了一版原型,效果不稳定。
如果重来一次,我会把这个作为扩展方向写进论文的"局限性"章节,而不是硬啃。答辩老师对"你知不知道自己方案的边界在哪"的认可度,往往高于"你把边界硬啃下来一点点"。
8.2 一些不成文但很实用的经验
第一,测试集里一定要有"看起来很简单但实际很坑"的样本。比如那种打印在光面纸上的码,看着黑白分明非常清晰,但因为纸面反光,稍微偏一点角度就有一大片过曝。这类样本最容易让你的成功率虚高,因为大部分算法在它上面会假性成功(能检出、能解出),但在实际使用中一换角度就崩。
第二,阈值不要只调一个。检测的置信度阈值、NMS 的 IoU 阈值、采样时的二值化阈值、解码的重试次数,这四个参数是耦合的。我最终的做法是固定三个,网格搜索一个,找到最优后再换下一个,两轮下来基本收敛。全部一起搜的组合数太多,浪费算力。
第三,日志要打到文件而不是控制台。视频流跑起来的时候,控制台刷屏根本看不清,出了问题没法回溯。我的做法是每次运行生成一个带时间戳的日志目录,里面存关键帧、中间结果、错误统计摘要三份东西,出问题直接翻目录。
第四,留一个"傻瓜模式"。演示的时候最怕出意外,所以我做了一个降级开关:一旦某个环节异常(模型加载失败、解码库异常),自动退回到一个简化流程(只做检测 + 标准库直解),保证演示能继续。这个小功能救过我一次,当时现场网络断开导致某个依赖加载失败,靠着降级模式把流程走完了。
第五,版本控制里不要把数据和权重放进去。4 万张图片加几个权重文件,仓库能撑到几个 G,clone 一次等到天荒地老。数据和权重单独放,代码里用配置文件指定路径,这是基本素养,但每年都有人的毕设仓库因为这个原因打不开。
8.3 关于论文里"创新点"的写法
最后聊一点务实的。这个题目很容易被质疑"不就是调了个检测模型吗,创新点在哪"。我的建议是不要硬编创新点,而是把工程上的取舍和量化验证当成贡献来写。
比如"针对小目标召回率低的问题,提出自适应切片推理策略,在不显著增加平均耗时的前提下将小目标召回率从 0.71 提升至 0.94,并给出了触发条件的量化依据",这句话里没有新算法,但它是一个完整的问题—方法—验证闭环,比空谈"改进了网络结构"有说服力得多。类似的还有"针对采样偏移导致的解码失败,提出面积加权采样与自适应阈值联合策略,解码成功率提升 3.6 个百分点",这些都是实实在在能写进论文、能被追问、也能答得出来的东西。
我个人在这个项目上最大的体会是:这个题目看起来简单,是因为它的每一层都有现成的工具;它做起来难,是因为把这几层拼起来、让它们互相不拖后腿,需要的工程判断远多于算法知识。我在调通整条流水线之后,回头看我最初写的那个"生成码 → 解码"的 30 行脚本,才发现中间隔着的是整整两个月和三百多次失败样本的分析。所以如果你现在刚开始做,别急着训模型,先把数据管线和评测脚本搭起来,后面每一步都会感谢当时的自己。