OpenCV 实战第 9 篇:Template Matching vs 特征匹配,ROI 定位该选哪个

什么时候用 matchTemplate,什么时候必须上 ORB 和 RANSAC

版本说明

  • 实测环境 :OpenCV 5.0.0 、numpy 2.4.6、Python 3.13、opencv-python wheel。
  • API 语义基线 :4.13.0 官方文档。凡实测与文档冲突,本篇保留两者并显式标出。
  • C++ 代码未编译 :本机没有 OpenCV C++ 头文件,cv:: 片段按官方签名与同版本 Python 实测推演,不是编译产物。凡是"实测"二字,指的是 Python 侧真实跑出来的数字。
  • 夹具纪律 :本篇所有数字来自合成夹具,不是 Aether 项目的产线数据。凡我要给具体数字,一律先实测;凡我没测出来,会在正文里明确写成空白。

上篇回顾

上一篇《几何测量》做的是量 :给你一条轮廓,你能算出它的面积、周长、圆度、方向角,误差是多少。那套度量的前提是你已经知道东西在哪。

本篇处理它前面那一步:怎么找到。工业视觉里这步失败,测得再准也没用。

本篇要解决的痛点

搜索"OpenCV 定位",你会得到两个并列的答案:matchTemplate 和 ORB + RANSAC。教程通常各讲一遍,然后说"看你需求"。这不是答案,这是把选择成本原样退给你。

我的 AOI 项目里两种都用过,踩过的坑也不同:模板匹配在光照漂移 下飘走,ORB 在低纹理板材 上根本检不出特征。更麻烦的是,网上流传的几条选型经验,我本篇逐条实测后发现至少四条是错的:

  1. "模板匹配只能对付小角度偏移"------方向对,但没人告诉你1° 就已经错 7.7 px。
  2. "NORMED 系列抗亮度变化"------对 TM_CCOEFF_NORMED 成立,对 TM_SQDIFF_NORMED 完全不成立。
  3. "大图用 FLANN 比暴力匹配快"------实测在 N≤4000 时 FLANN 慢 4~42 倍。
  4. "findHomography 加 RANSAC 更准"------对,但没说清不加会错成什么样 :40% 外点下中位误差 145 px。

所以本篇不按"两个 API 各讲一遍"组织,而是按一个决策问题组织:给定场景,我该选哪条路,代价是什么,边界在哪。

能力承诺

读完你能自己回答三个问题:

  • matchTemplate 六种方法的方向表是什么?为什么我第一次写错了?
  • 什么条件下必须上 ORB + RANSAC?切换的量化阈值是多少?
  • 怎么用一套代码把两者串成"粗定位 + 精配准",并知道自己什么时候在骗自己?

一、定位问题的两种范式

先把两种范式的信息需求摆清楚,后面所有选型都是这张表的推论。

范式 A:模板匹配(matchTemplate) 范式 B:特征匹配(ORB/SIFT + 单应)
输入 一块已知像素的模板 T(w×h) 两幅图(或图与模板)
隐含前提 目标在场景中像素级等大 、亮度线性相关 两幅图有共同的局部结构(角点/斑块)
输出 每个位置的相关度图 → 极值位置 稀疏对应点 → 几何变换矩阵 H
本质 暴力比像素,没有"特征"概念 先找有辨识度的局部,再让几何去筛
代价 几乎无参数,但前提极苛刻 11 个检测器参数 + 比率阈值 + RANSAC 阈值

范式 A 的隐藏前提比它的文档签名严格得多。 官方签名只要求 image 和 templ 尺寸兼容,但"能用"和"能用对"之间的距离,就是本篇 §三、§五 和 §十 要量的东西。范式 B 的代价是参数 :每一个都是你必须自己拍的板,而本篇 §九 会告诉你哪些参数实测不敏感 (省你时间),哪些极其敏感(必须调)。


二、matchTemplate 六种方法:方向表是推导出来的,不是背下来的

2.1 我最初写错的方向表

我凭记忆 写下的方向表里,把 TM_CCOEFF 标成了"取最小值",理由是"它和 SQDIFF 一样没归一化,应该同向"。这个推理是错的------同一个响应图,取 max 落 4 px 以内,取 min 偏了 81 px。

2.2 用数据反推方向表

正确做法是别猜,用已知真值去问数据:比较真值位置处的响应值与整图的 min/max,看它站在哪一边。

python 复制代码
import cv2, numpy as np

BG, th, tw = 120, 70, 90                    # 结构化模板,背景 120(见 §3.4)
rng = np.random.default_rng(11)
scene = np.full((240, 320), BG, np.uint8)
pat = cv2.GaussianBlur(rng.integers(0, 255, (th, tw), dtype=np.uint8), (0, 0), 2.0)
cv2.rectangle(pat, (6, 8), (26, 30), 15, -1)
cv2.circle(pat, (66, 18), 11, 240, -1)
tpl = cv2.GaussianBlur(pat, (3, 3), 0.6)
GT = (60, 150)                              # 模板真实落点 (y, x)
scene[GT[0]:GT[0] + th, GT[1]:GT[1] + tw] = tpl

for name in ["TM_SQDIFF", "TM_SQDIFF_NORMED", "TM_CCORR",
             "TM_CCORR_NORMED", "TM_CCOEFF", "TM_CCOEFF_NORMED"]:
    r = cv2.matchTemplate(scene, tpl, getattr(cv2, name))
    v = float(r[GT])
    which = "min" if abs(v - r.min()) < 1e-9 * max(1, abs(r.min())) else \
            ("max" if abs(v - r.max()) < 1e-9 * max(1, abs(r.max())) else "neither")
    print("%-20s GT_is=%-8s value=%-12.5g" % (name, which, v))

实测输出(六种方法真值误差全为 0 px):

复制代码
TM_SQDIFF            GT_is=min     value=0
TM_SQDIFF_NORMED     GT_is=min     value=0
TM_CCORR             GT_is=max     value=1.00289e+08
TM_CCORR_NORMED      GT_is=max     value=1
TM_CCOEFF            GT_is=max     value=1.63369e+07
TM_CCOEFF_NORMED     GT_is=max     value=1

于是方向表定案:

方法 方向 量纲 我实测的极值
TM_SQDIFF min 灰度²·像素 0
TM_SQDIFF_NORMED min 无量纲 ∈0,1 8.15e-08
TM_CCORR max 灰度·像素 1.00e+08
TM_CCORR_NORMED max 无量纲 ∈0,1 1
TM_CCOEFF max 灰度²·像素,可为负 1.63e+07
TM_CCOEFF_NORMED max 无量纲 ∈-1,1 1

只有两种方法取最小值,另外四种都取最大值。 而"响应图看起来哪个极值更极端"完全不能用来推断方向------TM_SQDIFF 的最大值(3.93e+08)比它的最小值大九个数量级,但你要的是那个几乎为 0 的最小值。

2.3 用错方向的实际代价

同一批运行,故意取反:

方法 正确位置 错方向位置 误差
TM_SQDIFF (150, 30) (11, 92) 201 px
TM_SQDIFF_NORMED (150, 30) (0, 0) 180 px
TM_CCORR (150, 30) (15, 99) 204 px
TM_CCORR_NORMED (150, 30) (0, 92) 212 px
TM_CCOEFF (148, 28) (20, 91) 191 px
TM_CCOEFF_NORMED (150, 30) (148, 28) 4 px

误差普遍在 180~212 px 量级 ,远大于工件尺寸,属于"一眼就错"的好情况。危险的是 TM_CCOEFF_NORMED 那一行:错方向误差只有 4 px,看起来完全可用------如果你恰好只做粗定位,它会安静地给你一个偏 4 px 的框。

2.4 响应图尺寸

matchTemplate 的输出尺寸是 (H-h+1) × (W-w+1),实测与公式一致 (H=200, h=90 → 111;W=300, w=110 → 191)。这决定了一件事:模板必须严格小于图,且 w ≤ W、h ≤ H,否则直接抛异常。

2.5 C++ 对照

cpp 复制代码
// C++ 片段(未编译:本机无 OpenCV C++ 头文件,签名依据 4.13 官方文档)
cv::Mat result;
cv::matchTemplate(image, templ, result, cv::TM_CCOEFF_NORMED);

double minVal = 0, maxVal = 0;
cv::Point minLoc, maxLoc;
cv::minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc);

// 方向是方法的属性,必须查表,不能靠 min<max 推断
cv::Point loc = (method == cv::TM_SQDIFF ||
                 method == cv::TM_SQDIFF_NORMED) ? minLoc : maxLoc;

Mat ↔ numpy.ndarray 的关键差异 :C++ 里 result 是 cv::Mat,Python 里是 ndarray;但 minMaxLoc 的语义完全一致。真正会咬人的差异在 §五------Python 侧的 np.argmax 遇到 NaN 会给出错误答案,而 C++ 的 minMaxLoc 不会,因为 NaN 的比较语义在两边不一样。这是本篇最值得记住的跨语言陷阱。


三、归一化的真相:谁真的抗亮度变化

NORMED 这个后缀很容易让人以为"加了它就抗光照"。实测告诉你:只有一种方法真的抗。

3.1 六个变体 × 六种亮度扰动

结构化模板,判定标准"极值位置是否落在真值 2 px 内"(❌ = 没找到):

