简介:面向机器视觉初学者与C++桌面开发者的(halcon+mfc)模板匹配demo,聚焦在Windows界面中完成基于形状、形状缩放与灰度的模板匹配验证。工程把Halcon图像处理能力嵌入MFC对话框,提供图像加载、匹配模式选择、参数设置、执行匹配及位置与相似度结果展示等交互,适合学习工业视觉定位、目标识别与GUI集成的基础流程。压缩包共27个文件,以8个h和6个cpp源码为主,配合ico、rc等界面资源,vcxproj、sln等VS工程配置,以及jpg预览图、exe可执行程序和说明文档,rar整体约11.45MB。从目录模块看,参数设置、形状操作、结果封装与主对话框逻辑划分较清晰,便于对照编译、调试和二次修改。已有1246人学习,可作为Halcon与MFC联合开发模板匹配功能的入门参考。
1. 一个能双击运行的 exe:这个 Halcon+MFC 模板匹配 demo 到底解决什么问题
某五金件产线上,工件在传送带上随机偏转,角度大概在正负十五度之间晃,机械手要过去抓。算法同事在 HDevelop 里半小时就调通了,create_shape_model建模板、find_shape_model拿 Row/Column/Angle 一气呵成。可真要交付到工位,操作工不可能打开 HDevelop 点运行。问题于是从“怎么匹配”变成“怎么把匹配包成一个能双击的 exe”——这正是 Halcon MFC 模板匹配 demo 的位置。
它做的事很具体:把形状匹配的建模、查找、结果显示、参数调节塞进一个 MFC 对话框程序里,对外输出一个四元组(Row、Column、Angle、Score),再把匹配到的轮廓叠画在图上。适合做课设毕设的、刚从脚本过渡到上位机的、以及要给现场交付独立程序的人;如果你只做纯算法研究或者习惯 Python 调库,这套东西对你价值不大。
2. 工程骨架:把 Halcon 算子塞进 MFC 的三条路与一次编译配置
2.1 HDevEngine、导出 C++、混合调用,先选一条
这一步选错了,后面全是返工。三种接法我都在项目里用过,差别不在性能,在“谁改算法”这件事上。
| 接法 | 做法 | 优点 | 代价 | 适合 |
|---|---|---|---|---|
| HDevEngine | 运行时加载 .hdev,用HDevEngine执行过程 | 算法同事改脚本不用重编 exe | 多带一个引擎 dll;报错是脚本行号;每次调用有解析开销 | 算法还在频繁迭代、现场要临时调参 |
| 导出 C++ | HDevelop 里“文件→导出 C++”,生成 .cpp | 执行最快,无额外依赖 | 改一行脚本就要重新编译整个工程 | 算法已经冻结、追求稳定交付 |
| 混合 | 建模和调参走脚本,查找和结果显示走 C++ | 兼顾两边 | 接口要自己设计,状态要管好 | 我一般会选这个 |
Demo 这种体量,直接导出 C++ 最省事,因为模板匹配的调用链很短,没必要为它背一个脚本引擎。但如果你要做“现场工程师拿着 hdev 自己调对比度”的功能,那就得走 HDevEngine,并且把脚本里的过程名、输入输出参数用SetProcedure、SetInputIconicParamObject这类接口约定死,否则脚本一改接口,exe 直接报找不到过程。
2.2 VS 工程配置:位数、运行时库、头文件顺序
Halcon 的库和你的工程必须位数一致,这一条没有例外。新版本基本只提供 64 位,所以 MFC 工程从新建那一刻就该选 x64,别等编译报一堆LNK2019再回头改。
属性页里要落的东西:
| 位置 | 项 | 值 |
|---|---|---|
| C/C++ → 常规 | 附加包含目录 | $(HALCONROOT)\include、$(HALCONROOT)\include\halconcpp |
| 链接器 → 常规 | 附加库目录 | $(HALCONROOT)\lib\$(HALCONARCH) |
| 链接器 → 输入 | 附加依赖项 | halconcpp.lib |
| C/C++ → 代码生成 | 运行时库 | 与 Halcon 保持一致,一般/MD(Release)、/MDd(Debug) |
| MFC 使用方式 | — | 在共享 DLL 中使用 MFC |
HALCONROOT和HALCONARCH这两个环境变量是安装时写进系统的,用$(HALCONROOT)引用比自己写死路径靠谱,换机器不用改工程。
头文件包含顺序上有一个老坑,windows.h里的min/max宏会和标准库打架,而 MFC 又必然带进windows.h:
// pch.h(或 stdafx.h)里,包含顺序不要随意调换 #define NOMINMAX // 挡掉 windows.h 的 min/max 宏 #include <windows.h> #include <atlconv.h> // 后面要用 CT2A 做编码转换 #include <halconcpp/HalconCpp.h> // 放在 windows.h 之后,避免类型重定义 using namespace HalconCpp; // 只在 cpp 里用,别写进头文件污染全局NOMINMAX不加,std::min之类的调用会莫名其妙编译不过;using namespace写进头文件,则会让所有包含它的 cpp 都被拖进 Halcon 的命名空间,后面和 MFC 的CRect、CPoint撞名是迟早的事。
部署时别忘了把运行时 dll 拷到 exe 同级目录,halcon.dll、halconcpp.dll是必须的,走 HDevEngine 还要加引擎那个 dll。我在项目里放了个拷贝脚本,打包前一键执行:
# 打包前把运行时 dll 拉到输出目录,避免现场机器装没装 Halcon 的玄学问题 OUT="bin/Release" HALCON_BIN="$HALCONROOT/bin/$HALCONARCH" cp "$HALCON_BIN/halcon.dll" "$OUT/" cp "$HALCON_BIN/halconcpp.dll" "$OUT/" # 只有用 HDevEngine 才需要下面这两个 # cp "$HALCON_BIN/hdevenginecpp.dll" "$OUT/" # cp "$HALCON_BIN/hdevengine.dll" "$OUT/"脚本本身没什么技术含量,价值在于把“哪些 dll 是必需的”这件事固化下来。现场机器往往装的是运行版而不是开发版,多拷一个 dll 顶多占几兆,少拷一个就是启动即崩。
2.3 线程模型:UI 线程只负责画,算子跑在工作线程
MFC 的死穴是 UI 线程不能阻塞超过几十毫秒,而一次find_shape_model在高分辨率图、多角度、多层金字塔的情况下跑到几十甚至上百毫秒很常见。连续采图时直接在OnBnClickedStart里循环调用,界面必卡,任务管理器里那个“未响应”的标题就是血泪经验。
更麻烦的是 Halcon 窗口有线程亲和性:OpenWindow在哪个线程开的,就只能在哪个线程操作它。跨线程去DispObj,轻则不显示,重则直接崩。
我一般这么切:
// 自定义消息:工作线程算完,把结果丢回 UI 线程去显示 #define WM_MATCH_RESULT (WM_USER + 100) UINT CMatchDemoDlg::MatchThreadProc(LPVOID pParam) { CMatchDemoDlg* pDlg = static_cast<CMatchDemoDlg*>(pParam); HObject ho_Image; HTuple hv_Row, hv_Column, hv_Angle, hv_Score; while (pDlg->m_bRunning) { try { // 1. 取图:这里换成你的相机采集或文件读取 ReadImage(&ho_Image, HTuple("data/part_01.png")); // 2. 匹配:hv_ModelID 在 UI 线程建好后传进来,建一次用一辈子 FindShapeModel(ho_Image, pDlg->m_hvModelID, HTuple(-15).TupleRad(), HTuple(30).TupleRad(), 0.6, 1, 0.5, "least_squares", 0, 0.8, &hv_Row, &hv_Column, &hv_Angle, &hv_Score); // 3. 只传结果和图像句柄,不碰任何窗口 pDlg->m_hoLastImage = ho_Image; // HObject 是引用计数,赋值是安全的 pDlg->PostMessage(WM_MATCH_RESULT, 0, 0); } catch (HException& ex) { // Halcon 异常必须接住,否则线程直接终止 pDlg->PostMessage(WM_MATCH_RESULT, 1, 0); } Sleep(10); } return 0; }这段的关键点有三个:FindShapeModel的NumLevels传 0 表示用建模时定好的层数,不要在这里另设一个值;SubPixel选least_squares比interpolation稳,代价是慢一点点;HObject的赋值是浅拷贝加引用计数,所以把图像存到对话框成员上不会带来一次完整的内存拷贝,但也意味着你在工作线程里对同一个ho_Image再ReadImage一次,主线程正在显示的那张会跟着变——想要每帧独立,就CopyObj一份。
UI 线程那边只做三件事:接消息、DispObj、画十字和矩形框。这样即使匹配跑到 200 毫秒,界面拖动、缩放依然跟手。
3. 模板训练与查找参数:八个建模参数和四个查找参数怎么定
3.1create_shape_model的参数,一个个说清楚
形状匹配的建模参数直接决定“能不能找到”,查找参数只决定“找得多准”。顺序别搞反。
| 参数 | 含义 | 常用取值 | 调错的表现 |
|---|---|---|---|
NumLevels | 金字塔层数 | "auto"或 0~6 | 太大丢小特征,太小慢 |
AngleStart | 起始角(弧度) | 按工件实际偏转定 | 范围不够直接找不到 |
AngleExtent | 角度跨度(弧度) | 同上 | 同上 |
AngleStep | 角度步长(弧度) | "auto"或按半径算 | 太粗定位抖,太细建模慢 |
Optimization | 模板点优化方式 | "auto"、"point_reduction_high" | 高优化会牺牲一点点精度 |
Metric | 极性 | "use_polarity" | 光照反了就全丢 |
Contrast | 边缘对比度阈值 | "auto"或 20~60 | 太高模板点太少 |
MinContrast | 查找时最低对比度 | 建模值的 1/2~1/3 | 太高现场找不到 |
Metric这个参数是最容易被忽略的。默认use_polarity要求待查图像里边缘的明暗方向和模板一致;如果现场打光方式变了,或者工件翻面,边缘极性反了,score 会直接掉到 0.1 以下,你还以为是角度范围设小了。另一种是ignore_local_polarity,允许局部极性变化,鲁棒性更好但误匹配率上升,我在有反光铝件上用过,得配合提高MinScore一起用。
// 建模:先框 ROI,再做域缩减,最后建形状模板 HObject ho_ModelRegion, ho_ModelImage; GenRectangle1(&ho_ModelRegion, 120.0, 200.0, 380.0, 460.0); // 行1,列1,行2,列2 ReduceDomain(ho_Image, ho_ModelRegion, &ho_ModelImage); // 只在 ROI 内取边缘 HTuple hv_ModelID; CreateShapeModel(ho_ModelImage, HTuple(0), // NumLevels=0,让 Halcon 自己定 HTuple(-15).TupleRad(), // AngleStart,注意单位是弧度 HTuple(30).TupleRad(), // AngleExtent,覆盖 -15°~+15° HTuple("auto"), // AngleStep HTuple("auto"), // Optimization HTuple("use_polarity"), // Metric HTuple("auto"), // Contrast HTuple(25), // MinContrast,现场光照波动大就调小 &hv_ModelID);ReduceDomain这一步不能省。整幅图去建模板,背景里的传送带纹路、料盘边缘全会被当成特征点,模板点数暴涨,建模慢、查找更慢,而且容易在背景相似的地方误匹配。ROI 尽量贴着工件外轮廓留 5~10 像素余量。
MinContrast单列出来说,是因为它决定了“现场能不能找到”。建模时的Contrast是按当前这张图的对比度自动算的,换一批料、换一个班次的光照,对比度可能掉三成。这时候MinContrast如果还等于建模值,那些边缘就被过滤掉,模板点剩不下几个。我一般把它压到Contrast的三分之一左右,宁可多留点噪声点,也别让模板变成空壳。
3.2 金字塔层数和角度步长:不要让"auto"替你做决定
"auto"在大多数场景下是合理的,但它有个隐含前提——你的模板 ROI 大小适中。ROI 特别大(比如 800×600 的整块钣金)时,auto给出的层数会偏高,最顶层只剩几十个点,粗定位直接飘;ROI 特别小(比如 30×30 的字符),层数又会偏低,查找慢得让人怀疑人生。
角度步长有个可以手算的经验公式:模板上离中心最远的边缘点,半径记作 r,旋转 Δθ 后这个点的位移约等于 r·Δθ。要求这个位移不超过 1 个像素,就得到 Δθ ≤ 1/r(弧度)。半径 150 像素的模板,Δθ 差不多 0.0067 弧度,也就是 0.38 度。
// 由模板 ROI 的等效半径反推角度步长,比 "auto" 更贴合实际尺寸 double w = 260.0, h = 160.0; // ROI 宽高,像素 double r = 0.5 * sqrt(w * w + h * h); // 最远边缘点半径 double stepRad = 1.0 / r; // 旋转位移不超过 1 像素 double stepDeg = stepRad * 180.0 / 3.14159265; stepDeg = stepDeg * 0.7; // 留 30% 余量 stepDeg = max(0.1, min(1.0, stepDeg)); // 夹到工程可用区间 HTuple hv_AngleStep = HTuple(stepDeg).TupleRad();算完之后可以反过来验证:把AngleStep设成这个值建一次模板,再用同一张图在角度范围的边界处(比如 +14.5 度)去查,score 应该还在 0.8 以上。如果掉得厉害,说明步长还是粗了。这个“边界自测”是我每次建完模板必做的一步,比看任何文档都直观。
层数这边,我的习惯是建完模板后打印一下每层的点数:
// 检查金字塔各层剩下多少模板点,顶层太少就手动降层数 HTuple hv_NumLevels, hv_Points; GetShapeModelContours(&ho_ModelContours, hv_ModelID, 1); // 取第 1 层轮廓 // 更直接的办法:重建一次,显式指定层数,对比匹配耗时和稳定性顶层点数低于 20 个,粗定位基本靠运气;这时候要么把NumLevels手动减 1~2 层,要么扩大 ROI 让特征更丰富。别嫌麻烦,这一步省下的时间会在现场调试时十倍还回来。
3.3find_shape_model:分数、重叠和贪心度
查找参数里真正需要动的是四个:MinScore、NumMatches、MaxOverlap、Greediness。
MinScore是门槛,低于它的候选直接丢弃。设太高,光照一变就报“未找到”;设太低,背景里随便一块纹理都能凑到 0.4。经验值是 0.6~0.7 起步,等批量验证过 score 分布之后再收紧。
MaxOverlap处理叠件。工件挨在一起时,两个匹配结果可能重叠 80% 以上,其实是同一个目标被重复报了。设成 0.3~0.5 能有效去重,但设太小会把真正紧挨着的两个工件误杀。
Greediness是搜索的“贪心程度”,0 最彻底最慢,1 最快但可能漏检,默认 0.9。精密对位场景我一般降到 0.7~0.8,多花 20% 时间换稳定性,值。
// 查找并解析结果,注意返回的是四个 HTuple,长度等于匹配个数 HTuple hv_Row, hv_Column, hv_Angle, hv_Score; FindShapeModel(ho_Image, hv_ModelID, HTuple(-15).TupleRad(), HTuple(30).TupleRad(), HTuple(0.6), // MinScore HTuple(3), // NumMatches,最多找 3 个 HTuple(0.4), // MaxOverlap,去掉重叠候选 HTuple("least_squares"), // SubPixel,亚像素精度 HTuple(0), // NumLevels=0,沿用建模层数 HTuple(0.8), // Greediness &hv_Row, &hv_Column, &hv_Angle, &hv_Score); // 结果按 Score 降序排列,取第 0 个就是最优 for (int i = 0; i < hv_Score.Length(); ++i) { double row = hv_Row[i].D(); // Row 是行坐标,也就是 Y double col = hv_Column[i].D(); // Column 是列坐标,也就是 X double ang = hv_Angle[i].D(); // 弧度,顺时针为正 double scr = hv_Score[i].D(); // 这里做你的坐标转换、发给 PLC }亚像素模式选least_squares,它在初步定位后用最小二乘拟合边缘,重复精度能到 0.02 像素级别;interpolation快一些但精度差一档;none只在极端追求速度时用。这个参数和Greediness是速度与精度这组矛盾的两个旋钮,别同时往快的那头拧。
4. 显示与坐标:从 HObject 到 Picture Control 的像素对齐
4.1 把 Halcon 窗口嵌进控件,父句柄别传错
Halcon 的窗口本质上是个子窗口,OpenWindow的第五个参数就是父窗口句柄。传对话框的m_hWnd,图像会盖住所有控件;传 Picture Control 的句柄,图像才会老老实实待在框里。
void CMatchDemoDlg::OpenHalconWindow() { // 重开前必须先关,否则每切换一次分辨率就漏一个窗口句柄 if (m_hvWindowHandle.Length() > 0) { CloseWindow(m_hvWindowHandle); m_hvWindowHandle = HTuple(); } CWnd* pCtrl = GetDlgItem(IDC_PIC_DISPLAY); CRect rc; pCtrl->GetClientRect(&rc); OpenWindow(0, 0, rc.Width(), rc.Height(), pCtrl->GetSafeHwnd(), // 父句柄,只认控件,不认对话框 "visible", "", &m_hvWindowHandle); SetWindowAttr("flush", "false"); // 批量绘制时先不刷,最后统一 FlushBuffer }flush设成false是个明显的性能开关,尤其在一帧里要画几十个轮廓的时候。设完之后记得在绘制结尾调一次FlushBuffer(m_hvWindowHandle),否则画面不动,你会以为程序死了。
窗口大小变化时要重开,或者用SetWindowExtents调整。重开更干脆,但必须先把旧窗口关掉,这一条我吃过亏——早期版本里每次OnSize都OpenWindow一次而不关旧的,跑了一下午,任务管理器里 GDI 对象数一路涨到几万,最后整个界面变成一片白。
4.2 三套坐标:图像坐标、控件坐标、屏幕坐标
这是新手最容易翻车的地方,也是我见过最多“匹配明明对了但框画偏了”的原因。三套坐标的换算方向必须记死:
- Halcon 的
Row对应屏幕上的 Y(竖直向下),Column对应 X(水平向右)。写代码时row传给了 X 参数,结果就是整个框顺时针转了 90 度。 - 控件坐标的原点在控件左上角,MFC 的
OnLButtonDown给的point是相对对话框的,得先ScreenToClient。 - 图像坐标和控件坐标之间隔着一个
SetPart,缩放比例和偏移都在里面。
void CMatchDemoDlg::OnLButtonDown(UINT nFlags, CPoint point) { // MFC 给的 point 是相对对话框客户区的,先转到显示控件坐标系 CPoint pt = point; m_wndDisplay.ScreenToClient(&pt); // 取出当前显示区域对应的图像范围 HTuple hv_Row1, hv_Column1, hv_Row2, hv_Column2; GetPart(m_hvWindowHandle, &hv_Row1, &hv_Column1, &hv_Row2, &hv_Column2); CRect rc; m_wndDisplay.GetClientRect(&rc); // 控件坐标 -> 图像坐标,线性映射,别在这里加任何经验偏移 double imgRow = hv_Row1.D() + (hv_Row2.D() - hv_Row1.D()) * pt.y / rc.Height(); double imgCol = hv_Column1.D() + (hv_Column2.D() - hv_Column1.D()) * pt.x / rc.Width(); // 记下来给 ROI 用 m_dClickRow = imgRow; m_dClickCol = imgCol; }另一种写法是直接用GetMposition(m_hvWindowHandle, &row, &col, "window"),让 Halcon 自己算。它更省事,但有个前提:调用它的线程必须是开窗的那个线程,工作线程里调会返回一堆 0 或者抛异常。所以我在跨线程的场合一律手算,宁可多写三行。
还有一个隐蔽的坑:SetPart传的参数顺序是(Row1, Column1, Row2, Column2),而GenRectangle1的顺序是(Row1, Column1, Row2, Column2),看着一致,但很多人顺手把Column2写成了Row2,结果显示区域变成一个细长条。这种错误编译器不会报,只能靠肉眼发现。
4.3 句柄和内存的生命周期,别指望 GC
Halcon 的HObject内部是引用计数,理论上离开作用域就释放。但有两类东西不在此列:一是窗口句柄,OpenWindow出来的必须CloseWindow;二是HTuple里塞的数组,如果在一个循环里不停TupleConcat,内存会一直涨。
我的做法是给图像加一个作用域守卫,避免在异常路径上漏掉释放:
// HObject 的作用域守卫:构造时给个空对象,析构时强制 ClearObj class HObjScope { public: HObjScope() { m_obj.GenEmptyObj(); } ~HObjScope() { m_obj.ClearObj(); } // 引用计数归零,底层内存立即回收 HObject* operator&() { return &m_obj; } // 支持 ReadImage(&scope, ...) 这种写法 HObject& Get() { return m_obj; } private: HObjScope(const HObjScope&); // 禁止拷贝,防止引用计数被意外加一 HObjScope& operator=(const HObjScope&); HObject m_obj; };ClearObj和让它自然析构的区别在于时机:自然析构要等栈展开,异常路径上如果被catch吞掉又没重新抛出,对象可能活到下一轮循环。显式清一下,内存曲线会平得多。至于查看内存,任务管理器不够用,我一般挂个性能计数器看私有字节,跑两百帧不回落就是有泄漏。
5. 避坑与排查:模板匹配在 MFC 里翻车的五种现场
5.1 Debug 能跑,Release 一启动就崩
现象:Debug 下匹配正常,切到 Release 双击 exe,要么闪退,要么ReadImage返回空、后面一路空指针。
原因:九成是运行时库不匹配。工程用/MT静态链接,而halconcpp.lib是按/MD编的,两套 CRT 同时在进程里,堆的分配和释放跨了边界,HObject析构时就会踩到别人的堆。
解决:C/C++ → 代码生成 → 运行时库,Debug 用/MDd、Release 用/MD,和 Halcon 保持一致。改完记得全量重编,只做增量编译的话旧的 obj 还带着老设置。
5.2 换個角度就找不到,score 掉到 0.3 以下
现象:工件摆正的时候 score 0.9 稳如老狗,一转到十几度就报未找到,或者匹配到一个明显错误的位置。
原因:三个可能,按概率排——AngleExtent没覆盖实际偏转范围;AngleStep太粗,边界角度上模板和实际错开了半个像素以上;MinContrast设太高,转到侧面时边缘对比度下降,模板点被大量过滤。
解决:先把AngleStart/AngleExtent放宽到实际范围的两倍做一次验证,能稳定找到再收窄;再按 3.2 的公式重算AngleStep;最后把MinContrast降到建模Contrast的三分之一试试。三步做完还是不行,那就是模板本身特征不足,得换 ROI 或者加辅助特征。
5.3 界面卡死,匹配框画在了按钮上
现象:点开始之后界面卡住,几秒后图像出来,但画在了对话框左上角,盖住了标题栏和按钮。
原因:算子在 UI 线程里同步跑,消息泵被堵住;同时OpenWindow的父句柄传成了对话框的m_hWnd而不是 Picture Control 的句柄。
解决:算子挪到工作线程(见 2.3),结果通过PostMessage回主线程绘制;父句柄改成控件句柄。改父句柄之后要检查OnSize,控件尺寸变了窗口得跟着调,否则图像会被裁掉一块。
5.4 跑一两个小时内存持续上涨
现象:刚启动时几十兆,跑一下午涨到一两个 G,最后弹“内存不足”。
原因:OpenWindow之后没CloseWindow(每次重开分辨率或切图都漏一个);循环里ReadImage出来的HObject挂在成员变量上没清;HTuple在循环里做TupleConcat累积。
解决:所有窗口操作成对出现,写一个ResetWindow()统一管;图像用 4.3 的守卫;HTuple在循环开头hv_Result = HTuple()重置。挂性能计数器观察私有字节,跑两百帧不回落就是还有漏。
5.5 中文路径的图读不进来
现象:ReadImage抛异常说文件不存在,但资源管理器里明明有;或者路径里的中文变成一堆问号。
原因:MFC 工程默认 Unicode,HTuple接受const char*时按 ANSI 解释,宽字符路径直接传进去就是乱码。
解决:所有传给 Halcon 的字符串都过一遍转换:
CString strPath = _T("D:\\图像\\part_01.png"); HTuple hv_File(CT2A(strPath)); // 转成窄字符,需要 atlconv.h ReadImage(&ho_Image, hv_File);更彻底的办法是工程目录和文件名全用英文,但现场拷贝过来的图往往不听话,转换这一步别省。
6. 交付前的一次压测:用五十张图验证稳定性与节拍
模板匹配调通不等于能交付。我踩过最疼的一次,是现场跑了两小时才开始偶发漏检——光照随时间漂移,上午十点以后太阳斜射进车间,工件的对比度整体往下掉了三成,MinContrast那关就过不去了。从那以后,交付前必须跑一轮批量验证。
验证分三块,缺一不可:
| 验证项 | 做法 | 判据 |
|---|---|---|
| 覆盖率 | 五十张覆盖全角度范围的真实图逐张跑 | 漏检 0 张,误匹配 0 张 |
| 分数分布 | 统计所有 score 的最小值与均值 | 最小值 >MinScore× 1.3 |
| 重复性 | 同一张固定图连查 100 次 | Row/Column 标准差 < 0.05 像素 |
| 节拍 | 连续跑 500 次取均值 | 单帧耗时 < 产线节拍的 1/3 |
最后一项留三倍余量不是保守,是给现场的老工控机留的——开发机是 i7,产线上那台可能是十年前的双核。
批量脚本我一般直接写在 demo 里,加个隐藏的按钮,只在调试模式下显示:
void CMatchDemoDlg::RunBatchTest() { double minScore = 1.0, sumMs = 0.0; std::vector<double> vRow; int nOk = 0, nNg = 0; for (int i = 1; i <= 50; ++i) { CString strPath; strPath.Format(_T("data\\part_%02d.png"), i); // 图按序号命名,覆盖全角度 HTuple hv_File(CT2A(strPath)); HObject ho_Image; ReadImage(&ho_Image, hv_File); LARGE_INTEGER t0, t1, freq; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&t0); // 计时只包住 FindShapeModel HTuple hv_Row, hv_Column, hv_Angle, hv_Score; FindShapeModel(ho_Image, m_hvModelID, HTuple(-15).TupleRad(), HTuple(30).TupleRad(), 0.6, 1, 0.5, "least_squares", 0, 0.8, &hv_Row, &hv_Column, &hv_Angle, &hv_Score); QueryPerformanceCounter(&t1); sumMs += (t1.QuadPart - t0.QuadPart) * 1000.0 / freq.QuadPart; if (hv_Score.Length() > 0) { ++nOk; minScore = min(minScore, hv_Score[0].D()); vRow.push_back(hv_Row[0].D()); // 重复性用固定图另外跑 } else { ++nNg; } ho_Image.ClearObj(); } // 结果直接弹个 AfxMessageBox,或者写日志,别只在调试器里看 CString strMsg; strMsg.Format(_T("通过 %d / 失败 %d / 最低分 %.3f / 平均耗时 %.1f ms"), nOk, nNg, minScore, sumMs / 50.0); AfxMessageBox(strMsg); }计时的位置很讲究:QueryPerformanceCounter要把FindShapeModel单独包起来,别把取图和绘制也算进去,否则测出来的是整个流程的耗时,优化的时候找不到重点。hv_Score[0]取第一个是因为结果已经按分数降序,这里只关心最优的那个。
重复性那项要单独做:拿同一张图重复查 100 次,把Row、Column存进std::vector,算标准差。如果标准差跑到 0.3 像素以上,说明模板的边缘特征不够锐利,或者Greediness设太高导致每次走的搜索路径不同。这个指标比 score 更能反映系统的真实精度,因为 score 是相对的,重复性是绝对的。
还有个小技巧:把五十张图的 score 全部打到日志里,画个折线看一眼。如果是一条平线,说明模板很稳;如果中间有个 V 形谷,那几张图就是薄弱环节,单独拎出来分析光照或者遮挡,比现场出了问题再回头查高效得多。
我现在的习惯是,只要这个 demo 要交给别人用,不管对方说得多急,批量验证这一轮必须强制走完,日志文件跟 exe 一起打包。不是为了证明什么,是因为现场那台机器和那张图,永远比实验室里多一个你想不到的变量。希望帮到你。
本文还有配套的精品资源,点击获取