ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC+MFC二维码解析:工控机离线扫码与zxing集成

VC+MFC二维码解析:工控机离线扫码与zxing集成

简介:这份面向Windows平台开发者的VC二维码解析工程,聚焦在MFC对话框程序中接入ZBar库,完成QR Code等二维码图像的识别与结果解析,适合具备C++与MFC基础、希望打通图像解码流程的开发者参考。包内共23个文件,以6个C++头文件和5个源码文件为核心,配合dsw、dsp等VC工程配置、rc资源脚本、ico图标及ReadMe说明,构成可直接打开研读的轻量示例;压缩包约34KB,工程结构紧凑,便于按模块定位库引用、解码调用与界面展示代码。已有729人学习下载,说明其在入门级二维码识别实践中有一定参考价值。读者可重点梳理ZBar库的引入方式、图像数据传入与SymbolSet结果遍历写法,借鉴错误处理和MFC控件更新的实现思路,并理解二维码从黑白矩阵到文本结果的转换过程,为后续做图像预处理、多线程扫描或摄像头实时识别打下基础。

1. 老工控机的扫码按钮:VC + MFC 二维码解析真正的落点

产线上那台跑了七八年的工控机,系统还是 32 位,软件是一套 MFC 对话框。新需求来得突然:质检员拍的二维码图片要能直接拖进程序里读出内容。扫描枪只能对付贴在纸上的码,屏幕里的、相机抓的、客户邮件发来的截图,它一概不认。VC + MFC 二维码解析这类工程最典型的落点就在这儿:它不追求移动端那种相机预览加实时框,而是把一张位图稳定地变成一串文本,嵌进已有的桌面程序里,离线、无网、纯本地跑。

它适合两类人:一是手上还维护着 VS2010 到 VS2019 老工程、被要求在界面上补一个扫码入口的人;二是需要在没有网络的车间、机房、检测台上做本地解码的人。整套东西的技术含量并不落在“调个库”上,而落在图像预处理、字符编码和工作线程这三处。下面按一条能跑通的链路,从工程骨架一路拆到二次开发。

2. 工程骨架与选型:MFC 对话框 + 解码静态库怎么搭才不返工

2.1 QRDECODE 的目录划分与模块边界

动手写第一行代码之前,我建议先把“界面”和“解码”切干净。血泪经验是:解码逻辑一旦和CQrScanDlg搅在一起,后面想加个命令行批处理、想接相机回调,就得整段复制。所以核心层放一个不依赖 MFC 的QrDecoder命名空间,里面只有灰度图和结果结构体,界面层只负责取路径、画预览、显示文本。

QrScan/ ├─ QrScan.sln ├─ src/ │ ├─ app/ # 只依赖 MFC 的界面层 │ │ ├─ QrScanDlg.h/.cpp # 主对话框:拖拽、队列、消息响应 │ │ ├─ PreviewWnd.h/.cpp # 自绘预览窗,负责缩放与 ROI 框选 │ │ └─ DecodeItem.h # UI 线程与工作线程之间传的结果体 │ ├─ core/ # 不依赖 MFC,可单独拿去命令行复用 │ │ ├─ GrayImage.h # 宽高 + 连续灰度缓冲 │ │ ├─ QrDecoder.h/.cpp # 解码封装,QRDECODE 的主体 │ │ └─ ImageLoader.h/.cpp # CImage 加载转灰度(这一层依赖 ATL) │ └─ third_party/ # 解码库源码或 .lib + 头文件 ├─ test/ │ └─ samples/ # 带 expected.txt 标注的回归样本 └─ build/ # 各平台中间目录,别把 bin 和 obj 混在源码树里

core里唯一不可避免依赖 ATL 的是ImageLoader,因为读 png/jpg 图最省事的方式就是CImage。如果你要的是“连 ATL 都不要”的纯净版本,就把ImageLoader换成自己写的 stb_image 单文件解码,GrayImage之后的所有代码一行都不用改——这就是切边界带来的好处。

2.2 解码内核的三条路线:为什么最后选了 zxing 系