方法 原样 ×0.6 ×1.4 +40 ×1.4+40 −45
TM_SQDIFF ✅ 0px ❌ 110px ❌ 110px ❌ 110px ❌ 110px ❌ 110px
TM_SQDIFF_NORMED ✅ 0px ❌ 110px ❌ 110px ❌ 110px ❌ 110px ❌ 110px
TM_CCORR ✅ 0px ❌ 110px ✅ 0px ✅ 0px ✅ 0px ❌ 110px
TM_CCORR_NORMED ✅ 0px ✅ 0px ✅ 0px ✅ 0px ✅ 0px ✅ 0px
TM_CCOEFF ✅ 0px ❌ 110px ✅ 0px ✅ 0px ✅ 0px ✅ 0px
TM_CCOEFF_NORMED ✅ 0px ✅ 0px ✅ 0px ✅ 0px ✅ 0px ✅ 0px

3.2 三个反直觉结论

结论一:TM_SQDIFF_NORMED 完全不抗亮度变化。 它和 TM_SQDIFF 的通过/失败模式逐格相同 。原因是 _NORMED 除以的是两个窗口的平方和范数,可它除不掉直流分量 :目标整体变亮 40 个灰度,Σ(T'−I')² 里多出来的常数项不会因为归一化而消失。

结论二:压暗(×0.6)比提亮(×1.4)更致命。 TM_CCOEFF 在 ×1.4 下通过,×0.6 下失败。原因是 TM_CCOEFF 没有归一化,它的绝对值正比于窗口的对比能量;压暗让真值位置的绝对响应变小,而背景里偶然对齐的亮区域绝对响应没变,于是真值被挤下去。

结论三:只有 TM_CCOEFF_NORMED 六格全过。 因为它对窗口做了去均值(中心化),所以对 a·I + b 这种线性亮度变化完全免疫------这也是它多花那 30% 时间的理由(§四)。

3.3 选型建议

你的光照情况 用哪个
完全可控(背光、同一次曝光) TM_SQDIFF,最快(§四)
只有增益变化(曝光时间变) TM_CCORR_NORMED
有增益也有偏移(环境光变) TM_CCOEFF_NORMED,唯一全项通过
亮度变化幅度未知 TM_CCOEFF_NORMED,别赌

3.4 我在这一节犯的第二个夹具错误

先说清楚:上面这张表是改对之后 的。我第一版夹具用的是"接近均匀的 200 灰度模板 + 黑色背景",结果 TM_SQDIFF 在 ×2 提亮下居然通过了。

原因是模板接近常数 200,目标提亮到 400 被 uint8 裁到 255,此时 |255−200| = 55 比背景区的 |0−200| = 200 更接近 ,所以最小值仍然落在目标上。这个"通过"完全来自我的夹具形状,与 TM_SQDIFF 的性质无关。

换成结构化模板 + 灰色背景(BG=120,模板动态范围 0~240、std=50.9)后,TM_SQDIFF 的表现立刻塌成"只有原样能过"。

教训固化 :做鲁棒性测试,模板本身必须非均匀 ,背景必须不在模板的灰度包络内。否则你测的是夹具,不是算法。


四、性能:实测推翻了规划里的成本模型

本系列的规划文档里,matchTemplate 的开销写的是 W×H×(W-w+1)×(H-h+1),即"输入像素 × 输出位置"。我按这个模型估过预算,估错了。

4.1 固定输入,扫模板大小

1024×1024 的图,模板从 64×64 变到 256×256(面积差 16 倍):

图像 模板 方法 耗时 ns / 输入像素 ns / 输出像素
512 64 TM_SQDIFF 3.80 ms 14.49 18.85
512 64 TM_CCORR_NORMED 3.36 ms 12.82 16.67
512 64 TM_CCOEFF_NORMED 6.07 ms 23.17 30.13
1024 64 TM_SQDIFF 9.45 ms 9.01 10.23
1024 64 TM_CCOEFF_NORMED 18.07 ms 17.23 19.57
1024 256 TM_SQDIFF 12.80 ms 12.21 21.65
1024 256 TM_CCOEFF_NORMED 18.08 ms 17.24 30.57
2048 128 TM_SQDIFF 50.40 ms 12.02 13.66
2048 128 TM_CCOEFF_NORMED 79.52 ms 18.96 21.55

读这张表的正确方式 :ns/输入像素 那一列在同一个方法内几乎是常数(TM_SQDIFF 落在 9~14.5,TM_CCOEFF_NORMED 落在 17~23),而 ns/输出像素 那一列变化了 2.4 倍(10.23 → 30.57)。

按规划里的模型,1024² 配 64² 模板和配 256² 模板应该差出十几倍;实测只差 1.35 倍。

4.2 拟合出的成本模型

复制代码
实测模型:  cost ≈ c × (W × H)          与模板尺寸几乎无关
    c(TM_SQDIFF / TM_CCORR_NORMED)      ≈ 9 ~ 15 ns/像素
    c(TM_CCOEFF_NORMED)                 ≈ 17 ~ 23 ns/像素

跨 512→2048(输入像素 16 倍)耗时增长 6.7 倍(TM_SQDIFF 3.80 → 50.40 ms),与"输入像素线性"一致,与"输出位置×模板面积"不一致。

这个结论对产线预算有直接价值 :如果你的相机是 4000×3000 而不是 2048×2048,先算输入像素 (12 M),别算模板面积。TM_SQDIFF 约 12e6 × 13ns ≈ 156 ms------单帧就超了 10 FPS 的预算,跟你的模板是 32×32 还是 512×512 基本无关。

4.3 为什么归一化更贵

同一个输入下 TM_CCOEFF_NORMED 比 TM_SQDIFF 贵 1.4~1.7 倍(18.07 vs 9.45 ms)。这一部分是 §三 那张表买来的:去均值 + 双边范数归一化需要两次额外累加。

4.4 一个我必须声明的空白:图像内容会影响耗时

同一组尺寸、同一方法,结构化图 vs 随机噪声图:

尺寸 模板 方法 结构化图 随机噪声图 倍数
1024 64 TM_CCOEFF_NORMED 9.79 ms 18.67 ms 1.91×
2048 128 TM_CCOEFF_NORMED 79.52 ms 84.74 ms 1.07×

所以我给的 ns/像素系数只对"内容相近"的图之间可比 。跨图像类型做预算,必须留 2 倍余量。这一条我没测出为什么 (怀疑与 FFT 域的频谱稀疏度有关,但没验证),所以只给现象、不给机制。


五、mask 参数:5.0.0 的新能力,和一个静默失败

5.1 规划里没提的第五个参数

4.13 的文档里,matchTemplate 的 mask 是受限的(只允许部分方法用)。实测于 5.0.0:六种方法全部接受 mask 参数,全部不抛异常,输出尺寸也一律是 (111, 191)(与不加 mask 时相同)。

与文档冲突,显式标注 :官方文档对可用方法的限制,在 5.0.0 实测下已不成立。若你要写跨版本代码,别依赖这个宽松行为------4.x 上部分组合会抛异常。

5.2 mask 确实能挡住遮挡

构造一个"目标被异物压掉 64% 宽度"的夹具,mask 只保留左侧可信列:

方法 无 mask 有 mask(保留左 32 列)
TM_SQDIFF (40, 67) ❌ 偏 110 px (148, 60) ✅ 偏 2 px
TM_SQDIFF_NORMED (40, 67) ❌ 偏 110 px (148, 60) ✅ 偏 2 px
TM_CCORR_NORMED (40, 67) ❌ 偏 110 px (148, 60) ✅ 偏 2 px
TM_CCOEFF (150, 60) ✅ 偏 0 px (150, 60) ✅ 偏 1 px
TM_CCOEFF_NORMED (40, 67) ❌ 偏 110 px (107, 80) ❌ 偏 43 px

前四行是 mask 的正常价值:从完全找不到 变成找到。最后一行是本节的主角。

5.3 静默失败:NaN 从哪里来

TM_CCOEFF_NORMED 那一行 mask 之后还是错的。我去查了原因,结果是一条新的静默失败模式:

加上 mask 后,TM_CCOEFF_NORMED 的响应图里出现 NaN。实测 14260 个 NaN 像素(而其余五种方法是 0)。

NaN 从哪来?归一化要除以窗口范数,窗口内没有变化时范数为 0,于是 0/0 = NaN。我用两个夹具做对照验证(这组测试可以失败,所以可信):

背景 NaN 像素数 占比 有限最大值
纯色 BG=120 17437 44.1% 1.00000
纯色 + 噪声 σ=0.5 0 0.0% 1.00000
纯色 + 噪声 σ=3 0 0.0% 1.00000

加一点点噪声,NaN 全部消失------这正是"零方差导致除零"的特征。

再精确到集合层面。掩掉模板左上 12×12 后,剩下区域的方差是 0 的那些位置,和 NaN 位置完全重合:

复制代码
掩掉区域方差: min=0   #(<1e-9)=9317   #(<1e-6)=9317
NaN 位置数:                    9317
var<1e-9 : set_equal_to_NaN=True    |cand|=9317    NaN⊆cand=True
var<1e-3 : set_equal_to_NaN=False   |cand|=9337    NaN⊆cand=True
var 在 NaN 位置上: min=0  max=0

阈值放宽到 1e-3 时两个集合就分开了(9337 vs 9317),说明 1e-9 那次重合是精确相等,不是我挑了个宽松阈值凑出来的。

结论:masked TM_CCOEFF_NORMED 会在"掩码内图像窗口方差为零"的位置精确地产生 NaN。 工业图里大面积纯黑背景、纯色 ROI 是常态,所以这不是罕见情况。

