工业视觉项目里,模板匹配大概是被调用次数最多、也最容易被低估的一个算子。产线上相机一拍,你要知道料框里那个零件在什么位置、角度偏了多少,第一步往往就是把事先截好的一小块模板拿去大图里做一次全局搜索——这个动作就是模板匹配。用C++来做这件事的团队,通常不是不想用更省事的脚本语言,而是节拍卡在那儿:一条产线每秒钟要走十几个工件,单帧处理预算可能只有二三十毫秒,运行时还得跑在没有多余依赖的工控机上。我这些年在这个算子上反复折腾过很多次,从最早调库函数调到怀疑人生,到后来自己拿循环和积分图重写一遍做对比,踩的坑基本都集中在“用什么度量”“阈值怎么定”“多目标怎么去重”“旋转了怎么办”这四件事上。这篇东西就是把这些经验摊开讲,适合已经能跑通一个 Demo、但结果老是飘或者速度上不去的朋友看,也适合刚接手视觉模块、需要快速建立直觉的同学。
1. 先搞清楚模板匹配到底在解什么方程
1.1 本质是一次穷举式的相似度搜索
模板匹配的模型极其朴素:你有一张大小为 W×H 的场景图 I,一张大小为 w×h 的模板图 T,模板左上角在场景图上的每一个合法位置 (x, y) 都试一遍,算出这个位置下“模板覆盖的那块区域”和模板本身有多像,最后把最像的位置挑出来。合法位置的数量是 (W-w+1)×(H-h+1),所以输出结果矩阵的尺寸就固定是这么大,这一点很多人第一次用会懵——为什么result的行列数不是原图尺寸?答案就是它每一个格子对应的是一个“左上角候选位置”,而不是一个像素。
这个定义决定了模板匹配的全部性格:它没有语义,不认识“螺丝”“字符”“焊点”,它只认像素灰度或梯度构成的模式;它也没有尺度与旋转的概念,模板是 64×64,那它就只在 64×64 这个尺度、这个朝向下找。理解这两点,后面所有“为什么匹配不上”“为什么匹配出一堆”的问题,基本都能自己推出来。我见过不少同学一上来就指望它做 OCR,结果字体稍微变个粗细就全崩,然后开始怀疑库有 bug——其实是对它的能力边界没建立正确预期。
1.2 为什么这条路上 C++ 依然是主流选择
做模板匹配的计算量是真的不小。举个具体数字:1280×960 的图上找一个 64×64 的模板,候选位置大约 1217×897 ≈ 109 万个,每个位置做 4096 次乘加,总共约 45 亿次乘加运算。就算现代 CPU 有 SIMD 和缓存优化,这也是一笔实打实的开销。这类场景下 C++ 的优势不是“语言更快”这种笼统说法,而是三件很具体的事:一是能直接吃满向量化指令和多核,二是内存布局完全可控(连续存储、手动对齐、避免不必要的拷贝),三是能嵌进没有解释器环境的设备里跑。
再往深一层,工业现场很多需求是“算子 + 定制后处理”捆在一起的,比如匹配完立刻要做亚像素修正、要做多个结果的 NMS、要把坐标传给机械臂做手眼转换。这些逻辑用 C++ 写可以零成本地嵌进整条流水线,不用担心跨语言调用的序列化开销。当然如果你只是离线批处理几万张图,Python 加同一个底层库也很香,选型上不必教条。
1.3 它擅长什么,明确不擅长什么
擅长的事情很清晰:刚性、纹理稳定、光照可控的目标定位,比如 PCB 上的 Mark 点、零件上的定位孔、包装上的印刷图案、拼接图的配准块。这类场景模板固定、目标不形变,匹配分数会非常干净,0.9 以上一大片,闭着眼睛都能定阈值。
不擅长的事情同样清晰:目标有大角度旋转、有尺度变化、有非刚性形变、有严重遮挡、有强反光或阴影漂移。这些情况下你硬调阈值只会把自己逼疯——阈值低了误检爆炸,阈值高了漏检。正确的做法不是继续调参,而是换思路:要么做多模板(按角度和尺度各生成一份),要么改用基于形状/梯度的匹配,要么先把图做归一化预处理。我在一个反光很厉害的金属件项目上就吃过这个亏,最后解决靠的不是匹配算法,而是在相机端加了偏振片加同轴光,把成像质量提上去,算法那边的阈值瞬间就宽松了。
2. 六种匹配度量的数学差异,选错等于白干
2.1 平方差与纯相关:直觉上最好懂的两类
平方差类(SQDIFF)的思路最直白:把模板和候选区域逐像素相减,差的平方求和,值越小越像。它的优点是物理意义清楚、计算简单,缺点是对亮度整体偏移极其敏感——场景图整体比模板亮 20 个灰度级,平方差就会立刻难看,哪怕形状完全一致。所以在实际生产里,纯粹的 SQDIFF 很少直接拿来定阈值,更多用在两幅图已经做过亮度归一化的配准场景。
纯相关类(CCORR)是逐像素相乘再求和,本质是内积。它衡量的是“方向一致性”,值越大越像。CCORR 有一个很讨厌的性质:它会被高亮区域带偏。如果场景图里有一块大面积接近 255 的白色区域,模板放上去内积也会很大,结果就是亮的地方全都变成候选。我早年在白底文档图上做匹配,结果全堆在空白区域,查了半天才发现是这个原因。所以 CCORR 单独用基本不靠谱,必须归一化。
2.2 归一化相关系数为什么成了事实标准
TM_CCOEFF_NORMED是我在绝大多数项目里的默认选择,理由是它对线性光照变化具有不变性。它的做法是先分别减去模板和候选区域的均值,再做归一化内积。减去均值这一步,等价于把“整体加一个常数”的干扰消掉了;除以各自的标准差,等价于把“整体乘一个系数(对比度变化)”的干扰消掉了。所以只要场景亮度是a*I + b这种线性关系,匹配分数就基本不受影响——这在产线上非常实用,因为换批次、换光源老化、换镜头光圈,成像往往就近似这种线性偏移。
它的取值范围是 [-1, 1],1 表示完全正相关,-1 表示完全反相关(也就是黑白反转),0 表示毫无关系。这个可解释性带来的好处是阈值好定:经验上 0.8 以上基本可信,0.6 到 0.8 需要人工复核,0.5 以下基本是噪声。当然这只是起步经验,具体还得看你的纹理丰富程度,纹理越弱(比如纯色块)这个分数越容易虚高,因为分母小。
2.3 一张表把选型这件事定下来
| 方法 | 取值范围 | 最优值 | 抗亮度偏移 | 抗对比度变化 | 主要坑点 |
|---|---|---|---|---|---|
| TM_SQDIFF | [0, +∞) | 最小 | 差 | 差 | 阈值随图变化,难通用 |
| TM_SQDIFF_NORMED | [0, 1] | 最小 | 差 | 一般 | 归一化后数值压缩,区分度下降 |
| TM_CCORR | [0, +∞) | 最大 | 差 | 差 | 高亮区域误响应严重 |
| TM_CCORR_NORMED | [0, 1] | 最大 | 一般 | 一般 | 支持掩膜,常用作替代方案 |
| TM_CCOEFF | (-∞, +∞) | 最大 | 好 | 好 | 未归一化,跨图不可比 |
| TM_CCOEFF_NORMED | [-1, 1] | 最大 | 好 | 好 | 不支持掩膜,低纹理区虚高 |
选型上我的建议很直接:默认上TM_CCOEFF_NORMED,需要掩膜时退到TM_CCORR_NORMED,做两图配准且已归一化时用TM_SQDIFF_NORMED。另外有个容易被忽略的细节:不同方法的最优方向不一样,SQDIFF 系列要找最小值,其他找最大值。写代码时如果方法名是个变量,一定要用if (method == TM_SQDIFF || method == TM_SQDIFF_NORMED)判断一下,不然会在调参时对着错误的方向折腾半天——这个错我自己犯过,也见过别人犯。
3. 把第一个能跑的版本搭起来
3.1 环境准备与最小可运行骨架
依赖上只需要一个开源计算机视觉库,用包管理器装或者自己编都行,我一般倾向于源码编译并打开向量化选项,实测同一段代码在打开与不打开 SIMD 的情况下性能差距能到几倍。读图这一步有个坑要提前说:imread默认读三通道,如果你拿彩色模板去匹配灰度图,或者反过来,程序会直接抛异常或者给出莫名其妙的结果。我的习惯是全部显式指定IMREAD_GRAYSCALE,模板和场景统一成单通道CV_8U,这样路径最稳。
下面是最小骨架,能跑通、能看到分数,先建立手感再谈优化:
#include <opencv2/opencv.hpp> #include <iostream> int main() { cv::Mat scene = cv::imread("scene.png", cv::IMREAD_GRAYSCALE); cv::Mat templ = cv::imread("template.png", cv::IMREAD_GRAYSCALE); if (scene.empty() || templ.empty()) { std::cerr << "read image failed" << std::endl; return -1; } if (templ.cols > scene.cols || templ.rows > scene.rows) { std::cerr << "template larger than scene" << std::endl; return -1; } cv::Mat result; cv::matchTemplate(scene, templ, result, cv::TM_CCOEFF_NORMED); double minVal, maxVal; cv::Point minLoc, maxLoc; cv::minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc); // CCOEFF_NORMED 越大越好,maxLoc 是模板左上角 std::cout << "score=" << maxVal << " at (" << maxLoc.x << "," << maxLoc.y << ")" << std::endl; cv::Rect box(maxLoc, templ.size()); cv::Mat vis; cv::cvtColor(scene, vis, cv::COLOR_GRAY2BGR); cv::rectangle(vis, box, cv::Scalar(0, 0, 255), 2); cv::imwrite("result.png", vis); return 0; }编译时记得把库的头文件和链接库都带上,如果用的是 CMake,find_package之后target_link_libraries关联一下就行。这段代码跑通之后你会立刻产生两个疑问:分数到底多少算匹配上?如果有多个目标怎么办?这两个问题后面两节分别解决。
3.2 matchTemplate 的参数逐项拆开看
函数签名是matchTemplate(image, templ, result, method, mask)。image和templ必须是同类型同通道,且templ不能比image大;result不需要预分配,函数会自动给你一个CV_32F的矩阵,尺寸就是前面说的候选位置数。method就是上一节那张表里的六选一,mask是个可选参数,类型要求和模板一致,非零像素参与计算、零像素被忽略。
这里有个非常关键的隐藏约束:mask 参数只对TM_SQDIFF和TM_CCORR_NORMED生效,其他方法传了会被忽略(有的版本直接报错)。这一点文档里写得不显眼,我当初想做“只匹配圆形区域”的时候,传了 mask 给CCOEFF_NORMED,跑出来结果和没传一模一样,查了好久才反应过来。如果你既需要掩膜又想要相关系数的光照不变性,那就得自己实现,第 5 节会讲怎么写。
还有个性能细节:result的类型固定是 32 位浮点,如果你的候选位置特别多(比如 4K 图上找小模板),这个矩阵本身就不小。4000×3000 的图找 32×32 模板,结果矩阵将近 1200 万个 float,差不多 48MB,每次都重新分配会有可观的开销。高频调用的场合,可以自己维护一个复用的result缓冲,把分配开销摊掉。
3.3 结果矩阵的解读与最容易犯的坐标错误
minMaxLoc返回的maxLoc是模板左上角在场景图中的坐标,不是中心。这一点在只做可视化的时候无所谓,但一旦你要把坐标交给下游做偏移量补偿或者手眼标定,左上角还是中心就差了半个模板尺寸,在某些高精度场景里这是致命的。我养成的习惯是拿到maxLoc之后立刻转成中心点:cx = loc.x + templ.cols / 2.0,后续所有几何运算都用中心点,避免来回换算。
另一个高频错误是把minLoc和maxLoc混用。用TM_SQDIFF_NORMED的时候要找minVal和minLoc,但minMaxLoc两个都给你,代码里写错一个变量不会报错,只会安静地给出错误位置。这类 bug 特别恶心,因为结果看起来“合理”,只是偏了。我的做法是在结果处理层统一加一层封装:把“方法 → 最优方向 → 取值函数”写成一个 switch,调用方只看一个统一的score和一个统一的loc,从源头上杜绝方向搞反。
3.4 先把计算量算清楚再谈优化
在动手优化之前,我强烈建议你手算一次工作量,这样你对“该不该上金字塔”“要不要多线程”会有完全不同的判断。公式很简单:候选位置数 × 模板像素数 = 乘加次数。1280×960 图、64×64 模板约 45 亿次;如果场景换成 2448×2048、模板还是 64×64,那候选位置变成 2385×1985 ≈ 473 万,乘加次数约 194 亿次,直接翻了四倍多。这时候你就能理解为什么有些项目单帧要跑几百毫秒了。
反过来,如果模板很小(比如 16×16)而图很大,候选位置数量是主导项,这时候优化重点应该放在“减少候选位置”上;如果模板很大(比如 256×256),单点计算量是主导项,那就该考虑频域方法或者降采样。这个判断经常被跳过,结果就是有人在模板很小的场景里拼命优化单点计算,收效甚微。算一遍,比猜十次都强。
4. 从“能跑”到“能用”的四个必做改造
4.1 多目标检测与局部极大值抑制
单个minMaxLoc只能给你一个位置,但现实里一个料盘上往往摆着十几个同样的零件。最朴素的做法是“找到一个就把那一块涂黑再找下一个”,但涂黑的大小很难定:涂小了同一个目标会在附近反复被检出,涂大了相邻的真实目标会被误杀。更规范的做法是在结果矩阵上做局部极大值抑制:先把所有超过阈值的候选点收集起来,按分数从高到低排序,然后依次保留,凡是与已保留点的距离小于抑制半径的就丢掉。
抑制半径怎么取?半模板尺寸是个不错的起点,因为模板匹配的响应在目标附近会形成一个平滑的峰,峰宽大致和模板尺寸相关。如果两个目标本身间距就小于半个模板,那它们在这个尺度下本来就难以区分,这时候你该考虑的是换更小的模板或者上更精细的方法,而不是继续压半径。
struct Match { cv::Point pt; double score; }; std::vector<Match> findMulti(const cv::Mat& result, double thresh, int nmsRadius, int maxCount) { std::vector<Match> cand; for (int y = 0; y < result.rows; ++y) { const float* row = result.ptr<float>(y); for (int x = 0; x < result.cols; ++x) { if (row[x] >= thresh) cand.push_back({ cv::Point(x, y), row[x] }); } } std::sort(cand.begin(), cand.end(), [](const Match& a, const Match& b) { return a.score > b.score; }); std::vector<Match> kept; for (const auto& c : cand) { bool ok = true; for (const auto& k : kept) { int dx = c.pt.x - k.pt.x; int dy = c.pt.y - k.pt.y; if (dx * dx + dy * dy < nmsRadius * nmsRadius) { ok = false; break; } } if (ok) { kept.push_back(c); if ((int)kept.size() >= maxCount) break; } } return kept; }这段代码在候选点特别多的时候会有点慢(两重循环),但实际场景里过阈值的点通常只有几十上百个,问题不大。如果你的阈值定得很低导致候选上万,那说明阈值本身就有问题,先回去调阈值比优化这段循环更划算。
4.2 亚像素精度:整数坐标不够用的时候
maxLoc只能给整数像素,但很多定位场景要求 0.1 像素级的重复精度。这时候可以用响应曲面的局部形状做插值——在峰值点及其左右(或上下)邻居上拟合一条抛物线,取顶点作为亚像素位置。原理很简单:假设响应在峰值附近是二次函数,那么一维情况下:
dx = 0.5 * (f(x-1) - f(x+1)) / (f(x-1) - 2f(x) + f(x+1))
分母是二阶差分,代表曲率;分子是一阶差分,代表坡度。注意这个公式对“值越大越好”的响应直接用,如果用的是 SQDIFF 系列(值越小越好),要先把响应取负或者把公式整体取反。分母接近零说明峰很平,这时候算出来的偏移会非常不稳定,我的做法是设一个下限,分母绝对值小于某个阈值时直接放弃亚像素修正,退回整数坐标,宁可精度低一点也不要跳点。
另一种思路是相位相关法,它对纯平移的估计天生就是亚像素级且对光照变化不敏感,缺点是需要做频域变换、且只能处理平移。在对精度要求极高且目标确实只做平移的场合(比如图像拼接的配准块),相位相关往往比模板匹配更合适。这两者我更倾向把它们看成互补工具而不是替代关系。
4.3 图像金字塔:粗定位加精定位
前面算过,全分辨率搜索的代价随图像尺寸线性甚至超线性增长。金字塔的思路是:先降采样,在大尺度上找到大致位置,再回到原图在附近小范围里精搜。典型的做法是降 2 到 3 层,最顶层分辨率只有原图的 1/4 或 1/8,候选位置数量直接降到 1/16 或 1/64,粗搜几乎瞬间完成;然后逐层把坐标乘 2 映射回去,在映射点周围开一个固定大小的窗口做精搜。
这里有几个实操细节必须注意。降采样一定要用面积平均或者高斯金字塔,不要用最近邻,最近邻会产生混叠,让响应峰变毛刺,粗定位容易偏。每层都要把模板同步降采样,不能只降图不降模板。映射回来的搜索窗口半径要留够余量,因为粗定位本身有误差,误差量级大约等于该层的模板尺寸,所以窗口半径取“该层模板尺寸的一半”比较稳妥。我用过一个偏小的窗口,结果粗定位偏了十几个像素,精搜窗口没覆盖到真实位置,最后返回了一个次优峰,排查了很久才发现是窗口给窄了。
这套结构在小目标定位上收益特别大。我做过一组对比,同一张 2448×2048 的图找 64×64 模板,直接全图搜和三层金字塔,耗时差距接近一个数量级,而精度几乎看不出区别——因为最后一步还是在原分辨率上精搜的。
4.4 遮挡、无关区域与掩膜匹配
模板里经常混着“不该参与打分”的区域,比如定位孔周围有反光、标签上有会变化的批号。这时候就需要掩膜:只让真正稳定的部分参与相似度计算。前面说过只有TM_SQDIFF和TM_CCORR_NORMED支持掩膜,所以实操上有两条路。
第一条是用TM_CCORR_NORMED加掩膜,代码写起来最省事。但它的问题是:归一化用的分母只考虑了模板一侧的能量,场景侧的亮度变化仍然会影响分数。所以如果你的图有整体亮度漂移,这个方案的阈值会不稳。解决办法是先做一次光照归一化再匹配,比如把图统一拉到某个均值方差,这样能把这个缺陷大部分抵消掉。
第二条路是自己实现带掩膜的归一化相关系数,把参与计算的像素集合限定在掩膜内,均值和方差都只在这个集合上统计。思路不复杂,就是循环里加一个if (mask != 0)的判断,但要注意所有求和都要在同一集合上做,包括均值、方差、内积,漏一个就会算错。这条路的性能不如库函数,但换来的是分数完全可控,在要求严格的场合值得。
5. 抛开现成库,自己写一遍才真正理解
5.1 朴素实现:性能瓶颈到底在哪
自己写一遍归一化相关系数,是我认为理解这个算法最快的途径。核心就是四层循环加四个累加器:候选区域和模板的均值、两者的平方和、两者的内积。写完之后你会立刻发现,瓶颈不在于乘法本身,而在于内存访问。每次滑窗移动一列,模板覆盖的区域里绝大部分像素和上一次是重叠的,但朴素实现每次都重新读一遍,缓存命中率再高也架不住重复读。
另一个隐性开销是边界处理。按定义每个候选位置都要遍历整个模板,那么x-1、y-1这些越界索引必须靠判断跳过,分支预测的开销在热循环里是实打实的。常见的优化是把内层循环拆成“左边界”“中间部分”“右边界”三段,中间那段无分支,速度会明显改善。
5.2 积分图加速:把窗口求和变成常数时间
归一化相关系数里最耗的部分其实是均值、方差这类“窗口求和”。而窗口求和正是积分图的主场:构造两张积分图,一张存灰度累加,一张存灰度平方的累加,之后任意矩形区域的sum和sum2都能用四个点做两次加减法得到,与窗口大小无关。有了这两个值,候选区域的均值和标准差就是 O(1),剩下的只有内积sum(T*I)还需要老老实实算。
// 构造积分图:一张存灰度,一张存平方 cv::Mat sumImg, sqSumImg; cv::integral(scene, sumImg, CV_64F, sqSumImg, CV_64F); // 取某个窗口 (x, y, w, h) 的 sum 与 sum2 auto rectSum = [](const cv::Mat& ii, int x, int y, int w, int h) { double a = ii.at<double>(y, x); double b = ii.at<double>(y, x + w); double c = ii.at<double>(y + h, x); double d = ii.at<double>(y + h, x + w); return d - b - c + a; };用CV_64F而不是 32 位浮点,是因为平方累加很容易溢出——255² ≈ 65025,一张 2000×2000 的图总和能到 2.6e11,32 位整数早就爆了,单精度浮点也会丢有效位。这个坑我在第一次写积分图时踩过,结果就是图的右下角算出来全错,找了半天才发现是溢出。
5.3 频域与向量化:进一步压榨性能
内积那部分如果还想更快,可以转到频域。模板在图上滑动做相关,等价于频域里的逐点相乘,所以把场景图和翻转后的模板都做一次正向变换、逐点相乘、再做逆变换,就能一次性得到所有位置的原始相关值。计算复杂度从 O(N·M) 降到 O(N log N),当模板尺寸较大时优势非常明显——模板 128×128 以上基本就能看到收益。代价是实现复杂度上来了,而且频域结果有周期性边界效应,边缘位置需要特殊处理或者对图像做填充。
如果不想动频域,向量化加多线程往往更划算。向量化上把内层循环改成按 8 个或 16 个像素批量累加,编译器一般能自动向量化,前提是别在循环里写分支和指针别名。多线程上最自然的切法是按行切分结果矩阵,每个线程负责一批候选位置,各算各的,完全没有写冲突。我实测过四线程能拿到接近三倍的加速,再往上收益就衰减了,因为内存带宽开始成为瓶颈。这时候继续加线程不如回头去优化数据布局,比如把模板复制成多份连续对齐的副本,减少缓存行冲突。
6. 那些文档上不会写的踩坑记录
6.1 阈值到底怎么定才不飘
固定阈值是绝大多数项目第一版的做法,也是上线后最容易被投诉的地方。0.8 这个数字在光照和镜头都不变的情况下挺好用,但一旦换批次、换季节、光源衰减,就会开始漏检或误检。我的经验是分两步走:先用相对阈值,取当前帧的最高分乘以 0.95 作为本帧的判定线,这样能自动适应整体分数的漂移;再叠加一个绝对下限,比如 0.6,防止整帧都很差的时候把噪声当成目标报出去。两个条件同时满足才接受。
这套组合的效果比我试过的任何单一阈值都稳。另外,如果你的应用允许多次拍照,可以在标定时统计一批良品图的分数分布,取“均值减三倍标准差”作为参考下限,这比拍脑袋定 0.8 靠谱得多。记得把这个统计过程写成一个离线脚本,换产品时重跑一遍,别靠人肉记数字。
6.2 光照、对比度与反转
前面说过CCOEFF_NORMED对线性光照变化不敏感,但它对非线性变化(比如局部过曝、阴影遮挡、gamma 变化)就没什么抵抗力了。所以如果现场光源不稳,与其在算法里死磕,不如在预处理上做文章:先把图转成浮点,减去局部均值或者做一次 CLAHE,把局部对比度拉平,再送去匹配。这两步能救回很多“看起来完全没法匹配”的图。
还有一种是黑白反转的情况,比如背光成像或者深色背景上的浅色目标。这时候相关系数会给出接近 -1 的分数,最优方向反过来。如果你的场景里两种极性都可能出现,那就干脆正反各匹配一次,取绝对值最大的结果,同时记录极性。这个 trick 挺土但很有效,我在几个混合料框的场景里一直这么用。
6.3 常见异常与排查路径
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 分数整体很低,最高不到 0.5 | 模板与场景极性相反、尺度不符、模板截取有偏移 | 打印 result 分布,检查模板实际尺寸与目标是否一致 |
| 最高分出现在空白/纯色区域 | 用了 CCORR 未归一化,或纹理过弱导致分母过小 | 换 CCOEFF_NORMED,统计模板方差,低于阈值就换模板 |
| 同一目标被检出多次 | 未做 NMS 或抑制半径过小 | 半径设为模板尺寸一半起步,逐步调整 |
| 结果偶尔整体偏移几个像素 | 模板截取时包含了不稳定的边缘区域 | 用掩膜裁掉边缘,或重新截取更靠内的模板 |
| 部分帧直接抛异常 | 模板比场景大、通道数不一致、空图 | 入口加尺寸与类型断言,日志打出实际尺寸 |
| 速度忽快忽慢 | result 矩阵反复分配,或线程数超过核数 | 复用缓冲,线程数锁死为物理核数 |
这张表里的每一条我都真实遇到过,其中“模板比场景大”这个异常最容易被忽略,因为它只在某些帧出现——比如相机触发早了,图还没拍全。所以入口断言一定要写,而且要带上实际的宽高信息,不然日志里只有一句异常,排查时等于没有信息。
6.4 模板本身的质量决定上限
最后这条是我觉得最值钱的经验:模板截取的质量,比后面所有的算法优化都重要。一个糟糕的模板,你用再好的度量、再多的后处理也救不回来。什么样的模板算好?纹理要有足够的梯度分布,不能大面积纯色;要包含目标的稳定特征区域,把会变化的边缘、反光、批号全部排除在外;尺寸要适中,太小则区分度不够、容易误检,太大则计算量上去且对遮挡更敏感。
我现在的习惯是先用几组不同的候选模板离线跑一遍历史图,把各自的分数分布和检出率画出来对比,选那组“良品分数高且方差小、不良品分数明显低”的。这个过程花半小时,能省掉后面几天的现场调试。比起在代码里加一堆补丁,这个投入产出比高得不是一点半点。
这套东西我在不同产线、不同产品上反复用过,每次的调整点都不一样,但骨架始终是这几个:选对度量、算清开销、做好去重、定稳阈值、把模板截好。真正花时间的从来不是写代码,而是搞清楚你的图为什么会变成现在这个样子。后续如果场景里出现了旋转,我会在模板生成阶段按 5 度一档批量生成子模板做成模板集,用分数最高的那一档反推角度,再在该角度附近做小步长细化——这条路子在没有专门的形状匹配工具时挺好用,只是模板数量和内存要提前算好账。