VC 下做二维码解析,能走的路其实只有三条,差别比想象中大。

方案依赖体积支持的码制接入成本适合的场景
zxing 的 C++ 移植中等,几十个 cppQR、DataMatrix、PDF417、一维码中,需要处理编译配置需要多码制、识别率优先
轻量 C 解码库(只做 QR)极小,两三个文件仅 QR低,直接加进工程嵌入式面板、体积敏感
OpenCV 的 QRCodeDetector大,几十 MBQR低,但部署麻烦已经有 OpenCV 依赖的机器视觉程序

我的选择是第一种。理由很实在:产线上偶尔会混进 DataMatrix 和 Code128,只支持 QR 的轻量库当场就得返工;而 OpenCV 那套在工控机上部署,光是 DLL 依赖就能让现场运维头疼半天。zxing 系方案虽然编译配置麻烦,但它是纯 C++、无外部依赖,编出来是一个静态库,拷到任何一台 32 位机器上都能跑。

注意,不同分支的接口差异不小:老版本用Ref<T>这套智能指针别名,新版本改成了std::shared_ptr,DecodeHints也换过写法。下面的代码以Ref<T>那一支为例,你手里的头文件函数签名对不上时,按头文件改,别照抄。

2.3 字符集、平台与运行时库的配置

这三项是新手最容易翻车的地方,而且报错信息一个比一个玄学。配置上我一般这么定:

  • 平台:工程和第三方库必须同为 Win32 或同为 x64。老工控机 90% 是 32 位系统,别想当然编 x64。
  • 字符集:MFC 工程一律 Unicode。第三方库返回的是 UTF-8 的std::string,进界面之前必须转CStringW,别直接往SetWindowText里塞。
  • 运行库:/MD(多线程 DLL)还是/MT(多线程静态),必须和解码库编出来时用的一致,否则链接期直接给你一句 LNK2038 的RuntimeLibrary mismatch。

包含目录和库目录在项目属性里配好之后,用#pragma comment把库名钉在代码里,比在链接器页面里一个个加更不容易漏:

// stdafx.h / pch.h 里统一处理,避免每个 cpp 都写一遍 #pragma comment(lib, "zlib.lib") // zxing 内部用到 #pragma comment(lib, "QrDecodeCore.lib")// 自己编出来的解码静态库 // 只在 x86 调试版加这个,方便定位库版本问题 #ifdef _DEBUG #pragma message("QrDecodeCore: " __DATE__ " " __TIME__) #endif

后面那句#pragma message看着没用,但它能在编译输出里打出库的时间戳。我遇到过好几次“明明改了库却没生效”,最后发现是链接器读的是build/下另一个同名旧 LIB,靠这行才定位到。

3. 核心链路拆解:一张位图从 CImage 到 UTF-8 字符串的全过程

3.1 图像加载:把任意格式统一压成 8 位灰度

解码库吃的是灰度缓冲,而用户拖进来的是 png、jpg、bmp,位数从 1 位到 32 位都有,还可能是带透明通道的截图。最稳的做法是先全部转成 32bpp,再统一取像素。为什么不在原位取?因为 24 位图的每行有 4 字节对齐的 padding,1 位图是按位打包的,为每种格式写一套取像素逻辑就是给自己挖坑。