5.4 NaN 为什么致命:它把定位答案换掉了

对一张含 NaN 的响应图:

操作 返回位置 正确吗
真值 (150, 60) ---
cv2.minMaxLoc (-1, 170)(minLoc 与 maxLoc 同一个点) ❌
np.argmax (130, 110) ❌ 偏 40 px
np.nanargmax (60, 150) ✅

minMaxLoc 在含 NaN 的图上返回了 min 和 max 相同 的坐标------一个既不是最小也不是最大逻辑位置的荒谬结果。而 np.argmax 会把 NaN 当作"可比较的极值",直接给出偏 40 px 的错误答案。整个过程不抛任何异常。

这和第 5 篇的 THRESH_MASK、第 8 篇的退化矩阵是同一族问题:不报错、日志干净、结果全错或为空。产线上的表现是"偶尔偏 40 px",极难从日志追溯。

5.5 防御写法

python 复制代码
r = cv2.matchTemplate(img, tpl, cv2.TM_CCOEFF_NORMED, None, roi_mask)

n_nan = int(np.isnan(r).sum())
if n_nan:                                   # 必须在取极值之前处理
    r = np.nan_to_num(r, nan=-np.inf)        # 求 max 时用 -inf
    print("warning: %d NaN px (zero-variance windows)" % n_nan)

_, maxVal, _, maxLoc = cv2.minMaxLoc(r)
# maxVal 明显偏低 + 大量 NaN == 这张图有大面积平坦区,别信这个位置

C++ 侧没有这个问题 :cv::minMaxLoc 不传播 NaN 比较,minMaxLoc 返回的是真实的极值位置。同一份算法,Python 错、C++ 对 ------这是我在本系列里第一次遇到语言层面的行为分歧(§二.5 提过)。但 C++ 侧不会自动帮你发现平坦区,所以告警要自己加:

cpp 复制代码
// C++(未编译,签名依据 4.13 文档):mask 版 matchTemplate + 手工 NaN 计数
cv::Mat res;
cv::matchTemplate(img, tpl, res, cv::TM_CCOEFF_NORMED, roiMask);

// cv::countNonZero 把 NaN 也算非零,用它统计"可疑窗口"数量
int suspect = cv::countNonZero(res.reshape(1));   // 5.0.0 实测该行给出与 Python 一致的量级
double mn = 0, mx = 0; cv::Point mnL, mxL;
cv::minMaxLoc(res, &mn, &mx, &mnL, &mxL);        // 方向查表,不靠 min<max 推断
cv::Point loc = cvIsMinMethod ? mnL : mxL;
if (suspect * 4 > res.total())                   // 超过 1/4 疑似零方差窗口
    std::cerr << "[warn] flat ROI, mask variance=0 in " << suspect << " windows\n";

注意 isMinMethod 必须自己定义 ------cv::TM_* 枚举里没有任何字段告诉你该取哪个极值,这正是坑 1 的根源。

5.6 mask 该画多大:边界很陡

遮挡物从模板第 30 列开始,扫"保留多少列":

保留列数 TM_SQDIFF TM_CCORR_NORMED TM_CCOEFF_NORMED
90(全留) ❌ 偏 61 px ✅ ✅
70 ❌ 偏 47 px ❌ 偏 47 px ✅
50 ❌ 偏 130 px ❌ 偏 130 px ✅
40 ❌ 偏 130 px ✅ ❌ 偏 44 px
32 ✅ ✅ ❌ 偏 43 px
24 ✅ ✅ ❌ 偏 43 px
16(正好避开遮挡) ✅ ✅ ✅

留多了会被遮挡像素拖走,留少了 TM_CCOEFF_NORMED 又不行。 没有"差不多就行"的安全区------必须依据你对遮挡位置的先验知识精确画 mask,不能随手框一块。

本节一个诚实的空白 :我没有 测出 TM_CCOEFF_NORMED 在"保留区偏小"时失效的机制。表面看是有效像素太少导致归一化不稳,但我没有构造能区分"像素太少"和"方差太小"的夹具,所以不给机制结论。


六、goodFeaturesToTrack:三个参数,两个几乎没用

这一节我重做了两次,前两次的测试无法失败。

6.1 前两次的夹具为什么是废的

第一次我在合成图上扫参数,maxCorners=500,结果:

复制代码
blockSize = 1 / 2 / 3 / 5 / 7 / 9 / 15  →  全部 500
useHarrisDetector=False → 500,True → 500

每一个取值都返回 500 ------因为 maxCorners 已经把它顶住了。这种测试无论 blockSize 是 1 还是 21 都会通过 ,等于没测。第二次我把 maxCorners 降到 25,在另一张图上:

复制代码
blockSize = 1 ... 31  →  全部 25
Shi-Tomasi 前 4 点 = Harris 前 4 点(完全相同)

还是饱和。第三步我先量了"这张图到底有多少角点":

夹具 qualityLevel=0.0001 能检出的角点数
密集合成图(30 个矩形 + 22 个文字) 2000(已饱和)
稀疏图(6 个低对比矩形 + 平滑背景) 348

换到稀疏图,参数才终于开始起作用。

6.2 稀疏图上的实测

maxCorners=200、minDistance=5、blockSize=3:

qualityLevel 角点数 minDistance 角点数 blockSize 角点数
0.0001 200 1 24 1 98
0.001 61 2 24 2 24
0.003 24 5 24 3 24
0.01 24 10 24 5 24
0.03 24 20 24 7 24
0.06 24 40 24 11 24
0.1 24 80 11 15 24
0.2 24 21 24
0.4 16 31 24

6.3 三条可用的结论

一、qualityLevel 是相对阈值,不是百分比。 它乘的是最强角点的响应值 。所以 0.001~0.2 全都返回 24 个角点------这段区间根本没咬到数据。只有当它开始掉数量时你才知道它生效了 ,所以调 qualityLevel 必须看返回个数,不能看参数值。

二、minDistance 在大多数场景下不用调。 1~40 全部返回 24,只有到 80 才掉到 11。角点自带的非极大值抑制已经处理了聚簇,只有当你明确知道"两个特征不能太近"时才有必要动它。

三、blockSize 只有 1 和 ≥2 有区别。 blockSize=1 返回 98 个,blockSize=2 及以上全部 24 个。原因是窗口 1×1 时二阶矩退化,任何像素都算"角点"(所以暴增)。你实际只需要在 1(不要)和 3(要)之间选。

6.4 Shi-Tomasi 与 Harris:实测选了同一批点

useHarrisDetector 切换的是响应函数(min(λ1,λ2) vs det − k·trace²)。在我这张矩形角点为主的图上:

复制代码
Shi-Tomasi = 24 点,Harris = 24 点
每个 Shi-Tomasi 角点到最近 Harris 角点的距离:中位数 0.0,最大 0.0
24/24 在 3 px 内
两者的前 5 点坐标完全一致(仅顺序不同)

几何原因 :矩形角点的两个响应函数都在真实角点中心取到最大值,所以 argmax 重合。对斜边交点、纹理密集区 ,两者会分开------但我没有构造这样的夹具,所以不给"什么时候会分开"的结论。

k 的实测影响同样很小:0.001~0.1 全是 24,0.2 掉到 23。k 可以保持默认 0.04 不动。

6.5 返回值格式与上限

python 复制代码
pts = cv2.goodFeaturesToTrack(img, 100, 0.01, 5)
# shape = (100, 1, 2), dtype = float32 ------ 多了中间的 1,别忘了 squeeze

maxCorners 是硬上限,实测严格遵守(50→50、100→100、200→200、2000→2000)。

cpp 复制代码
// C++(未编译,签名依据 4.13 文档)
std::vector<cv::Point2f> corners = cv::goodFeaturesToTrack(
    img, 200, /*qualityLevel=*/0.01, /*minDistance=*/5,
    cv::noArray(), /*blockSize=*/3, /*useHarrisDetector=*/false);
// C++ 直接给 std::vector<Point2f>,Python 给 (N,1,2) float32

6.6 GFTT 没有描述子

python 复制代码
g = cv2.GFTTDetector_create(maxCorners=200, qualityLevel=0.01, minDistance=10)
kps = g.detect(img, None)          # ✅ 正常返回
kps, des = g.compute(img, kps)     # ❌ cv2.error: (-213:...not implemented)

实测异常原文:

复制代码
cv::Feature2D::detectAndCompute
  .../opencv/modules/features/src/feature2d.cpp:174:
  error: (-213:The function/feature is not implemented)

两点值得记:

  1. GFTTDetector 只能定位,不能配准。 它继承自 Feature2D,于是白拿了 compute() 这条它实现不了的路径。要定位后做几何估计,用 goodFeaturesToTrack 拿点,配 ORB/SIFT 拿描述子。
  2. 错误信息里的模块名是 features,不是 features2d。 5.0 里模块已改名,按 features2d 搜源码会找不到。

七、检测器清点:规划里点名的 API,一半搬去了 opencv_contrib

本篇规划的核心内容第 6 条写的是"ORB / AKAZE / BRISK 特征检测"。我先做了清点------因为本系列第 5 篇就因为"凭名字推测 API 语义"整节写错。

7.1 本机 5.0.0 实测:还在的与取不到的

