上周有同行拿着一台工控机上的老项目找我,说现场要用 VC++ 写个小工具,把巡检单上贴的二维码读出来,界面得是原生 Windows 对话框,不能装运行时依赖,也不方便引入第三方库。他试过调用外部命令行工具,进程一多就卡;试过引第三方解码库,位数不匹配、编译链对不上。最后我们花了几个晚上,把 MFC 当外壳、纯 C++ 写解码内核,从像素一路手撸到比特流,跑下来单张 1080P 图 30 毫秒出头,工控机上稳定得很。这篇就把这套二维码解析的路子完整摊开:MFC 集成怎么做、纯 C++ 解码器的每个环节卡在哪、参数怎么定、坑在哪。不管你是在做设备点检、票据录入、纸质档案数字化,还是单纯想在桌面端加个扫码入口,只要你会写 C++、能看懂位图结构,这篇都能直接拿去改。代码片段是我实际跑通的版本,接口和参数都标了为什么这么取,不是纸上谈兵。
1. 方案选型:MFC 做壳、纯 C++ 做核
1.1 三条路线摆在一起比一比
在 Windows 桌面端做二维码解析,能走的路其实就三条,但每条路的代价差别很大,选错了后面全是返工。我把当时对比的三条路线贴在下面,你可以直接对号入座。
| 路线 | 落地方式 | 优点 | 明确代价 | 适用场景 |
|---|---|---|---|---|
| 调用外部程序 | 起进程传图片路径,读标准输出 | 半天就能跑通 | 进程启动 100ms 起步,批量处理性能崩,部署要带一堆文件 | 一次性验证、临时脚本 |
| 引入现成解码库 | 静态/动态链接第三方库 | 识别率高,功能全 | 编译链、位数、字符集容易打架,代码体积不可控,出问题难定位 | 对交付形态要求不严的项目 |
| 自研解码内核 | 纯 C++ 实现,MFC 只负责界面 | 零外部依赖,单 exe 可拷走,参数全可控 | 前期投入大,需要吃透规范 | 工控、内网、离线交付 |
我选第三条不是因为它“酷”,而是因为交付形态卡死了:工控机不允许装额外运行库,客户现场的机器还有 32 位系统,exe 拷过去双击就得能用。这种情况下,把解码逻辑写成不依赖任何框架的纯 C++,再用 MFC 包一层界面,是唯一能同时满足“零依赖”和“原生界面”的组合。
顺带说一句,选 MFC 而不是更现代的界面框架,理由很土:目标机器上大概率已经装了 VC++ 运行库,MFC 的对话框程序编译出来体积小、启动快,拖拽文件、剪贴板、GDI 绘制这些能力开箱即用,不用为了读一张图去拉一整套界面框架进来。
1.2 分层:让解码内核完全不认识 MFC
这一条是我踩过坑之后才坚持的。第一版我把解码代码直接写在对话框类里,用CImage的指针到处传,结果想把同一套逻辑复用到控制台批处理工具上时,发现每一行都跟 MFC 绑着,改了两天才拆干净。
正确的做法是先定一个自己的位图结构,解码内核只认这个结构,MFC 层负责把各种来源的图转换成它:
// 内核侧唯一定义的图像结构,不引用任何 Windows 头文件 struct QRBitmap { int width = 0; int height = 0; int stride = 0; // 每行字节数 std::vector<uint8_t> pixels; // 统一存灰度,1 字节/像素 inline uint8_t at(int x, int y) const { return pixels[static_cast<size_t>(y) * stride + x]; } inline bool inside(int x, int y) const { return x >= 0 && y >= 0 && x < width && y < height; } };灰度化放在转换层做,也就是“谁产生图像谁负责转灰度”,内核拿到的永远是单通道数据。这么切有几个直接好处:解码逻辑里不用到处写if (bpp == 32)这种分支;积分图、二值化这些算法按单字节走,缓存命中率高;将来接摄像头帧、接扫描仪数据,只要填进QRBitmap就能复用。
注意:转换层一定要处理 stride 对齐。
CImage的每行字节数是 4 字节对齐的,1080P 的 24 位图,一行 1920×3=5760 字节刚好对齐,但宽度是 1001 像素时就差 3 个字节,直接按width*3跳行会读出斜纹。这个坑我在扫描件上连踩两次。
1.3 一条图走完的完整数据流
整个链路我画不出花来,就是一条直线,但每一站都可能成为失败点,所以我习惯把站点明确写进注释里,出问题时按站排查:
原始图 → 灰度化 → 自适应二值化 → 寻像图形定位 → 第四角估计与透视采样 → 网格取点得比特矩阵 → 格式信息解码(纠错等级 + 掩码号)→ 去掩码 → 按蛇形顺序读数据位 → 分块拆码字 → Reed-Solomon 纠错 → 数据段解析(模式 + 计数 + 载荷)→ 文本输出。
这条链路上,前面任何一站出错,后面全废,而且现象往往一样——读不出东西。所以排查不能从后往前猜,必须从前往后打日志。我在内核里加了一个编译期开关QR_DEBUG_DUMP,打开后每一站都把中间结果落成 PGM 图片或者文本矩阵,实测这比单步调试快十倍。
2. 解码链路的六个关键环节
2.1 灰度与二值化:参数别凭感觉
灰度化没什么好纠结的,用亮度加权公式Y = (299*R + 587*G + 114*B) / 1000,整数运算即可,比浮点版在批量场景下快一截。真正决定成败的是二值化。
二维码是黑白模块,理论上全局阈值就够了,但实际图像很少这么理想:手机拍的图有阴影和反光,扫描件有底色偏黄,截图有抗锯齿灰边。全局 Otsu 在光照均匀的截图上很稳,在光照不均的照片上必挂。我最后定的是局部自适应阈值——积分图版,复杂度 O(1) 每像素,参数只有两个:窗口边长和偏移量。
// 积分图 + 局部均值二值化 void binarizeAdaptive(const QRBitmap& src, std::vector<uint8_t>& out, int block, int offset) { const int W = src.width, H = src.height; std::vector<uint32_t> integral((size_t)(W + 1) * (H + 1), 0); for (int y = 0; y < H; ++y) { uint32_t rowSum = 0; for (int x = 0; x < W; ++x) { rowSum += src.at(x, y); integral[(size_t)(y + 1) * (W + 1) + (x + 1)] = integral[(size_t)y * (W + 1) + (x + 1)] + rowSum; } } out.assign((size_t)W * H, 255); const int r = block / 2; for (int y = 0; y < H; ++y) { int y0 = std::max(0, y - r), y1 = std::min(H - 1, y + r); for (int x = 0; x < W; ++x) { int x0 = std::max(0, x - r), x1 = std::min(W - 1, x + r); int area = (x1 - x0 + 1) * (y1 - y0 + 1); uint32_t sum = integral[(size_t)(y1 + 1) * (W + 1) + (x1 + 1)] - integral[(size_t)y0 * (W + 1) + (x1 + 1)] - integral[(size_t)(y1 + 1) * (W + 1) + x0] + integral[(size_t)y0 * (W + 1) + x0]; // 均值减偏移作阈值,像素更暗记作黑(0) int mean = (int)(sum / area) - offset; out[(size_t)y * W + x] = (src.at(x, y) < mean) ? 0 : 255; } } }参数怎么定?block取图像短边的 1/16 到 1/20,我常用短边/16,1080P 图就是 67,取奇数 65 或 67 都行。offset在 8 到 12 之间,扫描件偏 8,手机拍摄偏 12。这两个数不是玄学——窗口要覆盖住 2 到 3 个模块,太小会被单个黑模块带偏,太大就退化成全局阈值,压不住阴影。如果你的图模块特别大(比如 2000 像素宽的码),按短边算出来的窗口可能覆盖十几个模块,这时宁可手动把block压到模块宽度的 6 到 8 倍。
实操心得:二值化后先做一次“黑白比例”体检。整图黑色占比低于 5% 或高于 60%,基本说明阈值跑偏了,直接放弃这轮,把
offset调一下重试,比后面一连串空洞的几何检测省时间。
2.2 寻像图形定位:1:1:3:1:1 的扫描逻辑
二维码三个角上有定位图案,结构是 7×7 模块:黑 1、白 1、黑 3、白 1、黑 1。任何一条穿过它中心的扫描线,黑白黑白黑的比例都是 1:1:3:1:1,总宽 7 个模块。这个比例是整个解码里最可靠的抓手。
我的做法是逐行扫描,维护一个五段的游程计数器,遇到颜色切换就移位。判定条件是:五段宽度之和不少于模块下限,中间那段大约是两侧各段的三倍,两侧误差允许 ±50%,靠外的两段允许 ±60%(因为外沿可能被裁切)。
// line 为一行二值化后的像素,返回候选中心与模块尺寸估计 bool matchFinderRatio(const uint8_t* line, int len, int endPos, int* runLen, double* moduleSize) { int unit = (runLen[0] + runLen[1] + runLen[2] + runLen[3] + runLen[4]) / 7; if (unit < 2) return false; // 模块不足 2 像素,采样必错 int maxVar = unit; // 单段最大偏差 if (std::abs(runLen[0] - unit) > maxVar * 1.2) return false; if (std::abs(runLen[1] - unit) > maxVar) return false; if (std::abs(runLen[2] - 3 * unit) > 3 * maxVar) return false; if (std::abs(runLen[3] - unit) > maxVar) return false; if (std::abs(runLen[4] - unit) > maxVar * 1.2) return false; *moduleSize = unit; return true; }单行命中不算数,因为二维码内部数据区也可能凑出类似比例(概率不高但确实有,实测在 177×177 的高版本码上能遇到)。所以要做交叉验证:横向命中后,以该点为中心向上向下各走一遍竖直方向,也满足同样的比例,才算真的定位图案。三处都找到之后,用三个中心点算距离,两两距离应该接近,三个点构成的三角形要有明显的直角特征。
这里最容易出问题的是模块尺寸下限。我实测过,模块宽度低于 2.5 像素时,采样错误率急剧上升,纠错也救不回来;低于 2 像素基本没戏。所以如果原图很大、码很小,别硬解,先把感兴趣区域裁出来放大到模块宽度 4 到 6 像素再解,成功率能翻几倍。
2.3 第四角估计与透视采样
三个定位图案只确定了三个点,第四个角得靠估计。低版本(版本 1)没有对齐图案,只能按平行四边形假设:第四点 = 右上 + 左下 − 左上。版本 2 及以上有对齐图案,在码内右下区域,是个 5×5 的回字结构,找到它能把透视校正准得多。
拿到四个点之后,做透视变换。不需要重采样整张图,只需要算出一个映射关系,把 (模块列号, 模块行号) 映射到图像坐标。模块总数由版本决定:模块数 = 17 + 4 × 版本,版本 1 是 21,版本 40 是 177。这个公式反过来用就是从模块数推版本:版本 = (模块数 − 17) / 4,前提是模块数是 21 到 177 之间且模 4 余 1。
采样时我不用“取模块中心一个像素”这种最省事的做法,因为中心恰好落在噪声点上的概率不低。我做的是在模块中心取 3×3 或 5×5 邻域,按黑白投票决定这一位。代价是每模块多读 8 个点,但换来的是抗噪能力明显提升,实测在低质量扫描件上,误码率能降一个量级。
// 由四角计算的透视映射,采样得到模块矩阵 void sampleGrid(const std::vector<uint8_t>& bin, int W, int H, const std::array<Point2f, 4>& corner, int moduleCount, std::vector<uint8_t>& out /* moduleCount^2, 1=黑 */) { out.assign((size_t)moduleCount * moduleCount, 0); for (int my = 0; my < moduleCount; ++my) { for (int mx = 0; mx < moduleCount; ++mx) { // 双线性插值求模块中心在图上的位置 Point2f p = mapModuleToImage(corner, moduleCount, mx + 0.5f, my + 0.5f); int votes = 0, total = 0; for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { int x = (int)(p.x + dx), y = (int)(p.y + dy); if (x < 0 || y < 0 || x >= W || y >= H) continue; total++; if (bin[(size_t)y * W + x] == 0) votes++; } } if (total == 0) continue; out[(size_t)my * moduleCount + mx] = (votes * 2 > total) ? 1 : 0; } } }一个绕不开的细节:图像里四个角对应的是模块边界还是模块中心?矩阵坐标里第 0 个模块占据 [0,1) 区间,中心是 0.5。所以定位图案中心在模块坐标里是 (3.5, 3.5) 而不是 (3, 3),差这半个模块,整张矩阵会整体偏移,格式信息一读就是错的。这个 0.5 我第一次写时忘了,排查了一晚上。
2.4 格式信息与版本信息:别自己推 BCH
15 位格式信息里装着纠错等级(2 位)和掩码号(3 位),外面套了 BCH(15,5) 校验,最后整体异或一个固定常量。规范里给了解码的数学流程,但工程上更省事、更不容易错的做法是打表比对:15 位数据只有 32 种合法值,把它们全部预先算出来存成 32 项数组,读到的 15 位跟每一项算汉明距离,距离不超过 3 就认定命中。
struct FormatInfo { int ecLevel; int maskId; int hamming; }; // 32 个合法格式码字在启动时算一次即可 FormatInfo decodeFormat(const uint8_t bits[15]) { static std::vector<uint16_t> table = buildFormatTable(); // 32 项 FormatInfo best{ -1, -1, 16 }; uint16_t v = 0; for (int i = 0; i < 15; ++i) v = (v << 1) | bits[i]; for (int ec = 0; ec < 4; ++ec) { for (int mask = 0; mask < 8; ++mask) { uint16_t cand = table[ec * 8 + mask]; int d = popcount16(v ^ cand); if (d < best.hamming) { best = { ec, mask, d }; } } } return best; // hamming > 3 说明两个副本都读坏了,直接放弃 }纠错等级那 2 位的编码是:L=01、M=00、Q=11、H=10,别按直觉硬记,我第一次就是按 0/1/2/3 顺序映射的,结果 H 和 M 反了,图片明明能解出来两个字符却是乱码。
格式信息在码内有两个副本,左上角一个,右上和左下合起来一个。两个都读,各自解码,取汉明距离小的那个;两个结果不一致且距离都小于等于 3 时,说明有副本被污染,宁可两份都试,看哪个能过 RS 校验。
版本信息只在版本 7 及以上存在,18 位,BCH(18,6),同样打表:只有 34 种合法值(版本 7 到 40),做法完全一样。版本 6 及以下没有版本信息,只能靠模块总数反推。
2.5 去掩码与蛇形读位
格式信息拿到掩码号之后,先对非功能区做异或:凡是数据位,都用对应的掩码公式取反。八种掩码公式以 (行 i, 列 j) 为参数,形式差异很大,从按行奇偶到乘积累加都有,具体八个表达式在规范里列着,实现时直接写成 8 个switch分支的bool maskBit(int id, int i, int j)就行,别想着合并成一个通式。
然后按规范顺序读数据位:从右下角开始,两列一组,向上走完一组再向下走,蛇形推进。每一组里先右列后左列,跳过第 6 列(那是竖向时序图形),跳过所有功能图案占据的位置(三个定位图案与其分隔带、两处格式信息、对齐图案、版本信息块、时序图形)。
这里有个隐蔽的坑:对齐图案的位置不是均匀分布的。规范里给了一张位置表,每个版本对应一组坐标,取的是所有行列组合——除了会与定位图案重叠的三个角。版本 2 的坐标是 {6, 18},去掉 (6,6)、(6,18) 与 (18,6) 之后只剩 (18,18) 一个。版本 7 是 {6, 22, 38},组合更多。这张表必须照抄规范,靠“均匀插值”去猜一定错。
读出来的位序列按 8 位一组拼成码字,得到的就是整个码的码字序列(数据码字 + 纠错码字)。
2.6 Reed-Solomon:把坏块救回来
二维码的纠错是 GF(256) 上的 RS 码,本原多项式x^8 + x^4 + x^3 + x^2 + 1(0x11D),生成元 α = 2。纠错能力取决于等级:L 约 7%,M 约 15%,Q 约 25%,H 约 30%。听起来很美,但它的纠错能力是按“码字”算的,不是按“像素”算的。一个码字对应 8 个模块,只要这 8 位里有 1 位错,这个码字就废了。所以图像稍微脏一点,码字错误数就暴涨,纠错瞬间被吃光。这也是为什么前面采样环节要做 3×3 投票——省下的每一分误码率,最后都变成了纠错余量。
实现上分四步:算伴随式、Berlekamp-Massey 求错误位置多项式、Chien 搜索找错误位置、Forney 公式求错误值。前两步是核心,伴随式全为 0 就说明这一块没坏,直接跳过后面三步,这是最常见的快路径,实际处理干净图片时 90% 以上的块走这条捷径。
// GF(256) 指数/对数表,启动时构建一次 static uint8_t g_exp[512], g_log[256]; void gfInit() { int x = 1; for (int i = 0; i < 255; ++i) { g_exp[i] = (uint8_t)x; g_log[x] = (uint8_t)i; x <<= 1; if (x & 0x100) x ^= 0x11D; // 本原多项式 } for (int i = 255; i < 512; ++i) g_exp[i] = g_exp[i - 255]; } inline uint8_t gfMul(uint8_t a, uint8_t b) { if (!a || !b) return 0; return g_exp[g_log[a] + g_log[b]]; } // 计算伴随式:S_i = R(α^i),i = 0..ecLen-1 bool rsSyndromes(const uint8_t* block, int n, int ecLen, uint8_t* S) { bool allZero = true; for (int i = 0; i < ecLen; ++i) { uint8_t s = 0; for (int j = 0; j < n; ++j) s = gfMul(s, g_exp[i]) ^ block[j]; S[i] = s; if (s) allZero = false; } return allZero; // true 表示无需纠错 }有个容易忽略的点:码字是按块交织的。版本高、纠错等级高的时候,一个码会被拆成多个块,数据码字交错排列——先取第 1 块第 1 字节,再第 2 块第 1 字节……纠错码字同理。所以读出来的码字序列必须先按块结构还原成一块一块,才能分别做 RS。块结构表(每块数据字节数、块数、纠错字节数)必须照规范录入,这是纯体力活,但录错一个数就会出现“某些图能解、某些图解不出”的诡异现象。
以版本 1 和版本 2 为例,块结构是这样的:
| 版本 | 纠错等级 | 总码字 | 数据码字 | 纠错码字 | 块数 |
|---|---|---|---|---|---|
| 1 | L | 26 | 19 | 7 | 1 |
| 1 | M | 26 | 16 | 10 | 1 |
| 1 | Q | 26 | 13 | 13 | 1 |
| 1 | H | 26 | 9 | 17 | 1 |
| 2 | L | 44 | 34 | 10 | 1 |
| 2 | M | 44 | 28 | 16 | 1 |
| 2 | Q | 44 | 22 | 22 | 1 |
| 2 | H | 44 | 16 | 28 | 1 |
版本再往上,块数会变成 2 块、4 块甚至更多,而且同一版本下不同块的数据字节数可能差 1,分组时要特别小心。我的建议是:这张表直接从规范附录抄成const数组,写完拿 40 个版本 × 4 个等级全跑一遍自检,只要总码字数对得上,基本就没抄错。
3. MFC 集成:把解码器装进对话框程序
3.1 CImage 载入与像素格式归一
CImage是 MFC 里读图片最省事的东西,JPEG、PNG、BMP、GIF 都能吃,底层走 GDI+,不需要额外初始化。但它有几个必须处理干净的细节。
第一是位深。CImage载入的图可能是 1 位、4 位、8 位调色板图,也可能是 24 位、32 位带 alpha。统一转成 24 位再取像素最省心:
bool LoadToGray(const CString& path, QRBitmap& out) { CImage img; HRESULT hr = img.Load(path); if (FAILED(hr)) return false; if (img.GetBPP() != 24) { CImage tmp; if (!tmp.Create(img.GetWidth(), img.GetHeight(), 24)) return false; HDC dc = tmp.GetDC(); img.Draw(dc, 0, 0, img.GetWidth(), img.GetHeight(), 0, 0, img.GetWidth(), img.GetHeight()); tmp.ReleaseDC(); img.Destroy(); img.Attach(tmp.Detach()); } const int W = img.GetWidth(), H = img.GetHeight(); const int pitch = img.GetPitch(); // 可能为负,需判断 BYTE* base = static_cast<BYTE*>(img.GetBits()); if (pitch < 0) base += (size_t)(H - 1) * (-pitch); // 自下而上存储 out.width = W; out.height = H; out.stride = W; out.pixels.assign((size_t)W * H, 0); for (int y = 0; y < H; ++y) { // pitch 为负时行方向相反,用 abs 保证正序 BYTE* row = base + (size_t)y * std::abs(pitch); for (int x = 0; x < W; ++x) { const BYTE b = row[x * 3 + 0]; const BYTE g = row[x * 3 + 1]; const BYTE r = row[x * 3 + 2]; out.pixels[(size_t)y * W + x] = (uint8_t)((299 * r + 587 * g + 114 * b) / 1000); } } return true; }第二是GetPitch()可能返回负数——BMP 是自下而上存的,这种情况很常见,忘了判断就会读出一张上下颠倒或者全是垃圾的图。第三,32 位带 alpha 的 PNG 转 24 位时,透明区域会变成黑色,如果二维码本身是白底黑码,透明底会被当黑,二值化直接崩。解决办法是先铺一层白底再Draw,这个细节我在处理用户拖进来的设计稿时踩过。
3.2 三条输入通道:文件、拖拽、剪贴板
实际用起来,用户的操作路径不止一条,至少要做三条:打开文件对话框选图、从资源管理器直接把图拖进窗口、截图后 Ctrl+V 粘贴。这三条都是 MFC 里几十行的事,但漏了任何一条,用户就会觉得“不好用”。
拖拽靠DragAcceptFiles(TRUE)加上WM_DROPFILES处理:
void CScanDlg::OnDropFiles(HDROP hDrop) { UINT n = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); for (UINT i = 0; i < n; ++i) { TCHAR path[MAX_PATH] = { 0 }; DragQueryFile(hDrop, i, path, MAX_PATH); PostMessage(WM_APP_SCAN_FILE, 0, (LPARAM)_tcsdup(path)); // 交给工作线程 } DragFinish(hDrop); CDialogEx::OnDropFiles(hDrop); }剪贴板读取要处理 CF_DIB 和 CF_BITMAP 两种格式,前者更常见:
bool LoadFromClipboard(QRBitmap& out) { if (!OpenClipboard(nullptr)) return false; bool ok = false; if (HANDLE h = GetClipboardData(CF_DIB)) { BITMAPINFOHEADER* bih = static_cast<BITMAPINFOHEADER*>(GlobalLock(h)); // 用 CImage 从内存流载入的替代方案:手工按 bih 解析 // 推荐做法:把 DIB 头补上 BITMAPFILEHEADER,再交给 CImage::Load(IStream*) GlobalUnlock(h); } CloseClipboard(); return ok; }提示:从剪贴板拿到的 DIB 没有文件头,最省事的做法是拼一个 14 字节的
BITMAPFILEHEADER在前面,然后用CreateStreamOnHGlobal造一个IStream,交给CImage::Load直接从内存加载,能复用整套转换逻辑。
3.3 工作线程与界面响应
解码一张大图如果放在 UI 线程,1080P 大概 200 到 300 毫秒(没开优化的话可能到 1 秒),用户会明显感到卡顿。批量处理时更是灾难。所以必须开工作线程。
我用std::thread而不是AfxBeginThread,因为内核是纯 C++,线程函数里不碰任何 MFC 对象,只在结束后PostMessage回主线程拿结果。这样既避免了 MFC 对象跨线程的坑,也让内核保持零依赖。
有一条铁律:CImage、CDC、任何CWnd派生对象都不能在工作线程里用。我的做法是在主线程把图转成QRBitmap(纯数据),把QRBitmap通过shared_ptr传进线程,线程里只做识别,结果打包成结构体再 Post 回来。
批量场景再加一个小的细节:进度反馈不要每张图都Invalidate,攒到每 10 张或者每 100 毫秒刷新一次,否则消息队列会被重绘消息塞满,反而更慢。
3.4 结果标注绘制
工具做出来,用户最想看到的是“到底识别在哪、识别出了什么”。我的做法是:解码成功后,在对话框上画原图,用红色矩形框出三个定位图案,用绿色框出整个码的四个角,下面一个只读编辑框显示解码文本,旁边显示版本号、纠错等级、掩码号、耗时。
绘制用双缓冲避免闪烁:
void CScanDlg::DrawResult(CDC* pDC) { CDC mem; mem.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, m_rcView.Width(), m_rcView.Height()); CBitmap* old = mem.SelectObject(&bmp); mem.FillSolidRect(&m_rcView, RGB(245, 245, 245)); if (m_img.IsNull() == false) m_img.Draw(mem.GetSafeHdc(), m_rcView); if (m_bHit) { CPen pen(PS_SOLID, 2, RGB(220, 40, 40)); CPen* oldPen = mem.SelectObject(&pen); for (auto& r : m_finderRects) mem.Rectangle(&r); mem.SelectObject(oldPen); } pDC->BitBlt(m_rcView.left, m_rcView.top, m_rcView.Width(), m_rcView.Height(), &mem, 0, 0, SRCCOPY); mem.SelectObject(old); }坐标映射别搞混:解码是在原图坐标里做的,绘制时要按缩放比例换算到客户区。我在缩放比较极端(比如把 4000 像素宽的扫描件塞进 600 像素的窗口)时,遇到过框画歪一格的问题,原因是整数除法截断,改成先算double比例再四舍五入就正常了。
4. 纯 C++ 核心实现:可独立编译的解码器骨架
4.1 接口与数据结构
内核只暴露三个东西:图像结构、结果结构、一个函数。这样将来接控制台工具、接服务端批处理,改都不用改。
enum class QREcLevel { L = 0, M = 1, Q = 2, H = 3 }; struct QRResult { bool success = false; std::string text; // 解码文本(已做编码转换) std::string rawBytes; // 原始字节流,便于自定义解析 int version = 0; QREcLevel ec = QREcLevel::L; int maskId = -1; int correctedErrors = 0; // 纠错掉的码字数,可作为质量指标 std::string errorMsg; }; QRResult DecodeQR(const QRBitmap& gray);correctedErrors这个字段很有用:它相当于一个“图像质量分”。我拿它做过统计,同一批设备拍的图,纠错数持续上升说明镜头脏了或者光源衰减了,可以做预测性维护的预警指标。这种信息在标准接口里没人给你,自己实现的才有。
4.2 定位与采样代码
定位阶段我做了两级扫描:先按每 4 行扫一遍找候选,找到候选后再在候选附近逐行精扫,把中心点坐标算准。全图逐行扫在 1080P 上大约 8 毫秒,够用;但 4K 图上会到 40 毫秒以上,加这级降采样能砍掉一多半时间。
struct Finder { double cx, cy, moduleSize; }; std::vector<Finder> findFinderPatterns(const std::vector<uint8_t>& bin, int W, int H) { std::vector<Finder> out; const int step = (H > 1200) ? 4 : 1; // 大图降采样 for (int y = step; y < H - step; y += step) { const uint8_t* line = &bin[(size_t)y * W]; int run[5] = { 0, 0, 0, 0, 0 }; int state = 0; // 0..4 对应五段 for (int x = 0; x < W; ++x) { bool isDark = (line[x] == 0); // 期望顺序:黑 白 黑 白 黑 bool expectDark = (state % 2 == 0); if (isDark == expectDark) { run[state]++; } else { if (state == 4) { double ms = 0; if (matchFinderRatio(line, W, x, run, &ms)) { // 竖直方向交叉验证 Finder f; f.moduleSize = ms; if (crossCheckVertical(bin, W, H, x - run[4] / 2, y, ms, &f)) { f.cx = x - run[4] - run[3] - run[2] / 2.0; f.cy = (y + f.cy) / 2.0; out.push_back(f); } } run[0] = run[2]; run[1] = run[3]; run[2] = run[4]; run[3] = run[4] = 0; state = 2; if (isDark == (state % 2 == 0)) run[2] = 1; else run[3] = 1; } else { ++state; run[state] = 1; } } } } return out; }这段游程状态机写起来绕,但比“收集连续同色像素再判断”快很多,因为它只维护 5 个计数器和 1 个状态位,没有动态分配。候选去重我用的是简单的距离聚类:两个候选中心距离小于 3 个模块尺寸就合并成一个。
拿到三个定位图案后,还要确定它们的身份:哪个是左上。判断依据是三者构成直角,直角顶点就是左上,另外两个点按叉积符号区分右上和左下。这一步错了,整张矩阵会旋转或镜像,读出来的全是垃圾。
4.3 RS 纠错代码
伴随式算完全零就返回,这是快路径。否则进 Berlekamp-Massey:
// 简化流程示意,重点关注调用顺序 bool rsCorrect(uint8_t* block, int n, int ecLen, int& corrected) { uint8_t S[64] = { 0 }; if (rsSyndromes(block, n, ecLen, S)) return true; // 无需纠错 uint8_t sigma[64] = { 0 }, omega[64] = { 0 }; int errLoc[32] = { 0 }, errNum = 0; if (!runBerlekampMassey(S, ecLen, sigma, &errNum)) return false; if (!runChienSearch(sigma, errNum, errLoc)) return false; if (!runForney(sigma, omega, S, errLoc, errNum, block, n, ecLen)) return false; corrected += errNum; return true; }三个函数里 Berlekamp-Massey 最容易写错,原因在迭代终止条件和不一致处理上。我给个校验办法:自己造测试数据,取一段已知数据,算出对应的 RS 纠错码字(用生成多项式除法),然后人为改掉其中 k 个字节,看能不能全部纠回来、纠回的字节值对不对。k 从 1 试到ecLen/2,全部通过才算实现正确。这个自测我强烈建议做,因为 RS 的 bug 往往表现为“某些图能解、某些图乱码”,最难查。
注意:纠错能力上限是
ecLen / 2个码字。超过这个数,RS 会“纠”到一个错误但能通过校验的码字上,产生无法察觉的错误输出。所以如果你发现纠错数总是接近理论上限,说明图像质量已经到了悬崖边上,别高兴地以为算法厉害,这是危险信号。
4.4 数据段解析与编码回退
纠错后的数据码字是一串字节,还要按规范解析:开头是模式指示符(4 位),数字模式 0001、字母数字模式 0010、字节模式 0100、汉字模式 1101,后面跟字符计数指示符(位数随版本变:版本 1 到 9 的字节模式是 8 位,版本 10 以上是 16 位),再跟实际数据。
多段拼接很常见,比如“编号 + 日期”用两个段写进同一个码,所以解析要写成循环,直到遇到终止符 0000 或者数据耗尽。
字节模式的编码是最大的坑。规范里说默认按 ISO-8859-1 解释,但现实是:国产软件生成的码绝大多数写的是 UTF-8,也有老系统写 GBK。我的做法是:取到字节串后先按 UTF-8 尝试解码,如果字节序列不是合法 UTF-8,再回退到 GBK。判断 UTF-8 合法性的写法就是逐字节检查首字节和续字节的高位模式,一百行不到。
bool tryDecodeUTF8(const std::string& s, std::string& out) { size_t i = 0; while (i < s.size()) { unsigned char c = (unsigned char)s[i]; int len = 0; if (c < 0x80) len = 1; else if ((c & 0xE0) == 0xC0) len = 2; else if ((c & 0xF0) == 0xE0) len = 3; else if ((c & 0xF8) == 0xF0) len = 4; else return false; if (i + len > s.size()) return false; for (int k = 1; k < len; ++k) if (((unsigned char)s[i + k] & 0xC0) != 0x80) return false; i += len; } out = s; // 转换到本地宽字符在 MFC 层用 MultiByteToWideChar 完成 return true; }汉字模式(Shift JIS)在国内业务上几乎见不到,一般生成器都用字节模式装 UTF-8,所以我把汉字模式的支持放在最后,能解就行,不追求完善。这一点上不用过度投入,实测一百张网上下载的二维码里,汉字模式出现的次数是零。
5. 问题排查实录
5.1 现象到原因的速查表
调试这套东西最耗时间的不是写代码,是“读不出来但不知道为什么”。我把遇到过的现象整理成了表,按顺序往下排,命中率很高。
| 现象 | 最可能原因 | 快速验证 | 处理 |
|---|---|---|---|
| 任何图都读不出 | 灰度化或 stride 算错 | 把灰度图存成 PGM 打开看 | 检查 pitch 正负与 4 字节对齐 |
| 灰度图正常,找不到定位图案 | 二值化阈值跑偏 | 输出二值图看黑块是否粘连 | 调 offset,或改局部窗口大小 |
| 找到两个定位图案 | 第三个被裁切或反光 | 看框出来的位置 | 放宽边缘扫描范围,图像加边 |
| 三个都找到但矩阵乱 | 四个点对应关系错 | 打印左上/右上/左下坐标 | 用叉积判断直角顶点 |
| 格式信息汉明距离都大于 3 | 采样偏移半个模块 | 打印格式位与最近码字距离 | 检查模块中心是否 +0.5 |
| 格式信息正常,纠错全失败 | 块结构表抄错或交织还原错 | 打印总码字数与表中是否一致 | 对照规范核对分块 |
| 纠错通过,文本乱码 | 编码判断错 | 打印原始字节 hex | UTF-8 优先,回退 GBK |
| 只有部分图失败 | 模块尺寸太小 | 打印模块尺寸估计值 | 小于 2.5 像素先放大再解 |
| 反色二维码读不出(白码黑底) | 极性相反 | 二值图黑占多数 | 二值化结果取反再试一轮 |
反色这一条值得单独说。工业场景里有印在白底上的黑码,也有印在深色金属件上的浅色码,还有屏幕上显示的白码。我的做法是:第一轮按常规极性解,失败后把二值图整体取反再解一遍。代价是多花一次定位的时间,但成功率提升明显。判断极性的快速办法是统计三个定位图案中心的颜色——如果中心是白的,说明整张图反了。
5.2 采样精度的两个实测结论
我做过一组对照实验:同一张 1200 万像素的图,逐步缩小到模块宽度从 10 像素降到 1 像素,看识别成功率。
结果很有说服力:模块宽度 6 像素以上,成功率 100%,纠错数为 0;3 到 5 像素,成功率接近 100%,纠错数在 1 到 3 之间;2.5 像素左右,成功率掉到 80% 出头,而且失败样本的纠错数常常直接顶到上限;2 像素以下,成功率不到 30%。所以我在转换层加了一道预判:先用粗略扫描估一下模块尺寸,小于 3 像素就先把图放大 2 倍(双线性插值),再走完整流程。放大不会创造新信息,但会让采样点落在模块内部的概率变高,实测能救回不少边缘样本。
另一个结论是关于抗锯齿的。截图类的图边缘有灰度过渡,二值化后模块边界会左右晃 1 个像素。这种情况下 3×3 投票采样的效果明显好于单点采样,尤其是在模块宽度只有 3 到 4 像素时,差距更明显。别为了省那点时间用单点。
5.3 性能:批量和单张的取舍
单张解码在 i5 级别的机器上,1080P 图大约 30 到 40 毫秒,其中二值化占 10 毫秒左右,定位扫描占 8 到 15 毫秒,剩余是采样和纠错。这个量级对单张交互完全够用。
批量处理要做的优化方向不同:单张优化是“减少无谓计算”,批量优化是“并行加流水线”。我的做法是按 CPU 核心数开 4 到 8 个工作线程,每个线程独立解码,用线程池管任务队列。注意每个线程要有自己的临时缓冲区,不要共享std::vector反复分配——在高频调用下,内存分配器本身就是瓶颈。我一开始没做缓冲复用,处理 500 张图花了 22 秒,改成线程局部缓冲后降到 9 秒。
还有个小优化:批量处理时不要每次都从文件重新读图和灰度化,可以先一次性把所有图转成灰度缓存在内存里,视内存情况限流。这个改动在 SSD 上提升不明显,在机械盘或者网络盘上提升非常可观。
6. 工程细节与参数速查
6.1 一张表记住关键参数
| 项目 | 取值 | 说明 |
|---|---|---|
| 模块总数 | 17 + 4 × 版本 | 版本 1 为 21,版本 40 为 177 |
| 定位图案 | 7×7 模块,比例 1:1:3:1:1 | 扫描线穿中心时五段宽度比 |
| 定位图案中心坐标 | (3.5, 3.5) 等 | 模块坐标,注意半模块偏移 |
| 格式信息 | 15 位,BCH(15,5) | 32 种合法值,打表比对 |
| 版本信息 | 18 位,BCH(18,6) | 仅版本 7 及以上,34 种合法值 |
| 纠错等级编码 | L=01, M=00, Q=11, H=10 | 别按顺序硬映射 |
| GF 本原多项式 | 0x11D | 生成元 α = 2 |
| 单块纠错上限 | ecLen / 2 个码字 | 超出会产生虚假正确结果 |
| 最小可用模块宽度 | 3 像素(推荐 4 到 6) | 低于 2.5 像素成功率骤降 |
| 局部二值化窗口 | 图像短边 / 16,取奇数 | 覆盖 2 到 3 个模块为宜 |
| 二值化偏移量 | 8(扫描件)到 12(拍照) | 光照不均时向上调 |
6.2 移植与扩展的几个方向
这套内核因为不依赖任何框架,移植起来很轻松。我实际做过两个方向的扩展:一是做成控制台工具接在自动化流水线上,一次扫一个文件夹,输出 CSV;二是把内核抽出来跑在嵌入式板子上,只保留解码部分,代码体积不到 200KB,内存峰值在 1MB 以内(大图会被降采样,所以峰值和图像尺寸相关)。
如果要接摄像头做实时扫描,关键改动不在解码,而在“找码”。我的策略是每隔 N 帧做一次全图定位,定位成功后只在上一帧的码区域内做局部搜索,帧率能从 5 帧左右提到 20 帧以上。另外,实时场景不要追求每一帧都解出来,连续几帧结果一致再确认输出,稳定性会好很多。
还有一个方向是给结果加结构化解析。二维码里装的往往是 JSON 或者带分隔符的字符串,直接丢给业务层解析容易出问题。我在工具里加了一个可配置的规则模板,比如按|拆分后映射到字段名,这样现场工程师不用改代码就能调整解析规则,省了很多来回沟通。
最后说一点我在这个项目里体会最深的:这类底层解码工作,真正的门槛不在算法本身,而在每一环的边界条件。半模块偏移、stride 对齐、块结构表、编码判断、极性反转,每一个单拿出来都是几行代码的事,但任何一个错了,现象都是“解不出来”,跟算法写错完全无法区分。所以别急着写主流程,先把调试输出做扎实——把中间结果落成图、把关键数值打出来,出问题时对着看。我前后在这上面省下的时间,比优化算法省的多得多。