简介:这份资源是面向C++开发者与计算机视觉学习者的ZXing二维码识别工程,将ZXing核心解码能力移植到C++,并封装为可直接接收OpenCV Mat图像的接口,适合在VS2010环境下做本地化二维码检测、工业视觉或嵌入式识别项目。压缩包共279个文件,约13.81MB,以h头文件与cpp源文件为主体,配合工程配置、库文件、日志与资源文件,便于阅读解码流程、编译链接和二次开发。已有2971人学习下载,说明其在实际项目中的参考价值较高。资源不仅包含ZXing解码模块,还涉及图像预处理、阈值调整、模板匹配定位等优化思路,并对中文乱码的UTF-8与GBK/GB2312转换给出处理方向;读者可据此梳理从Mat读图到结果输出的完整链路,理解OpenCV前处理与ZXing解码的衔接方式,并在此基础上扩展鲁棒性与性能优化。
1. 从一张糊图到可读字符串:zxing C++ 版加 OpenCV 接口能解决什么
一张被仓库摄像头拍歪的二维码,用 OpenCV 自带的检测器能框出来,却死活解不出内容;换 zxing C++ 版接上 OpenCV 的 cv::Mat,问题往往出在灰度通道和 stride,而不是算法本身。这份资源做的就是这条链路:OpenCV 负责读图、裁 ROI、做预处理,zxing C++ 负责定位和解码,输出文本、格式和四个角点。
它适合需要在 C++ 服务里离线识别二维码的开发者,尤其是已有 OpenCV 工程、又不想把图片传到外部接口的场景。新手可以按最小 demo 跑通,熟手重点看 ImageView 的内存模型和 ReaderOptions 的参数边界。下面按“怎么接、怎么调、哪里翻车、怎么验证”的顺序拆开。
2. 选型与工程接入:为什么用 zxing C++ 而不是只靠 OpenCV 的 QRCodeDetector
很多工程一开始用cv::QRCodeDetector,几行代码就能跑,但到现场就遇到边界。不是 OpenCV 不行,而是它的定位和解码策略偏通用,遇到低对比度、局部遮挡、多码、旋转角度大时,召回率会掉。zxing C++ 版把解码器拆得更细,格式、尝试旋转、反色、努力模式都能单独控制。选型时先看失败样本:如果失败集中在“图能拍到但解不出”,换解码器;如果失败在“图里根本没有二维码”,先改采集。
2.1 识别链路拆开看:OpenCV 负责取像素,zxing 负责解码
把链路拆成四段更清楚:OpenCV 用imread把文件读成cv::Mat,用cvtColor转灰度,必要时裁 ROI、做增强;zxing 用ImageView接管一块灰度内存,ReadBarcode返回Result,最后从Result里取文本、格式和定位点。zxing 不读文件,也不管理cv::Mat的生命周期,ImageView只是对内存的视图,不拥有数据。
| 阶段 | 输入 | 输出 | 容易出错的地方 |
|---|---|---|---|
| 读图 | 文件路径 | cv::Mat | 路径编码、空图、通道数 |
| 灰度化 | BGR/BGRA | 单通道Mat | 把三通道当灰度塞进去 |
| 构造视图 | 灰度内存 | ZXing::ImageView | rowStride单位、数据生命周期 |
| 解码 | ImageView+ReaderOptions | ZXing::Result | 参数开太猛、格式不匹配 |
这张表里最不起眼的是“构造视图”,实际翻车最多。ImageView的rowStride单位是字节,不是像素;对灰度图来说两者数值一样,对 ROI 裁剪出来的非连续Mat就不一样。另一个黑匣子是生命周期:ImageView指向gray.data,gray一释放,视图就悬空,解码时可能直接段错误。
2.2 CMake 接入与最小可编译工程
先给一个最小工程,目标是“一张图进去,一行文本出来”。下面这份CMakeLists.txt按“系统里已经装好 zxing 和 OpenCV”来写;如果源码包里自带 zxing 子目录,把find_package换成add_subdirectory即可。
cmake_minimum_required(VERSION 3.16) project(qr_zxing_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) find_package(ZXing CONFIG REQUIRED) add_executable(qr_demo main.cpp) target_link_libraries(qr_demo PRIVATE opencv_core opencv_imgcodecs opencv_imgproc ZXing::ZXing )这段 CMake 的逻辑很直:OpenCV提供读图和颜色转换,ZXing::ZXing提供解码目标。opencv_imgcodecs不能漏,imread在这个模块里;只链opencv_core会在链接阶段报未定义符号。find_package(ZXing CONFIG REQUIRED)的包名和目标名在不同集成方式下可能不同,拿到源码包后先看它自带的CMakeLists.txt导出的是ZXing::ZXing还是别的名字,不要照抄到别的工程里硬套。
对应的最小main.cpp如下。这里的重点是灰度转换和ImageView构造,先不追求多码和预处理。
#include <opencv2/opencv.hpp> #include <ZXing/ReadBarcode.h> #include <ZXing/ImageView.h> #include <ZXing/Result.h> #include <ZXing/BarcodeFormat.h> #include <ZXing/ReaderOptions.h> #include <iostream> #include <string> int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: qr_demo <image>\n"; return 2; } // 保留 BGR,后面如果要画框还能用;识别前再转灰度 cv::Mat bgr = cv::imread(argv[1], cv::IMREAD_COLOR); if (bgr.empty()) { std::cerr << "read failed: " << argv[1] << "\n"; return 3; } cv::Mat gray; cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); // ImageView 不拷贝数据,gray 必须活到 ReadBarcode 返回 ZXing::ImageView view( gray.data, gray.cols, gray.rows, ZXing::ImageFormat::Lum, static_cast<int>(gray.step) ); ZXing::ReaderOptions opts; opts.setFormats(ZXing::BarcodeFormat::QRCode); opts.setTryHarder(true); opts.setTryRotate(true); ZXing::Result result = ZXing::ReadBarcode(view, opts); if (!result.isValid()) { std::cout << "not found\n"; return 1; } std::cout << result.text() << "\n"; return 0; }代码里gray.step是每行字节数,转成int传给rowStride;灰度图连续时它等于gray.cols,ROI 裁剪后可能大于gray.cols,这时不能图省事写gray.cols。setFormats(ZXing::BarcodeFormat::QRCode)把格式锁在二维码上,减少其他码制的误触发;setTryHarder(true)和setTryRotate(true)提升召回,但会增加耗时,后面第 4 章会讲怎么按样本逐项开。
2.3 新旧 API 分水岭:ImageView/ReadBarcode 与 LuminanceSource 两套写法
zxing C++ 版在演进过程中有两套常见写法:新版走ImageView+ReadBarcode,老版走LuminanceSource+BinaryBitmap+MultiFormatReader。源码包里如果同时出现ZXing/ReadBarcode.h和zxing/LuminanceSource.h,先确认当前头文件属于哪一套,不要混着写。混用的典型症状是编译时报“找不到ImageView”,或者链接时报ReadBarcode未定义。
新版接口的好处是“视图”概念清晰:你给一块内存、宽高、格式和行跨度,解码器自己决定怎么二值化。老版需要自己继承LuminanceSource,实现getRow和getMatrix,再把BinaryBitmap喂给MultiFormatReader。下面这段只示意老版的包装思路,具体ArrayRef的构造和长度处理以手里的头文件为准。
// 老版 zxing-cpp 常见写法:自己包一层 LuminanceSource class MatGraySource : public zxing::LuminanceSource { public: explicit MatGraySource(const cv::Mat& gray) : zxing::LuminanceSource(gray.cols, gray.rows), gray_(gray.clone()) {} zxing::ArrayRef<char> getRow(int y, zxing::ArrayRef<char> row) const override { // 只填一行灰度;row 不够长时重新分配 if (row->size() < static_cast<size_t>(gray_.cols)) { row = zxing::ArrayRef<char>(gray_.cols); } std::memcpy(row->data(), gray_.ptr<char>(y), gray_.cols); return row; } zxing::ArrayRef<char> getMatrix() const override { // 返回整块灰度,clone 后保证连续 zxing::ArrayRef<char> matrix(gray_.total()); std::memcpy(matrix->data(), gray_.data, gray_.total()); return matrix; } private: cv::Mat gray_; };这段代码的用途是迁移对照,不是让你直接复制进新工程。老版里MatGraySource要保证gray_是单通道、连续;如果传进来的是 ROI,gray_.ptr<char>(y)按行取没问题,但gray_.total()和step的关系要自己核对。新版用ImageView把这件事简化了,代价是你要自己保证rowStride正确。两套 API 的选型建议:新工程优先新版;老工程如果已经稳定运行,不要为了“新”而重写,先把参数和生命周期守住。
3. 从 cv::Mat 到 ZXing::ImageView:灰度、stride 与内存生命周期的实操
真正把cv::Mat塞给 zxing,翻车最多的不是解码算法,而是“看起来是灰度,实际是三通道”。cv::imread(path, cv::IMREAD_GRAYSCALE)直接读灰度最省事,但如果你后面还要在原图上画定位框,就得保留 BGR,识别前再转灰度。两种路径都可以,关键是别把IMREAD_COLOR读出来的三通道Mat直接标成ImageFormat::Lum,那样解码器会按单通道解释内存,结果就是花屏一样的噪声,识别率无限接近零。
3.1 读图与灰度化:别把 BGR 当灰度塞进去
下面这个函数把“读图 + 转灰度 + 保证连续”收在一起。channels()判断不能省,现场图片可能来自不同模块,有的是 3 通道,有的是 4 通道,有的已经是单通道。isContinuous()判断也不能省,ROI 裁剪出来的Mat往往不连续,直接传data加step虽然也能用,但为了后面复用简单,这里统一 clone 成连续内存。
cv::Mat loadGray(const std::string& path) { cv::Mat src = cv::imread(path, cv::IMREAD_UNCHANGED); if (src.empty()) { return {}; } cv::Mat gray; if (src.channels() == 1) { gray = src; } else if (src.channels() == 3) { cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); } else if (src.channels() == 4) { cv::cvtColor(src, gray, cv::COLOR_BGRA2GRAY); } else { return {}; } // ROI 或带 padding 的 Mat 可能不连续,clone 后 step == cols if (!gray.isContinuous()) { gray = gray.clone(); } return gray; }IMREAD_UNCHANGED的好处是保留原始通道数,避免IMREAD_COLOR把透明通道直接丢掉后你还在按 4 通道解释。COLOR_BGR2GRAY和COLOR_BGRA2GRAY的权重不同,别混用;4 通道图用 3 通道转换会报错或得到意外结果。clone()有拷贝成本,但只在非连续时触发,批量跑图时这个成本比“stride 传错导致全量识别失败”低得多。
3.2 构造 ImageView 的四个参数与 stride 陷阱
ZXing::ImageView的构造参数可以记成“数据、宽、高、格式、行跨度、像素跨度”。最后两个都有默认值,灰度图最稳的写法是显式传rowStride,pixStride用默认。rowStride的单位是字节,表示从一行开头到下一行开头跨了多少字节;pixStride表示一个像素占几个字节,灰度是 1。
ZXing::ImageView makeView(const cv::Mat& gray) { // 调用方必须保证 gray 在 view 使用期间存活 return ZXing::ImageView( gray.data, gray.cols, gray.rows, ZXing::ImageFormat::Lum, static_cast<int>(gray.step) ); }这个函数返回ImageView本身没问题,问题是它指向的gray不能是局部临时对象。下面这种写法就是典型翻车现场:makeView内部创建了gray,返回后gray析构,view还在,解码时读的是已释放内存。血泪经验是:要么让gray在调用方作用域里活到ReadBarcode结束,要么把识别逻辑收进同一个函数,别把ImageView当值传来传去。
// 错误示范:view 指向的 gray 已经析构 ZXing::ImageView badView() { cv::Mat gray = cv::Mat::zeros(100, 100, CV_8UC1); return ZXing::ImageView(gray.data, gray.cols, gray.rows, ZXing::ImageFormat::Lum, static_cast<int>(gray.step)); }3.3 一个可复用的识别函数:返回文本、格式与定位点
工程里不建议每次写一遍ReadBarcode,包一个decodeQr更稳。返回值里带文本、格式字符串和四个角点,后面画框、排序、去重都用得上。注意Result::position()返回的是定位点,不是边界框;四个点可以直接转成cv::Point2f,画polylines时按顺序连起来。
struct QrHit { std::string text; std::string format; std::vector<cv::Point2f> corners; }; bool decodeQr(const cv::Mat& bgr, QrHit& hit, const ZXing::ReaderOptions& opts) { if (bgr.empty()) { return false; } cv::Mat gray; if (bgr.channels() == 1) { gray = bgr; } else if (bgr.channels() == 3) { cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); } else if (bgr.channels() == 4) { cv::cvtColor(bgr, gray, cv::COLOR_BGRA2GRAY); } else { return false; } if (!gray.isContinuous()) { gray = gray.clone(); } ZXing::ImageView view( gray.data, gray.cols, gray.rows, ZXing::ImageFormat::Lum, static_cast<int>(gray.step) ); ZXing::Result result = ZXing::ReadBarcode(view, opts); if (!result.isValid()) { return false; } hit.text = result.text(); hit.format = ZXing::ToString(result.format()); hit.corners.clear(); for (const auto& p : result.position()) { hit.corners.emplace_back(static_cast<float>(p.x), static_cast<float>(p.y)); } return true; }decodeQr里gray是局部变量,但ReadBarcode在函数内同步完成,view不会活过gray的生命周期,所以安全。opts用 const 引用传入,调用方可以复用同一份参数,避免每次构造。ZXing::ToString(result.format())把枚举转成可读字符串,落日志时比数字友好;如果手里的头文件没有这个函数,就退回static_cast<int>(result.format())记录枚举值,别自己编字符串映射。
3.4 批量目录识别与结果落盘
批量跑图时,最容易犯的错是“每张图都构造一次ReaderOptions并开一堆线程”。ReaderOptions本身很轻,但多线程共享同一个解码器实例不一定安全,稳妥做法是每线程独立opts,或者单线程批量跑。下面这个例子用std::filesystem遍历目录,输出 TSV,方便后面用表格软件看失败样本。
#include <filesystem> #include <fstream> void batchDecode(const std::string& dir, const std::string& outTsv) { ZXing::ReaderOptions opts; opts.setFormats(ZXing::BarcodeFormat::QRCode); opts.setTryHarder(true); opts.setTryRotate(true); std::ofstream out(outTsv); out << "file\tok\ttext\tformat\n"; for (const auto& entry : std::filesystem::directory_iterator(dir)) { if (!entry.is_regular_file()) { continue; } const std::string path = entry.path().string(); cv::Mat bgr = cv::imread(path, cv::IMREAD_COLOR); QrHit hit; const bool ok = decodeQr(bgr, hit, opts); out << path << '\t' << (ok ? 1 : 0) << '\t' << (ok ? hit.text : "") << '\t' << (ok ? hit.format : "") << '\n'; } }directory_iterator不保证顺序,回归测试时最好先按文件名排序,或者把样本命名成固定前缀,避免今天跑和明天跑结果对不上。imread失败时bgr为空,decodeQr直接返回 false,不会崩;但日志里最好把“读图失败”和“解码失败”分开记,否则排查时会把路径问题当成算法问题。TSV 里文本字段可能含制表符,生产环境建议换 JSON 或 CSV 转义,这里为了演示保持简单。
4. 解码参数调优:ReaderOptions、多码、镜像与 ROI 裁剪
参数调优的顺序建议是“先锁格式,再开努力模式,最后动预处理”。很多人一上来就把能开的开关全开,结果单张耗时翻几倍,误识别也上来了。ReaderOptions里最常用的是setFormats、setTryHarder、setTryRotate、setTryInvert:格式锁得越窄,误触发越少;tryHarder提升召回但更耗 CPU;tryRotate处理旋转;tryInvert处理反色二维码。先跑默认,拿失败样本逐项加,别凭感觉全开。
4.1 先定格式再谈速度:setFormats 与 tryHarder
如果业务只认二维码,就把格式锁在QRCode。这一刀能砍掉一堆无关码制的尝试,也能减少“把纹理误认成条码”的玄学问题。tryHarder适合离线批处理或对召回要求高的场景;实时视频流里每帧都开,帧率会明显掉。可以按场景分两套参数:预览用轻量,抓拍用努力模式。
ZXing::ReaderOptions makeOptions(bool realtime) { ZXing::ReaderOptions opts; opts.setFormats(ZXing::BarcodeFormat::QRCode); if (!realtime) { opts.setTryHarder(true); // 离线批处理:召回优先 opts.setTryRotate(true); // 允许尝试旋转 opts.setTryInvert(true); // 允许反色码 } else { opts.setTryHarder(false); // 实时预览:延迟优先 opts.setTryRotate(true); } return opts; }setTryInvert(true)不是所有场景都需要,反色二维码在物料上不常见,开了会增加耗时;如果现场确实有黑底白码,再开。setTryRotate(true)对手机拍摄的歪图有帮助,但它不是任意角度矫正,别把它当成“万能旋转”。实时场景里tryHarder=false不等于不识别,只是少做几轮尝试;如果帧率还是不够,优先降分辨率、裁 ROI,而不是继续砍参数。
4.2 多码与 ROI:一张图里多个二维码怎么拆
一张图里多个二维码,单码接口只会返回一个结果。新版接口用ReadBarcodes返回结果列表,适合盘点、货架、票据这类场景。多码识别的前提是二维码之间别贴太近,定位图案互相干扰时,解码器也救不了。工程上常见的做法是先跑全图多码,失败再按网格切 ROI 逐个解。
std::vector<QrHit> decodeMulti(const cv::Mat& bgr, const ZXing::ReaderOptions& opts) { std::vector<QrHit> hits; if (bgr.empty()) { return hits; } cv::Mat gray; cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); if (!gray.isContinuous()) { gray = gray.clone(); } ZXing::ImageView view( gray.data, gray.cols, gray.rows, ZXing::ImageFormat::Lum, static_cast<int>(gray.step) ); for (const auto& result : ZXing::ReadBarcodes(view, opts)) { if (!result.isValid()) { continue; } QrHit hit; hit.text = result.text(); hit.format = ZXing::ToString(result.format()); for (const auto& p : result.position()) { hit.corners.emplace_back(static_cast<float>(p.x), static_cast<float>(p.y)); } hits.push_back(std::move(hit)); } return hits; }ReadBarcodes返回的是 vector,遍历时仍然要判断isValid(),别假设每个结果都有效。多码结果没有固定顺序,业务上要按角点坐标排序,比如先按 y 再按 x,否则同一张图两次跑出来的文本顺序可能不同,后面做比对会以为识别不稳定。ROI 裁剪时注意坐标映射:裁的是原图左上角(x, y),识别出的角点是 ROI 内坐标,画回原图要加回偏移。
4.3 镜像、旋转与低对比度:预处理该放在哪一层
预处理放在灰度之后、构造ImageView之前,顺序是“灰度 → 增强 → 视图”。镜像用cv::flip,旋转用cv::rotate或仿射变换,低对比度用 CLAHE。CLAHE 对光照不均的图有效,但参数太猛会把二维码的模块边缘也拉花,导致定位失败。建议先小步试:clipLimit从 2.0 开始,tileGridSize用 8×8,跑失败样本看召回变化。
cv::Mat enhanceForQr(const cv::Mat& gray) { cv::Mat out; cv::Ptr<cv::CLAHE> clahe = cv::createCLAHE(2.0, cv::Size(8, 8)); clahe->apply(gray, out); return out; } // 镜像单独处理:zxing 的 tryInvert 管反色,不管左右翻转 cv::Mat mirror(const cv::Mat& bgr) { cv::Mat out; cv::flip(bgr, out, 1); return out; }tryInvert处理的是黑白反色,不是镜像;左右翻转的二维码要自己flip后再识别。旋转也别只依赖tryRotate,大角度倾斜先用cv::minAreaRect或霍夫线估角度再矫正,代价是代码复杂度上升。实际工程里我一般先跑原图,失败再跑镜像、再跑 90 度旋转、最后才上 CLAHE,按失败样本决定顺序,别每帧都全跑一遍。
4.4 用定位点做二次校验:把误识别压下去
二维码解码结果偶尔会出现“文本像对的,但位置不对”的误识别,尤其是纹理复杂的图。用四个角点做二次校验能压掉一部分:算多边形面积、算长宽比、检查角点是否在图像范围内。二维码是正方形,透视变形后长宽比不会太离谱;面积太小的结果直接丢,减少噪声。
bool plausible(const QrHit& hit, const cv::Size& imageSize) { if (hit.corners.size() != 4) { return false; } const double area = std::abs(cv::contourArea(hit.corners)); if (area < 20.0 * 20.0) { return false; } cv::Rect box = cv::boundingRect(hit.corners); box &= cv::Rect(0, 0, imageSize.width, imageSize.height); if (box.width <= 0 || box.height <= 0) { return false; } const double ratio = static_cast<double>(box.width) / box.height; return ratio > 0.5 && ratio < 2.0; }contourArea对四个点的顺序有要求,顺序乱了面积可能变成负值或偏差很大,所以取绝对值。boundingRect之后再和图像范围求交,能过滤掉角点跑到图外的结果。长宽比阈值 0.5 到 2.0 是宽松范围,透视很夸张的图也能过;如果你的场景全是正对拍摄,可以收紧到 0.8 到 1.2,误识别会更少。这个校验不是万能的,但它比“只看文本长度”靠谱。
5. 避坑与排查:五个现场翻车记录
二维码识别这类工程,算法本身往往不是最耗时的,排查输入和内存模型才是。下面五条是实际项目里反复出现的翻车记录,每条按“现象 → 原因 → 解决”拆开。你拿到这份 zxing C++ 加 OpenCV 接口的源码包后,建议先把这五条对照一遍,再改业务代码。
5.1 现象:编译通过,运行几帧后段错误
原因通常不是 zxing 崩了,而是ImageView指向的cv::Mat已经释放。常见写法是在一个函数里创建gray,返回ImageView给外面用;函数一返回,gray析构,view还在,解码时读已释放内存。另一种情况是cv::Mat浅拷贝后原图被release(),引用计数归零,数据没了。
解决方式是让ImageView的生命周期严格短于它指向的Mat。不要把ImageView作为返回值跨作用域传,识别逻辑和灰度图放在同一个函数里;如果确实要拆函数,就让调用方持有gray,把ImageView作为参数传进去,别在函数内部造临时图再返回视图。排查时可以在构造ImageView前后打印gray.data和gray.refcount,看数据是不是已经被释放。
5.2 现象:OpenCV 的检测器能框出来,zxing 就是解不出
先看通道。把IMREAD_COLOR读出来的三通道Mat直接标成ImageFormat::Lum,解码器会按单通道解释内存,等于把 BGR 字节流当灰度像素用,画面是错位的,识别率自然低。另一个常见原因是rowStride传了gray.cols,但gray是 ROI 非连续内存,实际行跨度是gray.step,两者不相等时每一行都错位。
解决方式是统一走cvtColor转灰度,检查gray.channels() == 1,再传static_cast<int>(gray.step)。如果图是从视频流里裁的 ROI,先clone()成连续内存再构造视图,别省这一步。排查时可以把gray存成 PNG 看一眼,如果保存出来的图是花屏,说明通道或 stride 已经错了,继续调参没有意义。
5.3 现象:中文内容乱码或被截断
二维码里的中文乱码,八成不是 zxing 解错,而是生成端和读取端的编码不一致。生成端用本地多字节编码写入内容,读取端按 UTF-8 落盘,就会出现乱码;截断则常见于把std::string当 C 字符串处理,遇到\0提前结束。zxing 返回的text()是std::string,长度以size()为准,不要用strlen。
解决方式是生成端统一用 UTF-8 编码写入二维码内容,读取端也按 UTF-8 输出;落盘时用二进制模式或明确 UTF-8,不要经过本地代码页转换。如果历史数据已经用了别的编码,读取后按已知编码转一次,再统一成 UTF-8,别在识别层做猜测。排查时打印text().size()和十六进制前几个字节,能快速判断是编码问题还是截断问题。
5.4 现象:批量跑几分钟后内存暴涨
批量识别时,每张图都imread出bgr,又clone出gray,如果这些Mat被塞进容器或缓存,引用计数不降,内存就会一直涨。另一种情况是日志里保存了整张图的cv::Mat副本,跑几千张后进程被拖死。ImageView本身不拷贝数据,但clone()和imread会。
解决方式是让每张图的Mat在循环作用域内自然析构,不要跨轮次缓存;需要保留失败样本时,只存文件路径和识别结果,不要存整张图。批量任务可以每处理 N
本文还有配套的精品资源,点击获取