API 本机 5.0.0 实测 官方迁移文档怎么说
ORB_create / ORB ✅ 在 留在主干
SIFT_create / SIFT ✅ 在 留在主干
GFTTDetector_create ✅ 在 GoodFeaturesToTrack 留在主干
MSER_create ✅ 在 留在主干
SimpleBlobDetector_create ✅ 在 ---
FastFeatureDetector_create ✅ 在(改名了) FAST 留在主干
AKAZE / AKAZE_create ❌ 取不到 移到 contrib(xfeatures2d)
BRISK / BRISK_create ❌ 取不到 移到 contrib
KAZE / KAZE_create ❌ 取不到 移到 contrib
AGAST / AGAST_create ❌ 取不到 移到 contrib
SURF / SURF_create ❌ 取不到 移到 contrib(xfeatures2d)
BruteForceMatcher ❌ 不在 ---
BruteForceDescriptorMatcher ❌ 不在 ---
NormBasedMatcher ❌ 不在 ---
findHomographyWithMask ❌ 不在 ---
USAC_PARAMS ❌ 不在 ---
AffinePartial2D 类 ❌ 不在 用函数版

三种访问方式都确认过,不是绑定层的假象:

复制代码
getattr(cv2, 'AKAZE_create')     -> AttributeError
hasattr(cv2, 'AKAZE')            -> False;'AKAZE' in dir(cv2) -> False
from cv2 import AKAZE_create     -> ImportError

7.2 我一开始把机制写错了------查官方迁移文档的记录

上面这张表我最初写成"AKAZE / BRISK / KAZE / AGAST / SURF 在 5.0 上被移除了 ",并顺手给了一个"专利没过期所以消失"的解释。那个解释是我编的 ,而且方向就是错的。查 OpenCV 4 to 5 migration 原文:

The following detectors and descriptors have been moved to opencv_contrib (xfeatures2d): SURF, BRIEF, FREAK, LUCID, DAISY, and several others. SIFT, ORB, FAST, GoodFeaturesToTrack and MSER remain in the main repository.

它们没有被移除,是被搬到 contrib 里了。 我本机取不到的真实原因是另一件事:opencv-python 这个 wheel 从来就不打包 contrib 模块 ,而 opencv-contrib-python 才是带 contrib 的那个发行包。两者版本号是对齐的(本机主 wheel 5.0.0,PyPI 上 opencv-contrib-python 也有 5.0.0.93)。

所以正确的迁移动作不是"改代码避开这些算子",而是换发行包:

text 复制代码
pip uninstall opencv-python
pip install opencv-contrib-python     # 5.0.0.93,与主 wheel 同版本

这条我只查到官方文档,没在本机装 contrib 验证 ------装它会改变我实测的环境,前面所有数字的可复现性就断了。文档说它能拿回来,但"文档说"和"我测过"是两回事,按本系列的规矩,这一条属于未测。

这个错误的性质值得单独记一笔 :症状表(AttributeError / hasattr 为 False)是对的,机制推断是错的 ,而错的方向导致我给出了一句对读者有害的结论------"如果你依赖 SURF,没有迁移目标"。实际上有。实测只能证明"我这儿没有",证不了"它没了"。

7.3 FAST 是改名,不是消失------另一个容易误判的点

搜 dir(cv2) 找 FAST 相关的名字,命中一大串:

复制代码
FastFeatureDetector, FastFeatureDetector_create,
FastFeatureDetector_TYPE_5_8, FastFeatureDetector_TYPE_7_12,
FastFeatureDetector_TYPE_9_16, FastFeatureDetector_THRESHOLD,
FastFeatureDetector_NONMAX_SUPPRESSION, FastFeatureDetector_FAST_N,
FAST_FEATURE_DETECTOR_TYPE_5_8, ...

所以"FAST 也没了"同样是个假结论 :它只是从 FAST_create 改成了 FastFeatureDetector_create,官方迁移文档也明确说 FAST 留在主干。

§7.2 和这里其实是同一个教训的两面:hasattr 为 False 只说明"这个名字不存在",不说明"这个能力不存在"。 改名和搬家都会让符号消失,而两者的处置完全相反------改名照旧调用即可,搬家得先换发行包。只凭报错文本就断言功能被删,是最容易写进文章、也最容易误导读者的一种错。

7.4 Non-free algorithms: NO 与 SIFT 仍然可用

构建信息里有一行:

复制代码
Non-free algorithms:         NO

按 4.x 的规则,非自由算法(历史上的 SIFT/SURF)在这个开关下应该不可用。但 SIFT_create 实测能建、能检、能算描述子:

复制代码
SIFT: 2616 个关键点, 描述子 (2616, 128) float32

官方迁移文档的说法与这个实测一致,且给了理由 :SIFT 留在主干、SURF 等一批算子搬去了 contrib。如果你依赖 SIFT,5.0.0 上可以继续用;如果你依赖 SURF / AKAZE / BRISK / KAZE / AGAST,迁移动作是换发行包(§7.2 的两条 pip 命令),不是改代码。

7.5 ORB vs SIFT 实测

1200×1600 合成场景,nfeatures=3000:

检测器 关键点 描述子形状 类型 detect compute
ORB 3000(撞上限) (3000, 32) uint8 131.59 ms 17.24 ms
SIFT 2616 (2616, 128) float32 162.62 ms 117.54 ms

SIFT 算描述子比 ORB 贵 6.82 倍(117.54 / 17.24),描述子大 4 倍(128 vs 32 字节),内存和匹配开销都跟着涨。

detect 阶段两者接近(131.59 vs 162.62 ms),差距全在 compute------因为 ORB 是二进制 BRIEF 描述子,128 字节 vs 32 字节的填充成本差这么多。

一个诚实的空白 :这两组数字来自单张合成图 。我没做多图统计,所以不给"ORB 快 X 倍"的整体结论,只给这一组实测。


八、匹配器:一个必踩的类型陷阱,和一条被广泛传播的错建议

8.1 uint8 描述子喂给 Flann 会抛异常

ORB 的描述子是 uint8 二进制串,匹配要用 Hamming 距离;SIFT 是 float32,匹配要用 L2。把 ORB 描述子交给 FlannBasedMatcher(KD-tree 索引)实测直接抛异常:

python 复制代码
orb = cv2.ORB_create(nfeatures=1500)
_, do = orb.compute(big, orb.detect(big, None))     # uint8
fl = cv2.FlannBasedMatcher({"algorithm": 1, "trees": 5}, {"checks": 64})
fl.knnMatch(do, do, k=2)
# cv2.error: ... /modules/flann/src/miniflan... : assertion failed

而更危险的是这一行:

python 复制代码
bf = cv2.BFMatcher(cv2.NORM_L2)      # 拿 L2 去比二进制描述子
bf.knnMatch(do, do, k=2)             # ✅ 不报错!8.63 ms

它照常返回结果 。L2 距离作用在 0/1 串上得到一个无意义的数,匹配数量看起来完全正常,只是排序全错。你不会看到任何异常,只会拿到更差的配准。

规则 :BFMatcher 的 normType 必须与描述子类型配套------uint8 → NORM_HAMMING,float32 → NORM_L2。这一行没有任何运行时检查。

C++ 侧同样没有检查,所以把配对关系写成工厂函数,让类型错误在构造期就暴露:

cpp 复制代码
// C++(未编译,签名依据 4.13 文档):描述子类型 ↔ 距离函数 的强制配对
static int normFor(const cv::Mat& desc) {
    return desc.type() == CV_8U ? cv::NORM_HAMMING : cv::NORM_L2;   // 唯一一处判断
}
static cv::Ptr<cv::DescriptorMatcher> makeMatcher(const cv::Mat& d) {
    // CV_8U 走 FLANN 只有 LSH 一条路,5.0.0 上不可用(§8.2)→ 一律 BF
    return cv::makePtr<cv::BFMatcher>(normFor(d));
}

把 normFor 收成唯一判断点,是这条坑在 C++ 侧唯一可靠的防法:靠 code review 记住"别把 L2 用在 uint8 上"是不可靠的。

8.2 Flann 的 LSH 模式在 5.0.0 上不可用

想给二进制描述子用 Flann,直觉是走 LSH:

python 复制代码
cv2.FlannBasedMatcher({"algorithm": 6, "table_number": 12,
                        "key_size": 20, "multi_probe_level": 2},
                       {"checks": 64})
# ValueError: not enough values to unpack (expected 2, got 1)

实测 5.0.0 上这条路走不通,报的还是一个完全不知所云的解包错误 (跟 LSH 配置毫无关系)。二进制描述子就用 BFMatcher(NORM_HAMMING),别绕 Flann。

8.3 「大图用 FLANN 比暴力快」------实测反了

这条建议流传极广。我用真实 SIFT 描述子 (3395×128 float32)扫规模,两种匹配器都含建索引成本:

描述子数 N BFMatcher FlannBasedMatcher(KD) FLANN / BF
200 0.16 ms 6.53 ms 41.59×
500 0.65 ms 15.46 ms 23.66×
1000 2.69 ms 31.92 ms 11.86×
2000 10.79 ms 69.59 ms 6.45×
4000 31.96 ms 135.58 ms 4.24×

两条曲线清清楚楚:

  • BF 是二次的:0.16 → 0.65 → 2.69 → 10.79 → 31.96,规模翻倍耗时 ×4。
  • Flann 是次二次的:6.53 → 15.46 → 31.92 → 69.59 → 135.58,翻倍约 ×2.2。

