MFC+纯C++实现零依赖二维码解码:从二值化到RS纠错 上周有同行拿着一台工控机上的老项目找我说现场要用 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::vectoruint8_t pixels; // 统一存灰度1 字节/像素 inline uint8_t at(int x, int y) const { return pixels[static_castsize_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×35760 字节刚好对齐但宽度是 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::vectoruint8_t out, int block, int offset) { const int W src.width, H src.height; std::vectoruint32_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我常用短边/161080P 图就是 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::vectoruint8_t bin, int W, int H, const std::arrayPoint2f, 4 corner, int moduleCount, std::vectoruint8_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 格式信息与版本信息别自己推 BCH15 位格式信息里装着纠错等级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::vectoruint16_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 位的编码是L01、M00、Q11、H10别按直觉硬记我第一次就是按 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 10x11D生成元 α 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 为例块结构是这样的版本纠错等级总码字数据码字纠错码字块数1L2619711M26161011Q26131311H2691712L44341012M44281612Q44222212H4416281版本再往上块数会变成 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_castBYTE*(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 三条输入通道文件、拖拽、剪贴板实际用起来用户的操作路径不止一条至少要做三条打开文件对话框选图、从资源管理器直接把图拖进窗口、截图后 CtrlV 粘贴。这三条都是 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_castBITMAPINFOHEADER*(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::vectorFinder findFinderPatterns(const std::vectoruint8_t bin, int W, int H) { std::vectorFinder 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格式信息正常纠错全失败块结构表抄错或交织还原错打印总码字数与表中是否一致对照规范核对分块纠错通过文本乱码编码判断错打印原始字节 hexUTF-8 优先回退 GBK只有部分图失败模块尺寸太小打印模块尺寸估计值小于 2.5 像素先放大再解反色二维码读不出白码黑底极性相反二值图黑占多数二值化结果取反再试一轮反色这一条值得单独说。工业场景里有印在白底上的黑码也有印在深色金属件上的浅色码还有屏幕上显示的白码。我的做法是第一轮按常规极性解失败后把二值图整体取反再解一遍。代价是多花一次定位的时间但成功率提升明显。判断极性的快速办法是统计三个定位图案中心的颜色——如果中心是白的说明整张图反了。5.2 采样精度的两个实测结论我做过一组对照实验同一张 1200 万像素的图逐步缩小到模块宽度从 10 像素降到 1 像素看识别成功率。结果很有说服力模块宽度 6 像素以上成功率 100%纠错数为 03 到 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 种合法值纠错等级编码L01, M00, Q11, H10别按顺序硬映射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 对齐、块结构表、编码判断、极性反转每一个单拿出来都是几行代码的事但任何一个错了现象都是“解不出来”跟算法写错完全无法区分。所以别急着写主流程先把调试输出做扎实——把中间结果落成图、把关键数值打出来出问题时对着看。我前后在这上面省下的时间比优化算法省的多得多。