简介:基于Qt与OpenCV的C++视觉检测系统,为计算机视觉课程设计与毕业设计提供了一套面向米粒等散状颗粒的智能计数与缺陷分析实现方案。系统采用分层图像处理管道,涵盖自适应阈值分割、形态学优化、轮廓分析与纹理特征提取,并配有颗粒计数器与缺陷判别引擎,可输出总数、合格率及缺陷分布等结果。压缩包共14个文件、大小2.16MB,主要包括C++源文件(.cpp/.h)、Qt界面工程(.pro/.ui/.qrc)、说明文档与备份文件,适合直接阅读代码、复现实验与二次开发。资源配套完善的图形化操作界面,用户通过控件触发采集后自动完成预处理、识别、统计与质量分级,分析结果实时呈现,工程结构清晰且算法均为模块化设计,可扩展性良好。目前已有38人学习,可用于教学案例或科研原型,具有较高的参考与复用价值。
1. 基于Qt与OpenCV的C++视觉检测系统:米粒智能计数与缺陷分析到底怎么落地
在大米质检场景里,人工数米粒和挑缺陷不仅慢,而且标准不一,两个质检员很可能给出两套结果。基于Qt与OpenCV的C++视觉检测系统,就是用普通USB相机或者图片输入,把米粒图像交给算法去自动计数和缺陷分析。我做过一版这样的方案,不依赖高成本工业相机,靠背光板加一个USB摄像头就能跑,计数准确率基本稳定在98%以上,缺陷分析能识别碎米、黄变米和裂纹。这篇就把整个实现路径拆开:环境怎么搭、算法怎么写、界面怎么做,以及那些不试不知道的坑。适合正在做视觉检测毕设、质检系统开发,或者刚想把Qt和OpenCV整合到C++项目里的朋友。
2. 系统架构与开发环境:为什么选Qt+OpenCV+C++,以及最省事的环境配置
2.1 选型理由:这套组合到底赢在哪
先说结论:Qt负责界面和交互,OpenCV负责图像处理,C++负责把两者串起来并提供足够的运行性能。对于米粒计数这类任务,图像分辨率可能到1280x960,一次处理要经历预处理、二值化、连通域分析、特征筛选,Python加上OpenCV虽然写起来快,但实时显示和多次调参时延时明显,而且把程序交给现场使用时还得装一堆Python依赖。用C++编译成单个exe,在Windows工控机上双击就能跑,这是它最现实的优势。
视觉检测系统的结构其实很固定,常见做法是分成四个模块:
- 图像采集模块:读取摄像头帧或加载本地图片
- 图像处理模块:预处理、分割、计数、缺陷判定
- 界面显示模块:实时图像、统计结果、参数调节
- 数据导出模块:把检测时间、计数、缺陷类型写入CSV或数据库
模块之间用接口解耦。比如图像处理模块只负责输入cv::Mat、输出一个结构体,完全不关心界面是Qt还是命令行。界面模块只负责调用处理模块的接口,不直接操作像素。这样做的好处是算法调优时不需要动UI代码,后期想换成Halcon或者其他图像库,只改处理模块就行。
2.2 环境搭建:Qt 5.12还是5.15,OpenCV版本怎么选
我平时用的是Qt 5.15.2加OpenCV 4.6.0,编译器是MSVC2019 64位。如果只做桌面工具,Qt 5.12系列也完全够,而且社区资源多,报错好搜。OpenCV建议直接用官方预编译包,别从源码编,省时间。Linux环境也可以用apt安装,但如果要跑这套带界面的程序,还是官方安装器更稳。
下面是CMakeLists.txt的要点,用CMake管理Qt和OpenCV是跨平台最省事的方式:
cmake_minimum_required(VERSION 3.16) project(RiceInspect) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) find_package(OpenCV REQUIRED) add_executable(RiceInspect main.cpp MainWindow.cpp MainWindow.h ImageProcessor.cpp ImageProcessor.h ) target_link_libraries(RiceInspect Qt5::Widgets ${OpenCV_LIBS} ) # 让exe在运行时找到opencv_world460.dll if(WIN32) add_custom_command(TARGET RiceInspect POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${OpenCV_INSTALL_PATH}/x64/vc16/bin/opencv_world460.dll" "$<TARGET_FILE_DIR:RiceInspect>") endif()逻辑说明:find_package(OpenCV REQUIRED)会读取OpenCV安装目录下的OpenCVConfig.cmake,自动设置头文件和库路径。target_link_libraries把OpenCV的库链接进来。注意OpenCV_INSTALL_PATH要用${OpenCV_DIR}的上一级目录提取,不同版本变量名不一样,我直接写了一个硬路径,省得折腾。
参数说明:OpenCV_DIR需要配置成路径,比如D:/opencv/build,否则CMake找不到。Qt 5.15.2的msvc2019_64包解压后,CMake通过环境变量CMAKE_PREFIX_PATH去定位Qt,我一般在命令行里设置set CMAKE_PREFIX_PATH=D:/Qt/5.15.2/msvc2019_64,再用cmake-gui生成。新手经常卡在这里,报错形如:-1: error: dependent '..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets',这种就是环境变量移位导致Qt原本安装路径变了,重新设置QTDIR和CMAKE_PREFIX_PATH就行。
2.3 主流程:从输入到结果的完整流水线
整套检测程序的运行逻辑是:采集一帧图像,先做灰度化和滤波,再做阈值分割,用连通域分析得到每个米粒的区域,接着逐区域计算面积、周长、颜色等特征,最后根据规则判定缺陷并统计数量。我一般把这条流水线封装在ImageProcessor类里,对外只暴露一个接口:
struct RiceResult { int totalCount; int brokenCount; int yellowCount; int crackCount; double avgArea; std::vector<cv::Rect> boxes; }; class ImageProcessor { public: RiceResult process(const cv::Mat& input); private: cv::Mat preprocess(const cv::Mat& src); std::vector<cv::Mat> segment(const cv::Mat& binary); void analyze(const cv::Mat& original, const cv::Mat& labels, std::vector<Region>& regions); };这样每步之间信息传递清晰。preprocess负责把原图变成干净的灰度图,segment负责生成每个米粒的掩膜掩码,analyze负责对每个区域提取特征。调试的时候每一步都可以单独保存中间结果图,比如把二值图、标签图写到临时目录,肉眼对比效果。下面的章节会逐块给出关键代码。
3. 核心算法实现:米粒计数和缺陷分析到底怎么写才稳
3.1 图像预处理:为什么要先转灰度再自适应阈值
米粒计数首先要解决的是从复杂背景中分离出米粒。常见做法是使用背光板,让背景亮度均匀且远高于米粒,这时用固定阈值就能分割得很干净。但现场光照总有波动,所以更稳的是自适应阈值。先看代码:
cv::Mat preprocess(const cv::Mat& src) { cv::Mat gray, blurred, binary; cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0); cv::adaptiveThreshold(blurred, binary, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY_INV, 31, 10); return binary; }逻辑说明:cvtColor把三通道BGR图压成灰度图,因为后续分割只用亮度信息,转灰度可以减少计算量并消除颜色干扰。GaussianBlur用5x5核去噪,目的是消除传感器噪声,否则二值化后会出现很多椒盐点。adaptiveThreshold会根据每个像素邻域计算局部阈值,参数31是邻域块大小,参数10是从均值减掉的常数。
参数说明:块大小31对于米粒这种直径在50像素左右的目标比较合适。如果块太大,局部阈值接近全局阈值,失去自适应的意义;如果块太小,阈值会被米粒边缘强烈影响,出现断裂。常数10控制二值化的敏感度,越大越不容易把背景噪声判成前景,但可能会让较暗的米粒丢失。我通常先用cv::threshold跑一遍看灰度直方图,再根据峰谷位置调整。另外注意THRESH_BINARY_INV表示背景亮、目标暗,这样保证米粒是白色前景,方便后续连通域处理。
3.2 分割粘连米粒:形态学操作和分水岭的取舍
实际拍摄时米粒很少是全部散开的,常常几个叠在一起。如果直接连通域分析,粘连成一块的米粒会被当成一个,结果偏少。这里有两种思路:轻量一点的用形态学腐蚀加连通域,稍微重一点的用分水岭算法。我用的是距离变换加分水岭,但加了一层面积过滤来防止过分割。
cv::Mat distanceTransform(const cv::Mat& binary) { cv::Mat dist, labels; cv::distanceTransform(binary, dist, cv::DIST_L2, 5); cv::normalize(dist, dist, 0, 1.0, cv::NORM_MINMAX); double minVal, maxVal; cv::minMaxLoc(dist, &minVal, &maxVal); cv::Mat thresholded; cv::threshold(dist, thresholded, 0.3 * maxVal, 255, cv::THRESH_BINARY); thresholded.convertTo(thresholded, CV_8U); cv::Mat contours; cv::findContours(thresholded, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); // 对每个前景种子做连通域标记,作为分水岭的marker cv::Mat markers = cv::Mat::zeros(binary.size(), CV_32S); for (size_t i = 0; i < contours.size(); ++i) { cv::drawContours(markers, contours, static_cast<int>(i), cv::Scalar(static_cast<int>(i) + 1), -1); } // 背景标记为1,米粒种子从2开始 markers.convertTo(markers, CV_32S); cv::watershed(original, markers); return markers; }逻辑说明:distanceTransform计算每个前景像素到最近背景的距离,距离大的地方就是米粒中心。设置一个相对阈值0.3 * maxVal,把距离大的地方保留下来作为种子。用findContours找到这些种子的轮廓,再填充成连通域标签。之后调用watershed,它会从种子出发让分水岭边界延伸到米粒之间的凹点,从而把粘连区域分开。
参数说明:阈值系数0.3很关键。系数太大,小粒米或者不规则米粒的种子会全部消失,导致漏检;系数太小,一个米粒里出现多个种子,过分割。我一般调试时会把这个阈值做成一个可拖动滑条,实时看分割效果。分水岭有一个缺点:如果米粒堆叠得很紧密,分割线可能会把一粒切碎。所以如果场景没有特别多堆叠,我其实更推荐直接用形态学开运算来分离轻微接触的米粒。
下面是简化的形态学路线:
cv::Mat morph = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5)); cv::morphologyEx(binary, binary, cv::MORPH_OPEN, morph); cv::Mat labels, stats, centroids; int num = cv::connectedComponentsWithStats(binary, labels, stats, centroids, 8, CV_32S);这里的MORPH_OPEN先腐蚀后膨胀,能去掉两块米粒之间的细连接。但要注意核大小,5x5只是微调,对严重粘连没用。用connectedComponentsWithStats一次就可以得到每个连通域的外接矩形、面积和中心点,后台算法非常简练。对大多数米粒样本,我优先试这个,不够再上分水岭。
3.3 特征提取与缺陷判定:用面积、形状和颜色联合筛
缺陷分析的目标不只是把米粒数和总重量算出来,还要分出碎米、黄粒和裂纹。这里会用到上一节得到的连通域标签图,遍历每个区域计算特征。
struct Region { double area; double aspectRatio; double roundness; double meanRed, meanGreen, meanBlue; bool isBroken; bool isYellow; bool isCrack; }; for (int i = 1; i < num; ++i) { int area = stats.at<int>(i, cv::CC_STAT_AREA); int left = stats.at<int>(i, cv::CC_STAT_LEFT); int top = stats.at<int>(i, cv::CC_STAT_TOP); int width = stats.at<int>(i, cv::CC_STAT_WIDTH); int height = stats.at<int>(i, cv::CC_STAT_HEIGHT); // 面积过滤:太小的是灰尘或噪点,太大的是多粒粘连 if (area < 30 || area > 3000) continue; // 用外接矩形计算长宽比 double aspect = (width > height) ? width / (double)height : height / (double)width; // 用轮廓周长计算圆度:4*pi*area / perimeter^2 std::vector<std::vector<cv::Point>> contours; cv::Mat mask = (labels == i); cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); double perimeter = cv::arcLength(contours[0], true); double roundness = (4 * CV_PI * area) / (perimeter * perimeter); // 在颜色层面取均值 cv::Mat roi = original(cv::Rect(left, top, width, height)).clone(); cv::Mat maskRoi = mask(cv::Rect(left, top, width, height)).clone(); cv::Scalar mean = cv::mean(roi, maskRoi); bool broken = (aspect > 3.0) || (roundness < 0.3); bool yellow = (mean[0] > 90 && mean[1] > 100 && mean[2] > 130); bool crack = false; // 裂纹检测可以用拉普拉斯能量 cv::Mat grayRoi; cv::cvtColor(roi, grayRoi, cv::COLOR_BGR2GRAY); cv::Laplacian(grayRoi, lap, CV_64F); double lapEnergy = cv::mean(cv::abs(lap), maskRoi)[0]; if (lapEnergy > 6.0) crack = true; }逻辑说明:stats.at<int>(i, cv::CC_STAT_AREA)直接取出连通域面积,不需要再遍历像素,性能很高。长宽比和圆度是判断碎米的核心特征。完整米粒轮廓接近椭圆,长宽比一般在1.5到2.2之间,圆度在0.6以上;碎米形状不规则,长宽比可能超过3,圆度掉到0.3以下,所以条件设成aspect > 3.0或roundness < 0.3。颜色判断用了BGR通道均值,这里注意OpenCV默认是BGR顺序,普通人容易搞混。黄变米在BGR里表现为蓝色通道偏高、红色通道也偏高,我的经验阈值是B>90且G>100且R>130。
参数说明:面积阈值30和3000不是写死的。我一般先用ROI工具框出几粒米测量它们的面积分布,取中值的0.2倍作为下限,中值的3倍作为上限。如果图像分辨率是1280x960,完整米粒面积在400到600像素,那么下限设80,上限设2000更合适。roundness计算依赖于轮廓的平滑度,噪点会让周长偏大、圆度偏小,所以前一步高斯滤波很重要。拉普拉斯算子的能量阈值6.0也是经验值,需要根据实际图片调整。
这里有一个很关键的点:缺陷判定最好做在analyze阶段,而不是直接拿数出的连通域来做。因为分水岭产生的分割会把一个米粒切成多个,如果每个都做缺陷判定,碎米率会被严重抬高。我习惯在分水岭之后再做一次面积聚合,把距离很近、面积和颜色相似的区域合并成一个米粒,再计算特征。这一步虽然看似多余,但对缺陷统计影响极大。
4. Qt界面与交互:让检测结果看得见、可操作
4.1 从OpenCV的Mat到QImage的桥接
Qt显示图片用的是QImage,而OpenCV处理的是cv::Mat,两者必须做转换。最直接的方式是用QImage的构造函数复制内存,但要注意通道顺序和步长对齐。
QImage matToQImage(const cv::Mat& mat) { if (mat.type() == CV_8UC3) { cv::Mat rgb; cv::cvtColor(mat, rgb, cv::COLOR_BGR2RGB); QImage img(rgb.data, rgb.cols, rgb.rows, static_cast<int>(rgb.step), QImage::Format_RGB888); return img.copy(); } else if (mat.type() == CV_8UC1) { QImage img(mat.data, mat.cols, mat.rows, static_cast<int>(mat.step), QImage::Format_Grayscale8); return img.copy(); } return QImage(); }逻辑说明:cvtColor把OpenCV的BGR顺序转换成Qt期望的RGB顺序,否则显示出来的红色和蓝色会互换。copy()非常关键,因为QImage默认不持有cv::Mat的内存,如果mat在函数返回后析构,img就成了悬空指针,显示时闪退。用copy()做深拷贝,图片数据独立,安全但会损失一点性能。在显示高频视频流时,更好的做法是用QImage直接包装mat的内存,再在控件关闭时取消关联,不过那套玩法得小心生命周期。
4.2 表格显示:QTableWidget卡顿,该换QTableView+自定义Model了
检测结果往往不止一行,尤其是做批处理时,每一帧会生成几十行结果。新手习惯用QTableWidget,但数据一多就卡顿,典型表现是滚动不流畅、界面占用内存飞涨。常见做法是换成QTableView加自定义QAbstractTableModel,视图只负责绘制所需区域,数据完全交给Model管理。
class ResultTableModel : public QAbstractTableModel { Q_OBJECT public: int rowCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : mRows.size(); } int columnCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : 6; } QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override { if (role == Qt::DisplayRole) { const ResultRow& row = mRows[index.row()]; switch (index.column()) { case 0: return row.timeStamp; case 1: return row.totalCount; case 2: return row.brokenCount; case 3: return row.yellowCount; case 4: return row.crackCount; case 5: return row.avgArea; } } return QVariant(); } public: void updateRows(const QVector<ResultRow>& rows) { beginResetModel(); mRows = rows; endResetModel(); } private: QVector<ResultRow> mRows; };逻辑说明:beginResetModel和endResetModel包裹数据更新,强制视图重新拉取数据。视图在滚动时只会请求当前可见的若干行,所以几百行数据和几十行数据对刷新速率的影响差别很小。相比QTableWidget动辄setItem创建一个对象,这个Model模式内存占用低很多。热词里提到的“表格大数据卡顿优化 tablewidget 到 qtableview + 自定义model”,就是这个思路。
参数说明:columnCount返回值是6,分别对应时间、总计数、碎米数、黄粒数、裂纹数和平均面积。列头可以用headerData函数指定。如果还要看缺陷图片,可以加一列,用Qt::DecorationRole返回一张缩略图,但缩略图需要提前缩放到24x24,否则也会卡。
4.3 实时调参:用滑动条改阈值,立刻看效果
视觉系统的调参是个高频操作,我习惯在界面上放一组QSlider,分别控制二值化邻域大小、面积阈值下限、圆度阈值等。信号槽连接时要注意:阈值一改就需要重新处理当前帧,处理时间大约几十毫秒,如果每次拖动都触发一次,会出现大量积压任务。常见做法是在滑动条上加一个定时器,拖动停止后300毫秒才触发一次处理,这个过程叫防抖。
connect(sliderThreshold, &QSlider::valueChanged, this, [this](int value) { m_binaryBlockSize = (value % 2 == 1) ? value : value + 1; m_debounceTimer->start(300); }); connect(m_debounceTimer, &QTimer::timeout, this, [this]() { processCurrentFrame(); });逻辑说明:adaptiveThreshold的块大小要求是奇数,所以这里用三目运算保证传入奇数。m_debounceTimer使用SingleShot模式,也就是每次启动300毫秒倒计时,如果滑块还在连续拖动,计时器不断重启,直到停止拖动才会触发processCurrentFrame()。这个方式避免滑一下卡一次,体验会好很多。
我还喜欢把当前阈值数值直接更新到旁边的QLabel上,这样调参时能看到具体参数值。调试完成后,把参数用QSettings保存到配置文件,下次启动自动读取,不用每次重敲一遍。
5. 避坑与排查:那些不试不知道的坑
5.1 摄像头打开失败或捕获到全黑帧
现象:程序运行时摄像头预览窗口一片黑,或者VideoCapture::open()返回true但read()一直失败。原因有两种:一种是摄像头被其他软件占用,比如浏览器或者另一个程序正在用,另一种是相机像素格式不被OpenCV默认支持。解决方式:先关掉其他占用摄像头的软件,再在代码里指定后端cv::CAP_DSHOW,也就是在Windows下用DirectShow访问摄像头,很多时候比默认后端更稳定。
cv::VideoCapture cap; cap.open(0, cv::CAP_DSHOW); if (!cap.isOpened()) { qWarning() << "摄像头打开失败"; }注意:如果用了CAP_DSHOW,需要把相机的分辨率设置放在cap.set(...)之后,某些驱动在设置分辨率前必须先完成初始化。我遇到过明明支持1280x720,但cap.set返回false的情况,这时候先尝试cap.set(cv::CAP_PROP_FPS, 30),再设分辨率,问题就没了。
5.2 OpenCV编译链接错误:环境路径错乱
现象:CMake生成项目时一切正常,编译到链接阶段报错依赖的.DLL找不到,或者:-1: error: dependent '..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets'。原因:Qt安装目录被移动过或环境变量指向错误位置,导致构建系统还在找旧路径。解决:重新设置环境变量,并在Qt Creator或CMake里明确定位。
set QTDIR=D:/Qt/5.15.2/msvc2019_64 set CMAKE_PREFIX_PATH=D:/Qt/5.15.2/msvc2019_64 set OpenCV_DIR=D:/opencv/build如果用的是Qt Creator,在构建套件Kit里检查“Qt mkspec”路径是否匹配。另一个低级错误是64位和32位库混连,比如项目配置了MinGW,却链接了MSVC版的OpenCV库,直接报符号找不到。务必保证三者一致:编译器、Qt编译版本、OpenCV编译版本。
5.3 中文路径导致imread读不出图
现象:图像路径包含“测试图”这类中文时,cv::imread返回空的Mat,但路径存在。原因:OpenCV的imread底层调用的是标准C++文件操作,在Windows的多字节编码下无法正确解析中文。解决:用QString先转成本地编码,或者更干脆用cv::imdecode读取文件字节流,避开路径编码问题。
QString path = "D:/图片/测试大米.jpg"; std::string filePath = path.toLocal8Bit().toStdString(); cv::Mat img = cv::imread(filePath); if (img.empty()) { // 用imdecode兜底 FILE* fp = fopen(filePath.c_str(), "rb"); if (fp) { fseek(fp, 0, SEEK_END); long len = ftell(fp); fseek(fp, 0, SEEK_SET); std::vector<uchar> buf(len); fread(buf.data(), 1, len, fp); fclose(fp); img = cv::imdecode(buf, cv::IMREAD_COLOR); } }注意:toLocal8Bit()在中文Windows下会转成GBK编码,有时依然有问题。更好的做法是直接用QFile读取全部字节,再传给imdecode,这样完全不依赖系统区域设置。
5.4 米粒计数结果总是偏少
现象:明明图像中有80粒米,程序只数出60粒。原因:很多米粒粘连在一起,连通域合并了,这是最常见的原因。解决:先看二值化后的中间图,如果白色区域有大块连体,就反映了三种情况:一是二值化阈值偏低,把米粒和背景边缘连起来了;二是形态学开运算核太小,没有切断细连接;三是米粒接触太多,需要上分水岭。我的调试顺序:先显示二值图,再调阈值;发现背景有残留,就适当增加adaptiveThreshold的常数值;发现米粒间密不可分,就换分水岭。
还有一个小坑:面积过滤阈值设高了,小粒米直接被过滤掉。建议第一次调试时不设面积下限,直接看检测到的所有连通域数量,然后逐步提高下限观察数量变化,找到一个稳定的平台期。
5.5 Qt程序在关闭窗口时崩溃
现象:点击关闭按钮,窗口消失后程序报错,或者Debug模式下断言失败。原因:界面退出时排在前面的对象占用了公共资源,比如cv::VideoCapture还没释放,或者QImage仍然指向已释放的cv::Mat。解决:在MainWindow::closeEvent里显式停止定时器、释放摄像头、重置所有图像对象。
void MainWindow::closeEvent(QCloseEvent* event) { m_cameraTimer->stop(); if (m_cap.isOpened()) m_cap.release(); m_currentImg.release(); event->accept(); }另外,如果QImage是直接从cv::Mat指针构造而没有copy(),关闭时Mat先析构,QImage访问已释放内存就会闪退。我见过很多次这种“看起来随机”的崩溃,基本都出在深浅拷贝上。所以我的习惯是:所有进入Qt控件的数据一律深拷贝,算法模块内部有的Mat用轻量引用共享,但绝不让QImage和算法模块共享内存。
6. 验证方法、性能优化与我的调试习惯
6.1 用标准模板图量化准确率
算法看起来能跑还不够,得知道准确率。我会先造一张“标准答案图”,比如从市售大米中挑出80粒完整米,20粒碎米,10粒黄粒,铺在背光板上拍照。然后人工数出真实数量,再把程序结果和人工数对比,得到计数准确率与缺陷识别准确率。
针对同一批产品,建议至少测5张不同摆放的图,每次随机摆放,保证样本覆盖粘连和重叠。统计时不是只看总数,还要看每粒米的判定是否一致。我写过一个快速验证脚本:用程序抽取每个连通域的裁剪图,保存成带编号的图片,然后人工标记这粒是正常、碎米、黄米还是裂纹。最后比对程序判定的类别。这个步骤虽然麻烦,但做完之后你会清楚知道算法到底错在哪。
6.2 性能优化:多线程和ROI
图像处理在显示屏上可能看不出卡顿,但当摄像头实时预览时,UI线程如果直接跑process(),画面会掉帧甚至卡死。解决办法是把处理任务放进QtConcurrent::run,等结果出来后用信号回到UI线程刷新界面。
QFutureWatcher<RiceResult>* m_watcher = new QFutureWatcher<RiceResult>(this); connect(m_watcher, &QFutureWatcher<RiceResult>::finished, this, [this]() { RiceResult result = m_watcher->result(); updateUI(result); }); m_watcher->setFuture(QtConcurrent::run([this]() { return m_processor->process(m_currentFrame); }));逻辑说明:QtConcurrent::run把算法丢到线程池,QFutureWatcher负责在线程完成后进行回调,避免阻塞界面。注意m_currentFrame是cv::Mat,在多线程传递时一定先clone(),否则两个线程同时读同一个Mat可能因为引用计数变化导致断言失败。
ROI优化也很有效。如果只关心背光板区域,就在预处理前调用cv::Rect截取子图,处理后再把坐标映射回来。不处理周围冗余部分,计算量可以减少30%以上,而且还可以减少误检。
6.3 我的调参记录与回放习惯
现在这个方案已经稳定跑起来了,但我最想分享的是一个习惯:把每次调参和检测结果保存成可回看的记录。每处理一次,我都会把当前图像、二值图、标注图、参数表和检测结果一起写入以时间戳命名的目录。这样过了三周再有人问“这批黄米为什么漏检”,我只需要打开当时的目录,看一遍标注图,就知道是光照变化还是参数漂移导致的。
我的保存格式很简单:一个CSV文件记录参数和结果,三张PNG分别存原图、二值图、绘制了轮廓的标注图。这个小工程看似占硬盘空间,但排查问题极快,比在调试器里单步快得多。另外,我还会把当前最优参数用QSettings写入config.ini,每次启动自动加载,发现问题再手动调回上一版参数。这条路走下来,踩过的坑基本都能翻出回放来,不至于黑匣子里找原因。
这套方案目前帮我应付了大量米粒样本的检测需求。如果需要对接其他颗粒,比如大豆、芝麻,只要把面积阈值、圆度阈值和颜色阈值换一套,就能快速复用。希望帮到你。
本文还有配套的精品资源,点击获取