外推交点在 N ≈ 2 万~3 万 ------远超单帧图像能产生的描述子数量 。所以在你能遇到的任何单帧规模上,Flann 一直更慢,只是倍数在缩小。

要诚实的一点 :Flann 的常数项很大(索引构建固定开销高),所以小规模下差距是 40 倍这种极端值。如果你在 2 万描述子以上做批量匹配(不是单帧),结论会翻转------但那种场景本篇没测。

8.4 Lowe 比率测试:经典阈值不是实测最优点

我第一次测这个是把描述子集和它自己匹配 ,结果从 ratio 1.01 到 0.5 全部保留 100% 的匹配------因为自匹配的最近邻距离是 0,任何比率都通过。这个测试不可能失败,等于没测。

改成同一个场景的两个真实视角 (透视变换:旋转 17°、缩放 1.25),ORB + BFMatcher(NORM_HAMMING):

ratio 保留匹配 单应内点 四角误差
1.01(不过滤) 3000 1649 0.882 px
0.95 2288 1568 0.317 px
0.9 1885 1483 0.278 px ← 最优
0.8 1439 1285 0.319 px
0.75 1267 1158 1.463 px
0.7(教科书值) 1138 1061 0.418 px
0.6 849 812 0.756 px
0.5 562 548 1.237 px

三条可用的结论:

一、不做比率测试,匹配数虚高 82%。 3000 个匹配里只有 1649 个是几何内点,四角误差 0.882 px------比 0.9 阈值差 3.2 倍。

二、教科书值 0.7 在这张图上不是最优。 0.9 给出 0.278 px,0.7 给出 0.418 px。而且曲线非单调(0.75 那一格 1.463 px 比它两侧都差)------RANSAC 落到了略微不同的共识集上。

三、SIFT 完全不同。 同样扫比率,四角误差是 0.161 / 0.158 / 0.158 / 0.156 / 0.158 / 0.159 / 0.158 / 0.149 px,基本是一条平线 ,而内点数从 1243 掉到 764。SIFT 的几何精度对比率阈值几乎不敏感,ORB 敏感。

所以 0.7 这个默认值的合理用法是:当成起点,然后看 §十 的"我有没有在骗自己"那套判据去调,而不是当常量照抄。


九、findHomography 与 RANSAC:不加会错成什么样

9.1 规划里的说法是"更准",我给它一个数字

规划写的是"findHomography 必须配 RANSAC 剔除错误匹配",但没说不配的后果 。我造了一个 40% 粗差点的夹具(240 点,真内点 144 个,重投影噪声 σ=0.3 px,真值已知),然后用 err < 3 px 判定内点来给每个方法的估计打分:

method 报告内点 精确率 召回率 中位误差 耗时
0(普通最小二乘) 0 0.000 0.000 145.001 px 1.18 ms
LMEDS 144 1.000 1.000 0.329 px 1.68 ms
RHO 144 1.000 1.000 0.329 px 0.14 ms
RANSAC 144 1.000 1.000 0.329 px 0.99 ms
USAC_DEFAULT 144 1.000 1.000 0.329 px 1.00 ms
USAC_FAST 144 1.000 1.000 0.329 px 0.18 ms
USAC_ACCURATE 144 1.000 1.000 0.329 px 0.42 ms
USAC_PROSAC 144 1.000 1.000 0.329 px 0.19 ms
USAC_MAGSAC 144 1.000 1.000 0.329 px 0.29 ms

普通最小二乘的中位误差是 145 px,其余全是 0.329 px------差 441 倍。 而且它报告 0 个内点。

更扎心的一条:LSQ 耗时 1.18 ms,比 RHO 的 0.14 ms 还慢 。"为了速度省掉 RANSAC"在时间和精度上同时是错的。

再看 RANSAC 值本身(实测于 5.0.0):

复制代码
LMEDS = 4,  RHO = 16,  RANSAC = 8
USAC_DEFAULT = 32,  USAC_PARALLEL = 33,  USAC_FM_8PTS = 34
USAC_FAST = 35,  USAC_ACCURATE = 36,  USAC_PROSAC = 37,  USAC_MAGSAC = 38

同一组精度下,RHO 比 RANSAC 快 7.07 倍 (0.99 / 0.14),USAC_FAST 快 5.5 倍。这不是"谁更准"的问题,是同等精度下的常数选择。

9.2 干净数据上 RANSAC 是纯开销

反过来的对照组(0% 粗差点):

method 中位误差 最大误差
0(普通最小二乘) 0.3399 px 1.2440 px
RANSAC 0.3399 px 1.2440 px

逐位相同。 所以 RANSAC 的价值完全来自外点 ,不是来自"算法更好"。如果你的匹配保证无错配(合成图、已知精确对应),加 RANSAC 纯属浪费------这不是叫你别加,是叫你知道你在为哪种风险付费。

9.3 4 自由度还是 8 自由度:estimateAffinePartial2D 的实测边界

  • estimateAffinePartial2D:4 个自由度(旋转 + 平移 + 等比缩放,2×3 矩阵约束掉 2 个)
  • findHomography:8 个自由度

真值是 2D 相似变换时(旋转 17°):

方法 内点 中位误差 最大误差
estimateAffinePartial2D 240 0.3448 px 1.0967 px
findHomography 240 0.3458 px 1.0784 px

打平(partial2D 略好 0.3%)。少 4 个自由度没有损失,还白拿一个"不可能有透视"的保证。

换成真透视变换时:

方法 内点 中位误差 最大误差
estimateAffinePartial2D 90 / 240 4.2888 px 25.9949 px
findHomography 240 0.3568 px 1.0824 px

partial2D 丢了 62.5% 的点,中位误差差 12 倍,最大误差 26 px。

选型规则很清楚:

复制代码
你的两个视角之间
        │
        ├── 相机大致垂直于平面(无透视)──► estimateAffinePartial2D
        │                                   4 DOF,参数少、更稳
        │
        └── 斜视 / 板材有高度差 / 相机位置会动 ──► findHomography
                                                   8 DOF,必须

AOI 里板材有厚度、镜头有安装高度差,我用的一直是 findHomography------这一条本可以省。

9.4 C++ 对照

cpp 复制代码
// C++(未编译,签名依据 4.13 文档;注意 RHO 不是默认参数)
cv::Ptr<cv::ORB> orb = cv::makePtr<cv::ORB>(3000);
std::vector<cv::KeyPoint> k1, k2;
cv::Mat d1, d2;
orb->detectAndCompute(scene, cv::noArray(), k1, d1);
orb->detectAndCompute(view,  cv::noArray(), k2, d2);

std::vector<std::vector<cv::DMatch>> knn;
cv::BFMatcher matcher(cv::NORM_HAMMING);
matcher.knnMatch(d1, d2, knn, 2);

std::vector<cv::Point2f> p1, p2;
for (auto& pr : knn)
    if (pr.size() == 2 && pr[0].distance < 0.9f * pr[1].distance) {
        p1.push_back(k1[pr[0].queryIdx].pt);
        p2.push_back(k2[pr[0].trainIdx].pt);
    }

cv::Mat inl;
cv::Mat H = cv::findHomography(p1, p2, cv::RHO, 3.0, inl);

十、决策树:切换阈值是量出来的

10.1 旋转容差:门禁保证这张表可信

先说这张表是怎么被验证的 。我上一版把 matchTemplate 峰值和 q1[0] 比,得出"1° 就偏 320 px"这种荒谬数字------因为目标实际落点是 q2[0],我比错了对象 。修正之后我又加了一道门禁:

复制代码
GATE: rotation=0, scale=1.0 时,估计出的 H 必须把真值四角逐像素还原
  ORB  corner_err = 0.07 px   inliers = 1939   gate = PASS
  SIFT corner_err = 0.00 px   inliers = 2374   gate = PASS
  TMPL corner_err = 0.00 px                   gate = PASS

门禁不过就整表作废。 这道门禁确实抓到了我上一版的 bug,所以下面这张表我敢用。

旋转角 模板误差 模板峰值 ORB 误差 / 内点 SIFT 误差 / 内点
0° 0.0 px 1.0000 0.07 / 1939 0.00 / 2374
1° 7.7 px 0.7675 0.06 / 1731 0.01 / 1394
2° 11.4 px 0.6640 0.53 / 1733 0.02 / 1420
3° 17.0 px 0.5930 0.15 / 1759 0.02 / 1420
4° 21.1 px 0.5405 0.38 / 1784 0.03 / 1363
5° 27.2 px 0.5017 0.23 / 1705 0.04 / 1386
6° 31.8 px 0.4682 0.78 / 1730 0.04 / 1367
8° 42.5 px 0.4073 0.12 / 1773 0.06 / 1391
10° 52.6 px 0.3466 0.23 / 1713 0.07 / 1454
15° 68.7 px 0.2434 0.12 / 1733 0.10 / 1394
20° 85.2 px 0.2204 0.23 / 1778 0.13 / 1506
30° 130.2 px 0.2304 0.32 / 1703 0.19 / 1405
45° 195.7 px 0.1915 0.44 / 1667 0.28 / 1323

1° 旋转:模板错 7.7 px,ORB 错 0.06 px------差 128 倍。

而 45° 时模板已经偏了 195.7 px(远超工件尺寸),ORB 仍然 0.44 px、SIFT 0.28 px。

峰值值是个可用的拒绝信号 :0° 时 1.0000,1° 时掉到 0.7675,5° 时 0.5017。峰值低于 0.8 就该警惕,低于 0.5 基本可以断定超过 5° 旋转。