// ImageLoader.cpp —— 任意格式图片统一转 8 位灰度 bool LoadAsGray(const CString& path, GrayImage& out) { CImage src; if (FAILED(src.Load(path)) || src.IsNull()) return false; // 统一到 32bpp,之后只写一套取像素逻辑 if (src.GetBPP() != 32) { CImage conv; if (!conv.Create(src.GetWidth(), src.GetHeight(), 32)) return false; HDC hdc = conv.GetDC(); src.Draw(hdc, 0, 0, src.GetWidth(), src.GetHeight()); conv.ReleaseDC(); // GetDC 必须配对 ReleaseDC,否则 GDI 句柄泄漏 src.Destroy(); src.Attach(conv.Detach());// 把转换结果接管过来 } const int w = src.GetWidth(); const int h = src.GetHeight(); const int pitch = src.GetPitch(); // 自下而上的 DIB 这里是负值 const BYTE* base = static_cast<const BYTE*>(src.GetBits()); out.width = w; out.height = h; out.data.resize(static_cast<size_t>(w) * h); for (int y = 0; y < h; ++y) { // pitch 为负时,base + y*pitch 依然是从上往下走,不用额外判断 const BYTE* row = base + static_cast<ptrdiff_t>(y) * pitch; BYTE* dst = out.data.data() + static_cast<size_t>(y) * w; for (int x = 0; x < w; ++x) { const BYTE b = row[x * 4 + 0]; // 内存里是 BGRA 顺序,不是 RGBA const BYTE g = row[x * 4 + 1]; const BYTE r = row[x * 4 + 2]; const BYTE a = row[x * 4 + 3]; // 透明像素按白底算,否则截图里的二维码会被当成黑块 dst[x] = (a < 16) ? 255 : static_cast<BYTE>((r * 77 + g * 151 + b * 28) >> 8); } } return true; }

几个参数值得说明。GetPitch()是每行的字节数,包含 padding,绝不能用width * 4代替,尤其是宽度不是 4 的倍数的图。灰度系数77/151/28是 0.299/0.587/0.114 的定点近似,和浮点算出来的结果差不超过 1,但快一个数量级。a < 16这个阈值是给 PNG 截图留的余量,很多截图工具导出的图 alpha 是 0 而不是 255。

3.2 解码调用:QrDecoder 封装类的实现

封装层的职责很窄:把灰度缓冲喂进去,把结果或异常翻译成调用方能懂的结构体。异常这块必须兜住,因为 zxing 找不到码时抛的是NotFoundException,不 catch 就直接崩在用户面前。

// QrDecoder.h struct GrayImage { int width = 0, height = 0; std::vector<BYTE> data; // 行优先,每行 width 字节,无 padding }; class QrDecoder { public: struct Result { bool ok = false; CStringW text; // 解码内容 CStringW format; // QR_CODE 等码制名 int x = 0, y = 0, w = 0, h = 0; // 码在图中的包围盒,用于画红框 CStringW error; }; Result Decode(const GrayImage& img, bool tryHarder = true); };
// QrDecoder.cpp QrDecoder::Result QrDecoder::Decode(const GrayImage& img, bool tryHarder) { Result out; if (img.width <= 0 || img.height <= 0) { out.error = L"空图像"; return out; } // 1) 灰度缓冲 -> LuminanceSource Ref<LuminanceSource> source(new GreyscaleLuminanceSource( img.data, img.width, img.height, 0, 0, img.width, img.height)); // 2) 二值化:光照均匀走全局直方图,拍屏幕/渐变背景换成 HybridBinarizer Ref<Binarizer> binarizer(new GlobalHistogramBinarizer(source)); Ref<BinaryBitmap> bitmap(new BinaryBitmap(binarizer)); // 3) 只开 QR,减少误判;TRY_HARDER 用 2~5 倍时间换倾斜和低对比的识别率 DecodeHints hints; hints.setTryHarder(tryHarder); hints.setPossibleFormats(std::vector<BarcodeFormat>{ BarcodeFormat::QR_CODE }); MultiFormatReader reader; try { Ref<Result> r = reader.decode(bitmap, hints); if (r) { out.ok = true; out.text = Utf8ToWide(r->getText()); // 库给的是 UTF-8 out.format = Utf8ToWide(ToString(r->getBarcodeFormat())); // 包围盒用于界面上框出来,取不到就保持 0 const auto& pts = r->getResultPoints(); if (pts.size() >= 2) { out.x = pts.front()->getX(); out.y = pts.front()->getY(); out.w = pts.back()->getX() - out.x; out.h = pts.back()->getY() - out.y; } } } catch (const std::exception& e) { out.error = Utf8ToWide(e.what()); // NotFoundException 也在这里落地 } return out; }

UTF-8 到宽字符的转换建议单独抽一个函数,全工程只留一份,别到处手写MultiByteToWideChar:

CStringW Utf8ToWide(const std::string& s) { if (s.empty()) return CStringW(); const int n = ::MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); CStringW w; if (n > 0) { ::MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), w.GetBuffer(n), n); w.ReleaseBuffer(n); } return w; }

CP_UTF8这个参数是死的规定,别改成CP_ACP。二维码里存中文时,生成端几乎都用 UTF-8 编码,用本地代码页去转,出来就是一堆问号,而这个错误在英文码上完全看不出来,等用户发来一张中文名片码才发现,返工成本很高。

3.3 识别失败后的补救:缩放重试与 ROI 裁剪

一次Decode失败不代表图里没有码。模块宽度只有 2 到 3 个像素的小码、被压缩过的截图、整图分辨率超过 4000 像素导致二值化窗口失准,都是常见情况。我的做法是在封装层外面套一层“补救策略”,而不是改库。

QrDecoder::Result DecodeWithRetry(QrDecoder& decoder, const GrayImage& img) { QrDecoder::Result r = decoder.Decode(img, true); if (r.ok) return r; // 策略一:放大一倍再试。小码在放大后模块边界清楚,识别率提升最明显 if (img.width * 2 <= 4096 && img.height * 2 <= 4096) { GrayImage big = ResizeBilinear(img, img.width * 2, img.height * 2); r = decoder.Decode(big, true); if (r.ok) return r; } // 策略二:大图降采样再试。分辨率过高时二值化容易把模块糊成一团 if (img.width > 2400) { GrayImage small = ResizeBilinear(img, img.width / 2, img.height / 2); r = decoder.Decode(small, true); // 注意:返回的坐标要乘回 2 才能用于界面 } return r; }

这里有个容易忽略的细节:缩放后坐标要还原。ResizeBilinear放大两倍后返回的包围盒是放大图上的坐标,直接拿去画红框会偏到画面外。我在缩放函数里顺手把scale因子记进GrayImage结构,出结果时统一乘回去,比在每个调用点手动乘要靠谱。

4. 界面与交互:拖拽、预览框选与工作线程的三处实现

4.1 拖拽接收与文件过滤

用户体验上,拖拽比“点按钮选文件”快得多,实现也就三行。窗口初始化时开启接收,ON_WM_DROPFILES里处理路径,注意文件夹拖进来时要展开。

BOOL CQrScanDlg::OnInitDialog() { CDialogEx::OnInitDialog(); DragAcceptFiles(TRUE); // 也可以改对话框属性的 Accept files,运行时开更省事 m_tryHarder = true; return TRUE; } void CQrScanDlg::OnDropFiles(HDROP hDrop) { const 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); if (::PathIsDirectory(path)) { // 拖的是文件夹,展开一层 EnqueueFolder(path); continue; } LPCTSTR ext = ::PathFindExtension(path); if (ext && (_tcsicmp(ext, _T(".png")) == 0 || _tcsicmp(ext, _T(".jpg")) == 0 || _tcsicmp(ext, _T(".jpeg")) == 0 || _tcsicmp(ext, _T(".bmp")) == 0)) { m_queue.push_back(path); // 先入队,界面立刻给个缩略图反馈 } } ::DragFinish(hDrop); // 不调用会泄漏系统分配的拖放句柄 StartDecodeThread(); }

DragQueryFile第一次传0xFFFFFFFF拿的是文件数量,这个魔数看着怪,但它是 Win32 API 里唯一正确的写法,不要改成-1。

4.2 预览绘制与 ROI 框选

预览窗是自己画的。这里最容易被忽略的是SetStretchBltMode:默认的COLORONCOLOR是最近邻采样,缩小之后二维码的模块会糊成一片灰,人眼都看不清,用户根本没法判断框哪儿。

void CPreviewWnd::OnPaint() { CPaintDC dc(this); if (!m_bmp.GetSafeHandle()) return; CRect rc; GetClientRect(&rc); CDC mem; mem.CreateCompatibleDC(&dc); CBitmap* old = mem.SelectObject(&m_bmp); // HALFTONE 必须先设好,再把原点设成 0,否则收缩后整体偏移 dc.SetStretchBltMode(HALFTONE); dc.SetBrushOrg(0, 0); dc.StretchBlt(rc.left, rc.top, rc.Width(), rc.Height(), &mem, 0, 0, m_imgW, m_imgH, SRCCOPY); if (!m_rcSel.IsRectEmpty()) // 框选区域描个虚线框 DrawSelection(&dc, m_rcSel); mem.SelectObject(old); }

框选区域要映射回原图坐标才能裁剪。映射比例直接按“视口宽 / 图宽”算,别用窗口宽,因为图是按比例拉伸的:

CRect CPreviewWnd::MapToImage(const CRect& rcSel) const { if (m_rcView.Width() <= 0 || m_rcView.Height() <= 0) return CRect(); const double sx = double(m_imgW) / m_rcView.Width(); const double sy = double(m_imgH) / m_rcView.Height(); return CRect(int(rcSel.left * sx), int(rcSel.top * sy), int(rcSel.right * sx), int(rcSel.bottom * sy)); }

裁剪之后只解码这块矩形,对高分辨率实拍图的效果比整图硬解好得多——背景越干净,二值化越准。

4.3 工作线程:50 张图不卡界面的关键

一次拖进 50 张图,如果在 UI 线程里逐张解码,界面会白屏几十秒,用户会以为程序挂了。标准做法是开一个工作线程取队列,解完用PostMessage把结果甩回 UI 线程。

UINT CQrScanDlg::DecodeThread(LPVOID p) { auto* self = static_cast<CQrScanDlg*>(p); ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); // CImage 走 GDI+ 时建议初始化 while (!self->m_bStop) { CString path; if (!self->m_queue.Pop(path)) { ::Sleep(30); continue; } GrayImage img; QrDecoder::Result r; if (LoadAsGray(path, img)) r = DecodeWithRetry(self->m_decoder, img); else r.error = L"图片打不开"; // 千万不要在工作线程里直接操作控件;把结果打包丢回 UI 线程,由它负责 delete auto* item = new DecodeItem{ path, r }; self->PostMessage(WM_DECODE_DONE, 0, reinterpret_cast<LPARAM>(item)); } ::CoUninitialize(); return 0; }

两个约束必须守住。一是别用SendMessage,工作线程会阻塞等 UI 线程处理完,一旦 UI 线程此时正在等别的东西,就是死锁。二是结果结构体new出来在 UI 线程delete,谁 new 谁不管,谁收到谁管,写清楚之后就不会有内存泄漏。

5. 排查清单:编译链接、识别率和 GDI 泄漏这几类翻车现场

5.1 编译与链接期的三个坑

现象一:LNK2019 一堆未解析的外部符号,函数名都对得上。原因九成是平台不匹配,工程是 Win32 而.lib是 x64 编的。解决办法是把第三方库按平台各编一份,目录按lib/x86和lib/x64分开,项目属性里用$(Platform)宏引用,别在两个平台之间共用同一个库路径。

现象二:LNK2038RuntimeLibrary不匹配。原因是主工程用/MDd,解码库用/MTd。这在混用预编译头的老工程里特别常见。解决办法是把所有参与链接的工程(包括第三方库)的 C/C++ → 代码生成 → 运行库统一成同一个值,Debug 和 Release 分别对齐。

现象三:编译通过,运行时一调解码就崩在库内部。常见于 MFC 用 Unicode 而库那边按 MBCS 编。检查项目属性里的字符集设置,同时确认库的#define里没有残留的_MBCS。这类崩溃没有调用栈信息可看,只能靠配置对齐,属于典型的黑匣子问题。

5.2 识别不出来的四类图像

现象一:手机截图能解,相机实拍全解不出来。原因是实拍图有光照渐变,全局直方图二值化把整个码区糊成一团。解决办法是把GlobalHistogramBinarizer换成HybridBinarizer,它是分块局部阈值,对不均匀光照的适应性明显更好,代价是多花一点时间。

现象二:中文内容显示成问号。原因是用CP_ACP去转 UTF-8 数据。解决办法见 3.2 节的Utf8ToWide,全工程只留这一份,谁都不许手写。

现象三:透明背景的 PNG 解不出来。原因是 alpha 为 0 的像素 RGB 值可能是 0,转灰度后整片黑。解决办法是在LoadAsGray里把a < 16的像素强制置 255,或者交给 GDI+ 先合成到白底上。

现象四:反色码(白码黑底)识别率极低。这类码在深色主题的 App 截图里很常见。解决办法是解一次失败后把整幅灰度图取反(255 - v)再解一次,成本很低,覆盖率提升明显。

5.3 内存与句柄泄漏

现象:软件连续跑两三天,界面开始画不出来,任务管理器里 GDI 对象涨到一万多。原因是CImage::GetDC()没有配对的ReleaseDC(),或者CreateCompatibleDC出来的CDC没有DeleteDC。解决办法是把所有GetDC用 RAII 包一层,析构时自动释放,杜绝手写路径上的遗漏。

现象:拖进去的图片删不掉,提示“文件被占用”。原因是CImage对象没Destroy()就出了作用域,或者加载失败时提前 return 跳过了释放。解决办法是让CImage老老实实做局部变量,靠析构释放;需要跨函数传递时用Detach/Attach明确所有权转移。

现象:批量处理 200 张图后内存涨到 1G 不回落。原因是每张图都新建了工作线程,或者DecodeItem在 UI 线程里被异常路径漏掉没 delete。解决办法是队列 + 单工作线程,WM_DECODE_DONE的处理函数里用std::unique_ptr接管指针,异常路径也跑不掉。

6. 二次开发:把解码内核抽成 DLL 并接进批量与相机流程

真正落到项目上,第一件要做的事是把core抽成独立的 DLL,导出两个函数就够了:一个吃文件路径,一个吃内存里的灰度缓冲。后者是给相机回调准备的——工业相机抓到一帧就是一片BYTE*,根本不需要落盘成 jpg 再读回来,中间那次编码解码纯属浪费。接口用 C 风格导出,避免 C++ 名字修饰,C# 或 LabVIEW 那边也能直接调。

// QrDecodeApi.h —— 导出接口只保留 C 风格,跨语言调用省事 extern "C" { // 返回 0 成功,非 0 为错误码;outText 由调用方提供缓冲,成功时写入 UTF-8 __declspec(dllexport) int __stdcall QrDecodeFile(const wchar_t* path, char* outText, int cbText); __declspec(dllexport) int __stdcall QrDecodeGray(const unsigned char* gray, int w, int h, char* outText, int cbText); }

第二个要改的是批量流程。把“拖进来的每个文件”换成一张CListCtrl记录:路径、状态、内容、耗时。耗时这一列别省,它能直接告诉你哪类图在拖后腿,比凭感觉调参数强得多。

参数调优我整理成一张对照表,现场按图源类型直接查:

图源类型tryHarder二值化器预缩放
手机截图、纯白底关GlobalHistogram1x
拍屏幕、有摩尔纹开Hybrid0.5x
打印件扫描、有倾斜开Hybrid1x
小码,模块宽度 2px开Hybrid2x
反色码(白码黑底)开GlobalHistogram1x + 取反重试

验证方法别靠肉眼。我一般建一个test/samples目录,里面放 30 到 50 张最难的图,再配一个expected.txt,一行一个文件名加期望内容。跑一个控制台小工具遍历目录,比对结果,输出召回率和耗时。这个样本集是后悔药:以后换解码库版本、改二值化策略、调缩放倍数,跑一遍就知道是变好还是变坏,不用靠“感觉好像快了点”。

最后说个习惯。我最早做这类项目时,是拿手边最清晰的那张码调通就交付了,结果现场第一张实拍图就解不出来,来回折腾了三天。从那以后,我每接一个解码需求,都先把能拿到的、最难看的 30 张样本凑齐跑一遍,再谈参数和封装——顺序反过来,后面全是返工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进