
什么时候用 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° 就已经错 7.7 px。
- "NORMED 系列抗亮度变化"------对
TM_CCOEFF_NORMED成立,对TM_SQDIFF_NORMED完全不成立。 - "大图用 FLANN 比暴力匹配快"------实测在 N≤4000 时 FLANN 慢 4~42 倍。
- "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)
两点值得记:
GFTTDetector只能定位,不能配准。 它继承自Feature2D,于是白拿了compute()这条它实现不了的路径。要定位后做几何估计,用goodFeaturesToTrack拿点,配 ORB/SIFT 拿描述子。- 错误信息里的模块名是
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 我怎么知道自己没在骗自己
三条可自动化的判据,缺一不可:
- 内点数占比 :
mask.sum() / len(good)低于 30% 就该怀疑匹配质量(§八.4:不过滤比率时 3000 个匹配只有 55% 是内点)。 - 残差中位数 :把内点送过 H,看残差。实测健康值是 0.3~0.5 px,超过 1 px 说明 H 不对。
- 自洽性门禁 :合成图上你必须 能拿回已知变换。产线上做不了,就定期用一张已知姿态的样本图跑一遍------这就是我 §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 篇确立)。本篇的增量是:给每一张对比表配一道"已知条件下必须成立"的门禁,让夹具错误无处可藏。
本期互动
三个可以直接落地验证的点:
- 在你的场景里量一下角度容差。 用 §10.1 的方法:绕真值旋转 0°/1°/2°,看
matchTemplate峰值掉到多少。如果你的相机和模板之间存在哪怕 1° 的安装偏差,你的模板匹配现在就在给你错答案。 - 给你的配准函数加内点比例和残差两个返回值。 成本很小,但能让"偶尔偏几十像素"这类问题从"客户投诉"变成"日志告警"。
- 验证一次你的 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 只能证明"这个名字不存在",证不了"这个能力被删了"------改名和搬家都会让符号消失,而处置完全相反。