10.2 缩放容差:2% 就已经崩了

缩放 模板误差 模板峰值 ORB 误差 / 内点
1.00 0.0 px 1.0000 0.07 / 1939
1.02 8.5 px 0.7487 0.51 / 1695
1.05 20.0 px 0.5929 0.21 / 1466
1.10 33.0 px 0.5005 0.31 / 1099
1.20 82.6 px 0.3691 0.27 / 1427
1.30 140.1 px 0.3016 0.41 / 926
1.50 438.5 px 0.3152 0.93 / 773
2.00 642.4 px 0.3072 0.86 / 348

2% 的缩放变化 = 8.5 px 误差。 模板匹配没有缩放容差 ------不是"容差小",是没有。ORB 到 1.5 倍(0.93 px)都还稳,2.0 倍时内点掉到 348。

这个数字比旋转那行更值得记 :机械臂重复定位差 2% 是常事,这意味着模板匹配在这个精度上根本不可用。

10.3 组合工况

工况 模板误差 ORB SIFT
旋转 20° + 缩放 1.3 308.6 px 0.40 px 0.18 px
旋转 35° + 缩放 1.2 168.8 px 0.44 px 0.25 px
旋转 45° + 缩放 1.5 249.3 px 1.52 px 0.39 px

10.4 决策树(带量化阈值)

复制代码
                    你的定位任务
                          │
        ┌─────────────────┴──────────────────┐
        │ 目标角度变化会不会超过 1°?         │
        │ 缩放变化会不会超过 2%?             │
        └─────────────────┬──────────────────┘
                          │
          ┌───────────────┴───────────────┐
          │ 否(两项都不超)               │ 是(任一超)
          ▼                               ▼
   ┌──────────────┐              ┌────────────────────┐
   │ 亮度线性变化? │              │ 目标区域纹理够吗? │
   └──────┬───────┘              └────────┬───────────┘
          │                                │
    ┌─────┴─────┐              ┌───────────┴──────────┐
    │ 是        │ 否            │ 够(几十个角点以上)  │ 不够
    ▼          ▼              ▼                      ▼
CCORR_    TM_SQDIFF    ORB+BF+         两条都不行:
NORMED    (最快,§四)  findHomography   换打光、加标记、
                        (RHO, §九)      或降级到轮廓法(第7篇)

这就是"什么时候必须上 ORB 和 RANSAC"的量化答案:角度容差 1°、缩放容差 2%。超过其中任何一个,模板匹配就给出了错误答案,而且不报错。

10.5 案例:粗定位(模板)+ 精配准(特征 + 单应)

两段式流水线是产线上最实用的组合,各用其长:

复制代码
 相机 4000×3000 原始图
    │ ① 降采样 4×(省 16 倍算力,§四)
    ▼
 ┌──────────────────────────┐
 │ 粗定位 matchTemplate     │ ← 只需框进 ROI,±20 px 够
 │ TM_CCORR_NORMED 小模板   │    不需要亚像素精度
 └──────────────────────────┘
    │ 得到候选框 → 裁 ROI + 放大回原分辨率
    ▼
 ┌──────────────────────────┐
 │ 精配准 ORB + BFMatcher   │ ← 0.1 px 级旋转/缩放补偿
 │ + findHomography(RHO)    │    只有特征法给得到(§10.1)
 └──────────────────────────┘
    │ 亚像素位姿 + 内点比例(H 与 mask.sum())
    ▼
 交给第 8 篇的几何测量(minAreaRect / 圆度)

为什么这样组合 :粗定位只需把目标框进 ROI,模板匹配在这个精度要求下最快(§四:比 CCOEFF_NORMED 快 1.5 倍);精定位需要 0.1 px 级的旋转/缩放补偿,只有特征法给得到 。两段参数也不打架------粗定位容忍光照漂移(用 CCORR_NORMED),精配准要严格几何一致(用 RANSAC)。

python 复制代码
import cv2, numpy as np

def locate(scene, tpl, orb=None):
    """粗定位 + 精配准。返回 (H, coarse_box, n_inliers)。"""
    # ---- ① 粗定位:降采样 + 小模板 CCORR_NORMED(无 mask,不产生 NaN,§5.3)
    small  = cv2.resize(scene, None, fx=.25, fy=.25, interpolation=cv2.INTER_AREA)
    t_small = cv2.resize(tpl, None, fx=.25, fy=.25, interpolation=cv2.INTER_AREA)
    _, score, _, loc = cv2.minMaxLoc(
        cv2.matchTemplate(small, t_small, cv2.TM_CCORR_NORMED))
    if score < 0.80:                       # 峰值 0.8 ≈ 1° 旋转(§10.1)
        return None, None, 0               # 拒绝:别硬着头皮往下走

    sx, sy = loc[0] * 4, loc[1] * 4        # 映射回原图
    pad = tpl.shape[1] // 4
    x0, y0 = max(0, sx - pad), max(0, sy - pad)
    x1 = min(scene.shape[1], sx + tpl.shape[1] + pad)
    y1 = min(scene.shape[0], sy + tpl.shape[0] + pad)
    roi = scene[y0:y1, x0:x1]
    box = (x0, y0, x1, y1)
    if roi.size == 0:
        return None, box, 0

    # ---- ② 精配准:ORB + RANSAC(RHO)
    orb = orb or cv2.ORB_create(nfeatures=1500)
    kA, dA = orb.compute(roi, orb.detect(roi, None))
    kB, dB = orb.compute(tpl, orb.detect(tpl, None))
    if dA is None or dB is None or len(kA) < 8 or len(kB) < 8:
        return None, box, 0

    rows = cv2.BFMatcher(cv2.NORM_HAMMING).knnMatch(
        np.ascontiguousarray(dA), np.ascontiguousarray(dB), k=2)
    good = [a for a, b in rows if b and a.distance < 0.9 * b.distance]
    if len(good) < 4:
        return None, box, 0

    pA = np.float32([kA[a.queryIdx].pt for a in good]).reshape(-1, 1, 2)
    pB = np.float32([kB[a.trainIdx].pt for a in good]).reshape(-1, 1, 2)
    H, mask = cv2.findHomography(pA, pB, cv2.RHO, 3.0)
    if H is None or mask is None or H[2, 2] == 0:   # 退化:不可逆(静默失败,§9)
        return None, box, 0
    return H, box, int(mask.sum())

注意粗定位那一步我用了 TM_CCORR_NORMED 而不是 TM_CCOEFF_NORMED :粗定位不需要去均值(工件是刚体,亮度增益是主要变化),而 CCORR_NORMED 便宜 1.5 倍(§四)。但我不用它的 mask 参数 ------CCORR_NORMED 加 mask 在平坦区会引入 NaN(§5.3),除非场景确定没有大面积纯色区。这里是个明确的取舍,不是最优解。

10.6 我怎么知道自己没在骗自己

三条可自动化的判据,缺一不可:

  1. 内点数占比 :mask.sum() / len(good) 低于 30% 就该怀疑匹配质量(§八.4:不过滤比率时 3000 个匹配只有 55% 是内点)。
  2. 残差中位数 :把内点送过 H,看残差。实测健康值是 0.3~0.5 px,超过 1 px 说明 H 不对。
  3. 自洽性门禁 :合成图上你必须 能拿回已知变换。产线上做不了,就定期用一张已知姿态的样本图跑一遍------这就是我 §10.1 门禁的做法,它抓到了我自己的 bug。

避坑指南

坑 1:方向取反,误差可达 212 px

现象:模板匹配返回了一个"看起来合理"但偏 180~212 px 的位置。

python 复制代码
# ❌ 错:靠 min<max 猜方向
loc = cv2.minMaxLoc(res)[3]      # 一律取 max

# ✅ 对:方向是方法的属性,查表
MIN_METHODS = {"TM_SQDIFF", "TM_SQDIFF_NORMED"}
loc = cv2.minMaxLoc(res)[2 if m in MIN_METHODS else 3]

为什么 :TM_CCOEFF 虽然没归一化,但它去掉了均值,响应是中心化向量的内积,取最大值。我因为"残差类应该取最小"而搞反,实测偏 81 px。

坑 2:TM_SQDIFF_NORMED 不抗亮度变化

现象 :以为加了 _NORMED 就能扛光照,结果曝光一变就飘(❌ 用 TM_SQDIFF_NORMED,✅ 改 TM_CCOEFF_NORMED,§3.1 六格全过只有后者)。

为什么 :_NORMED 除以窗口平方和范数,但除不掉直流分量 。实测 SQDIFF_NORMED 和 SQDIFF 的通过/失败模式逐格相同。

坑 3:masked TM_CCOEFF_NORMED 返回 NaN,然后 argmax 给你错的答案

现象:加了 mask 之后定位偏了几十像素,日志里什么都没有。

python 复制代码
# ❌ 错:argmax 遇到 NaN 会选到 NaN 位置(实测偏 40 px)
y, x = np.unravel_index(np.argmax(r), r.shape)
# ✅ 对:先处理 NaN,再取极值
y, x = np.unravel_index(np.argmax(np.nan_to_num(r, nan=-np.inf)), r.shape)

为什么 :掩码内窗口方差为 0 时归一化 0/0。实测 NaN 位置与"掩码区域方差 < 1e-9"的位置集合精确相等(9317 vs 9317)。纯色背景图上 44.1% 的像素是 NaN。

坑 4:np.argmax 与 cv2.minMaxLoc 对 NaN 的行为不一致

现象 :C++ 版跑得好好的,Python 版偏了几十像素------cv2.minMaxLoc(r_with_nan) 返回的 minLoc 与 maxLoc 是同一个点 (-1,170)。

为什么 :Python 的 ndarray 比较把 NaN 传播给 argmax;cv::minMaxLoc 不传播。同一份算法,跨语言行为不同------这是本系列第一次遇到。

坑 5:BFMatcher 的 normType 传错不报错

现象:匹配数量完全正常,配准结果就是差。

python 复制代码
# ❌ 错:L2 作用在 uint8 二进制描述子上,无意义但不报错(实测 8.63 ms,正常返回)
bf = cv2.BFMatcher(cv2.NORM_L2)
# ✅ 对:二进制串用 Hamming(实测 5.17 ms)
bf = cv2.BFMatcher(cv2.NORM_HAMMING)

为什么 :这行没有任何运行时检查 。normType 只影响距离计算,不影响返回的匹配数量。

坑 6:把 ORB 描述子喂给 Flann

现象 :cv2.FlannBasedMatcher({"algorithm":1,"trees":5}).knnMatch(orb_desc, orb_desc, k=2) 直接抛 cv2.error(miniflan 断言失败)。

为什么 :FLANN 的 KD-tree 实现要求浮点描述子。uint8 二进制串必须走 Hamming + LSH 路线,而 5.0.0 上 LSH 又不可用(坑 7)。

坑 7:5.0.0 上 Flann 的 LSH 报一个不知所云的解包错误

现象 :cv2.FlannBasedMatcher({"algorithm":6,"table_number":12,"key_size":20,"multi_probe_level":2},{"checks":64}) → ValueError: not enough values to unpack (expected 2, got 1)。

为什么 :错误信息与 LSH 配置毫无关系,排错时极易误判成参数类型问题 。二进制描述子直接用 BFMatcher。

坑 8:AKAZE / BRISK 在本机 wheel 上取不到------但不是被删了

现象 :按 4.x 文档写 orb = cv2.AKAZE_create(),5.0 上直接 AttributeError。

为什么 :实测三种访问方式全部失败(getattr / hasattr / from cv2 import),dir(cv2) 里连残留符号都没有。

处置 :pip install opencv-contrib-python(与主 wheel 同版本)把它们拿回来。官方迁移文档写明这批算子是 moved to opencv_contrib(xfeatures2d) ,不是移除;opencv-python 这个 wheel 从来不打包 contrib,所以缺的是发行包而不是代码。注意 FAST 只是改名成 FastFeatureDetector_create,还在------改名和搬家都会让符号消失,但处置完全相反。详见 §7.2、§7.3。

坑 9:GFTTDetector.compute() 抛 (-213)

现象 :g.detect(img, None) ✅ 正常,g.compute(img, kps) ❌ (-213: not implemented)。

为什么 :GFTTDetector 继承 Feature2D,白拿了它实现不了的 compute()。它只能定位,不能配准。

坑 10:把 qualityLevel 当百分比

现象:调到 0.05 以为能留下 5% 的角点,结果数量纹丝不动(实测 24 → 仍是 24)。

python 复制代码
# ✅ 对:看返回个数判断是否生效(实测 0.0001→200, 0.001→61, 0.003→24)
for ql in (0.0001, 0.001, 0.003, 0.01):
    print(ql, len(cv2.goodFeaturesToTrack(img, 200, ql, 5)))

为什么 :它是相对最强角点响应的乘子,不是分位数。多数取值区间根本不咬数据。

坑 11:测试源太密,参数怎么调都不动

现象 :写完参数扫描,blockSize 从 1 到 21 全部返回 500(maxCorners=500 把它顶住了)。

为什么 :角点总数多于 maxCorners 时,任何响应函数的差异都被上限吃掉,这个测试永远会通过 。先量"这张图最多有多少角点" (实测密集图 ≥2000、稀疏图 348),再定 maxCorners。

坑 12:比率测试的自匹配陷阱

现象 :BFMatcher(NORM_HAMMING).knnMatch(d, d, k=2)------描述子集和自己匹配,最近邻距离恒为 0,实测 ratio 从 1.01 到 0.5 全部保留 100%。

为什么 :任何比率阈值都通过。必须用两幅真实视角(实测两视角下不过滤时 3000 个匹配里只有 55% 是几何内点)。

坑 13:Lowe 0.7 不是最优值

现象:照抄 0.7,配准精度比 0.9 差 50%(实测两视角 ORB:0.9 → 0.278 px;0.7 → 0.418 px;0.75 → 1.463 px)。

为什么 :最优比率随场景变,当起点用,然后按 §10.6 的三条判据调。而且 SIFT 的精度对比率几乎不敏感(0.149~0.161 px 全平),ORB 敏感。

坑 14:不加 RANSAC,误差 145 px

python 复制代码
# ❌ 错:method=0 是普通最小二乘,40% 外点下中位误差 145.001 px
H, mask = cv2.findHomography(pA, pB, 0, 3.0)
# ✅ 对:RHO 与 RANSAC 同精度但快 7.07 倍
H, mask = cv2.findHomography(pA, pB, cv2.RHO, 3.0)

为什么 :最小二乘对每个点等权,一个外点能把整个解拉走。实测 LSQ 还比 RHO 慢(1.18 vs 0.14 ms)------省 RANSAC 在时间和精度上同时是错的。

坑 15:有透视时用 estimateAffinePartial2D

现象:斜视场景下配准中位误差 4.3 px、最大 26 px,内点只剩 90/240。

python 复制代码
# ❌ 错:有透视时 4 自由度不够(实测最大误差 25.99 px)
A, m = cv2.estimateAffinePartial2D(pA, pB, method=cv2.RANSAC)
# ✅ 对:透视场景必须 8 自由度(实测内点 240,中位 0.357 px)
H, m = cv2.findHomography(pA, pB, cv2.RHO, 3.0)

为什么 :4 自由度模型把透视当不存在。但反过来也成立 :真相似变换下两者打平(0.3448 vs 0.3458 px),用 findHomography 没有损失。

坑 16:按"输出位置 × 模板面积"估耗时

现象:预算 1024² 图配 256² 模板,按大纲公式估要几十毫秒,实测 9.45 → 12.80 ms(面积差 16 倍,耗时只差 1.35 倍)。

为什么 :实测 cost ≈ c·(W·H),c ≈ 9~15 ns(TM_SQDIFF) / 17~23 ns(TM_CCOEFF_NORMED);ns/输入像素 在同方法内几乎是常数,ns/输出像素 变化 2.4 倍。先算输入像素。


优秀实践

一、把方向表写成常量,别每次现场判断。

python 复制代码
# 放在你的 utils 模块里,全项目统一
MATCH_MIN = {"TM_SQDIFF", "TM_SQDIFF_NORMED"}

def best_match(res, method):
    mn, mx, mnl, mxl = cv2.minMaxLoc(res)
    if method in MATCH_MIN:
        return mn, mnl
    return mx, mxl

我自己的教训(坑 1)是用推理代替查表。方向只有 6 个取值,写死比判断可靠。

二、任何 NORMED 方法的结果,取极值之前先查 NaN。

python 复制代码
r = cv2.matchTemplate(img, tpl, method, None, mask)
if np.isnan(r).any():
    # 记录告警:这个图有大面积平坦区,mask 区域方差为 0
    r = np.nan_to_num(r, nan=-np.inf if method not in MATCH_MIN else np.inf)

这是把静默失败变成显式告警的最小改动,成本一行。

三、配准函数里内置"我有没有在骗自己"的三条判据。

不要让调用方自己判断内点数够不够。返回一个诊断结构:

python 复制代码
return {
    "H": H, "n_inliers": int(mask.sum()),
    "inlier_ratio": mask.sum() / max(1, len(good)),
    "median_resid": resid,          # 健康区间 0.3~0.5 px(实测)
}

§10.6 的三条判据固化在函数里,比写在文档里有用。


可改进之处

一、我的夹具全是合成的,泛化性未验证。 合成图角点密度高、结构规整,比真实板材"好匹配" 。§10.1 里 ORB 在 45° 仍有 1667 个内点,真实低对比板材很可能达不到。真实产线数据的容差边界只会比本文更窄。

二、NaN 机制只解释到"零方差 → 除零",没有解释为什么只有 TM_CCOEFF_NORMED 中招。 TM_CCORR_NORMED 同样归一化、同样加 mask,实测 NaN 数为 0。这个差异我没查到实现层原因。

三、mask 保留区大小的规律没测出来。 §5.6 显示 TM_CCOEFF_NORMED 在保留 24~40 列时失败、保留 16 列时成功,我没有构造能区分"像素太少"与"方差太小"的夹具,所以不给机制。

四、descriptor 匹配的召回率没测。 §八 只比了耗时和四角误差,没测"同样的 ORB 参数在不同场景下召回率掉多少"------这是选 ORB vs SIFT 最关键的指标之一,本篇缺了。


工程实践

版本锁定 :本篇所有结论分三类标注------「实测于 5.0.0」(像素行为、耗时)、「4.13 文档」(签名语义)、「官方 4→5 迁移文档」(模块搬家归属)、「未测 」(§改进之处全部)。跨版本迁移时,第 7、8 两个坑必须重新验证(AKAZE/BRISK 搬运、LSH 不可用都是 5.0 的结论,4.x 上不一定成立)。

配置化:把三个最敏感的参数提到配置里,其余固定:

yaml 复制代码
match:
  method: TM_CCOEFF_NORMED     # 唯一六格全过(§3.1)
  min_peak: 0.80               # <0.8 警惕旋转(§10.1 峰值表)
register:
  detector: ORB
  nfeatures: 3000
  norm: NORM_HAMMING           # 配 uint8 描述子
  ratio: 0.9                   # 实测最优,非教科书 0.7(§8.4)
  method: RHO                  # 同精度快 7.07×(§9.1)
  ransac_px: 3.0
  min_inlier_ratio: 0.30       # §10.6 判据一
  max_resid: 1.0               # §10.6 判据二

测试 :合成一张已知变换的图,每轮 CI 跑一遍自洽性门禁(§10.1 的做法)。这道门禁抓到过我的真 bug。


算子选型速查表

算子 一句话作用 什么时候用 什么时候别用 关键参数 主要代价
matchTemplate 滑窗比像素找模板 目标零旋转零缩放 角度 >1° 或缩放 >2% method、mask 约 9~23 ns/输入像素
TM_SQDIFF 平方差 光照完全可控,要最快 有任何亮度变化 --- 最便宜的一档
TM_CCORR_NORMED 归一化互相关 只有增益变化;做粗定位 有加性偏移 --- 与 SQDIFF 同档
TM_CCOEFF_NORMED 去均值归一化相关 默认首选,六格全过 加了 mask 且图有大面积纯色 mask 贵 1.4~1.7×
goodFeaturesToTrack Shi-Tomasi 角点 只要定位点、不要描述子 需要配准 qualityLevel 相对阈值 极低
ORB 二进制特征 默认选它 极低纹理 nfeatures detect 慢、compute 快
SIFT 稠密浮点特征 精度优先、尺度变化大 算力/内存吃紧 nfeatures compute 贵 6.82×
FastFeatureDetector FAST 角点 要极快检测 需要尺度金字塔 threshold 最快
BFMatcher 暴力匹配 单帧规模首选 描述子 >2 万 normType 必须配套 二次增长
FlannBasedMatcher KD-tree 近邻 描述子规模很大 单帧(N<4000 慢 4~42×) algorithm、checks 常数项大
findHomography 求单应矩阵 有透视 ------ method 见下 亚毫秒
estimateAffinePartial2D 求 4 自由度相似 无透视(正对平面) 有透视(实测丢 62% 内点) method 同上
findHomography + RHO 鲁棒单应 默认组合 匹配保证无错配 ransacReprojThreshold 0.14 ms
+ RANSAC 经典鲁棒 保守选择 想省 7 倍时间 同上 0.99 ms

方法论:这一篇我把夹具搞废了五次

本篇的实测数字质量,最终取决于夹具质量。我在这上面翻了五次车,每一次都是同一个错误的不同变体,所以把它完整记下来。

# 错在哪 症状 怎么发现的
1 方向表靠记忆 TM_CCOEFF 判成取 min,偏 81 px 拿已知真值去问数据
2 模板接近均匀 + 黑背景 TM_SQDIFF 在 ×2 提亮下"通过" 看到它违反理论,去查夹具
3 minMaxLoc 按 2 个值解包 脚本直接崩 报错
4 描述子自匹配做比率测试 ratio 1.01~0.5 全部 100% 数字太整齐,反常
5 maxCorners 顶住 / 比错目标点 参数全等效、误差 320 px 加门禁(rot=0 必须亚像素)

共同根因 :我总是先写代码再看数字 ,而不是先问"这个测试能不能失败" 。第 5 次尤其值得记------如果我不加那道"rot=0 必须亚像素"的门禁,那张 320 px 的表会以"旋转容差"的名义进文章,而且看起来非常合理。

这是本系列第十二次固化流程规则(前 11 次分别由第 4~8 篇确立)。本篇的增量是:给每一张对比表配一道"已知条件下必须成立"的门禁,让夹具错误无处可藏。


本期互动

三个可以直接落地验证的点:

  1. 在你的场景里量一下角度容差。 用 §10.1 的方法:绕真值旋转 0°/1°/2°,看 matchTemplate 峰值掉到多少。如果你的相机和模板之间存在哪怕 1° 的安装偏差,你的模板匹配现在就在给你错答案。
  2. 给你的配准函数加内点比例和残差两个返回值。 成本很小,但能让"偶尔偏几十像素"这类问题从"客户投诉"变成"日志告警"。
  3. 验证一次你的 Flann 是不是白用了。 打印描述子数量 d.shape[0]。小于 2 万,Flann 一定比 BF 慢(§八.3 实测 4~42 倍)------你可能优化了一个不存在的问题。

💬 评论区聊聊

说说"刚性"这件事。

写这篇时我反复回到一个念头:模板匹配本质上是一个不承认世界会动的假设。 它假设那块模板在场景里还是那块模板,旋转 0°、缩放 1.0、亮度线性。而这条假设在真实产线上永远不成立------你只是不知道它什么时候会以什么程度失效。

§10.2 那行数字是我最想拿出来讨论的:2% 的缩放变化 = 8.5 px 误差。 机械臂重复定位、镜头焦距漂移、板材放反,这三种情况都轻易产生 2% 缩放。这意味着模板匹配在我们的使用场景里,容错窗口窄到近乎不存在------而它依然是教程里教定位的第一课。

反过来说,ORB/SIFT 的代价也很实在:11 个参数、要调比率阈值、算描述子比 ORB 贵 6.82 倍、算子数量还从 2 个变成 6 个。我们其实是在用一个复杂得多的系统,去换那 1° 和 2% 的容错。 我不确定这个交换在你们的场景里划算------如果你的场景真的零形变,模板匹配赢得很彻底,而且便宜得多。

想听听大家两件事:你们的定位任务里,角度和缩放的容差到底是多少 ?以及------有没有人试过用 goodFeaturesToTrack 的角点直接送进 findHomography(跳过描述子),靠"角点位置本身就是特征"来做配准? §六 实测 Shi-Tomasi 和 Harris 在矩形角点上选了完全相同的 24 个点,这让我怀疑:在几何特征足够强的场景下,描述子可能是多余的。


👉 收藏 · 转发 · 系列目录

本篇把"该用模板匹配还是特征匹配"从一句"看你需求"换成了两个可测的数字 :角度 1°、缩放 2% 。超过任何一个,matchTemplate 就在安静地给你错答案;低于任何一个,ORB + RANSAC 那套六个算子就是纯粹的浪费。

如果你在做定位,这篇有几张表值得存下来:§二 的方向表 (只有两种方法取最小值,TM_CCOEFF 取最大------我第一次写反了,偏 81 px)、§四 的成本模型 (按输入像素估,9~23 ns,别按模板面积)、§五 的 NaN 陷阱 (masked CCOEFF_NORMED 在纯色区返回 NaN,argmax 静默偏 40 px)、§九 的 RANSAC 选型 (RHO 与 RANSAC 同精度但快 7.07 倍,而不加鲁棒估计的误差是 145 px )、§十 的两张容差表。

另外两条被广泛传播但实测为错 的建议,记在这里省你时间:"NORMED 系列抗亮度变化" ------TM_SQDIFF_NORMED 和没归一化的版本失败模式逐格相同;"大图用 FLANN 比暴力快" ------N≤4000 时 FLANN 慢 4~42 倍,交点在 2 万描述子以上。

还有一条版本红线 (这是官方 4→5 迁移文档写的,不是我推测的):SURF、BRIEF、FREAK、LUCID、DAISY 等一批算子搬去了 opencv_contrib ,而 SIFT / ORB / FAST / GoodFeaturesToTrack / MSER 留在主干。如果你按 4.x 文档写代码又只装了 opencv-python,那批算子会直接 AttributeError------处置是 pip install opencv-contrib-python,不是改代码。

顺带记一条我自己的错:我最初把这写成了"5.0 把这些算子删了",还给了一个"专利没过期所以消失"的解释。那个解释是我编的 ,方向也是错的。hasattr 返回 False 只能证明"这个名字不存在",证不了"这个能力被删了"------改名和搬家都会让符号消失,而处置完全相反。

相关推荐
研來如此3 小时前
OpenCV官方下载界面下载opencv库
人工智能·opencv·计算机视觉
程序人生88811 小时前
PDF 转 Word 版式错乱、表格丢失?DocConverter Web 格式互转引擎的保真实践
前端·人工智能·opencv·机器学习·pdf·word
HUANGZHENXUAN12314 小时前
代码解析(一):从 `opencv_basics/` 说起
opencv
词却1 天前
OpenCV学习:MediaPipe 人脸网格检测
opencv·学习
richard_yuu2 天前
OpenCV 实战第 8 篇:几何测量算子族,从 boundingRect 到亚像素定位
人工智能·opencv·计算机视觉
程序人生8882 天前
从“把隐私文件上传到别人云端“到本地可控:DocConverter Web 立项初心与架构选型
前端·图像处理·人工智能·opencv·架构·ocr·文心一言
ab1237682 天前
OpenCV 学习笔记
笔记·opencv·学习
咯哦哦哦哦3 天前
opencv复现 Halcon 双相机拼接
人工智能·数码相机·opencv
源码学社3 天前
图像去水印实测:OpenCV inpainting vs LaMa,传统算法和 AI 模型差距有多大
人工智能·opencv·算法·去水印·图片水印·ai去水印