
为什么
minAreaRect明明是全局最小二乘拟合,加了噪声反而不如cornerSubPix?
上一篇讲边缘与轮廓,结尾留了个问题:像素级误差能不能压到毫米级。这篇把度量算子族整体摊开,用实测数据 回答四个问题:minAreaRect返回的角度到底是什么范围、fitEllipse返回的轴是半轴还是全轴、圆度4πA/P²到底在测什么、以及亚像素精化究竟能买到多少精度。其中有三个结论和主流说法相反:fitEllipse是这一族里唯一返回全轴的 、minAreaRect的角度只出现在[-90, 0)、以及亚像素在干净图上不如全局拟合,在噪声图上才碾压它。
版本说明 :本文所有像素级输出、角度偏差、异常行为与噪声扫描均在 OpenCV 5.0.0 实测获得(Python 绑定,opencv-python-headless,numpy 2.4.6)。API 语义以 4.13.0 官方文档发布为准。两者不一致处,本文保留实测结果并标出"实测" ,不做隐藏。本文中cv::getRectSubPix与cv::cornerSubPix的行为结论已对照 5.x 分支源码逐行确认(见第七节,附行号)。代码说明 :本机没有 OpenCV C++ 头文件 ,所以文中的
cv::调用未在本机编译执行;凡涉及cv::行为的数字均来自 Python 侧同版本实测。但本节两段纯 STL 代码(normalizeRotatedRect归一化、2×2 协方差本征分解求主轴)已剥离cv::依赖,用g++实际编译运行 ,输入取自 Python 侧实测的minAreaRect结果与原始矩,两侧输出逐组一致(归一化最大差4.2e-11、主轴最大差2.6e-11)。这一步不是走过场,它直接改写了正文:编译先暴露出(a + 90.0) % 180.0对double非法(须std::fmod),随后与真值比对又暴露出"折回[0,90)"对非正方形有一半输入差 90°。 详见 §二。
上篇回顾
上一篇的核心是阈值自适配:Canny 的固定阈值 50, 150 在 A 光源下刚好,在 B 光源下要么全是噪点、要么一条边都不剩。实测还发现梯度中位数是本篇最差的基准 ------干净图上中位数只有 2.000,落在平坦背景上,而真正代表边缘强度的是 p90 以上的分位。
本篇换到几何测量,但底层还是同一个道理:算子的"默认行为"和它的"统计性质"是两件事 。minAreaRect 在教科书里是"求最小外接矩形",可它返回的角度范围、size 的长短边顺序、以及它对噪声的敏感度,没有一条是文档里写着的;fitEllipse 返回全轴这件事,更是只有你拿一个已知半径的圆去对一遍才会发现。
一、先把"半轴还是全轴"定死,否则后面全是 2 倍和 4 倍的错
arcLength / contourArea / moments 大家都熟,但形状拟合这一族有三个函数都会给出"轴长",而它们的单位约定并不统一。这是我在这篇里踩到的第一个坑,也是最容易在代码里潜伏很久的那种。
我不去翻文档猜,直接拿一个已知半径的正圆去判定:
| 画的圆半径 | fitEllipse 返回 axes |
2R |
R |
minEnclosingCircle 返回 r |
|---|---|---|---|---|
| 60 | (118.98, 118.98) |
120 | 60 | 60.00 |
| 120 | (239.05, 239.05) |
240 | 120 | 120.00 |
结论一目了然:
fitEllipse返回全轴 (直径,2×半轴)minEnclosingCircle返回半径(半轴)- 配套的绘图函数
cv2.ellipse的axes参数也是半轴 ------实测cv2.ellipse(axes=(60, 30))出来的轮廓面积是5662.0,而π·60·30 = 5654.9,π·60·30·4 = 22619.5完全对不上。
所以同一族里三种"轴长"有两种单位。这个不一致会连累两处计算:
cpp
// ❌ 错法 1:把 fitEllipse 的全轴当半轴,面积直接大 2 倍(两个轴各错 2 倍 -> 面积错 4 倍)
auto (center, axes, ang) = fitEllipse(contour);
double area = CV_PI * axes[0] * axes[1]; // 实测:真值 30787.6 -> 算出 122915.5,偏 +299.24%
// ❌ 错法 2:同一段代码里 fitEllipse 和 minEnclosingCircle 混用,两者差 2 倍
double r1 = fitEllipse(cnt)[1][0]; // 279.78 <- 全轴
double r2 = minEnclosingCircle(cnt)[1]; // 140.29 <- 半轴
// ✅ 正确:fitEllipse 的结果记得除 2 变回半轴,再和 minEnclosingCircle 同量纲比较
double a = fitEllipse(cnt)[1][0] / 2.0; // 139.89
double b = fitEllipse(cnt)[1][1] / 2.0; // 69.92
double area = CV_PI * a * b; // 30728.9,相对真值 -0.19%
那个 +299.24% 值得单独拎出来:它不是"拟合得差",而是 4 - 1 = 3,正好是"两个轴各乘 2 倍"的结果。这种误差有个很讨人喜欢的特征------它不报错、不越界、图画出来还挺顺眼 ,属于容易发现的一类。真正致命的是相反的那一类:同一行代码、图一个字节没改,输出就差好几倍,而你的下游全盘接受 。第 7 篇的两个坑正是标本:apertureSize 从 3 改到 5,边缘数量涨 8 倍,而你一个字节的图都没换 ;approxPolyDP 的 eps=0.5,圆上给 124 个点、矩形上给 4 个点,同一条语句,输出差 31 倍。两次都没有任何异常。
顺带一个
minAreaRect的单位:它的size是整边长 ,不是半长。实测 300×140 的板材,minAreaRect给300.066,不需要除 2。所以这一族的单位是:minAreaRect.size整边、minEnclosingCircle半轴、fitEllipse全轴、cv2.ellipse画图半轴。四个函数,四种约定。
二、minAreaRect 的角度:实测只落在 [-90, 0)
这是本篇第二个坑。几乎所有中文教程在拿到 RotatedRect 后都会写一句"角度范围是 (0, 90]",然后给出归一化代码。我在 5.0.0 上把 0--179 度全扫了一遍,结论不一样。
同一个 240×120 的矩形,绕中心旋转不同角度,取 minAreaRect:
| 真值角 | 返回 angle |
返回 size.w |
返回 size.h |
w < h? |
|---|---|---|---|---|
| 0 | -90.00 | 120.00 | 240.00 | 是 |
| 10 | -79.95 | 120.83 | 241.73 | 是 |
| 30 | -60.02 | 120.90 | 240.97 | 是 |
| 45 | -45.00 | 120.21 | 239.00 | 是 |
| 60 | -29.98 | 120.90 | 240.97 | 是 |
| 89 | -0.95 | 121.00 | 241.02 | 是 |
| 90 | -90.00 | 240.00 | 120.00 | 否 |
| 100 | -79.95 | 241.73 | 120.83 | 否 |
| 135 | -45.00 | 239.00 | 120.21 | 否 |
| 179 | -0.95 | 241.02 | 121.00 | 否 |
20 个测试角(0~171,步长 9),返回值全部落在 [-90.0000, -9.1114],落在 (0, 90] 的是 0 个。
而且 size 的长短边在 10/20 的情况下和直觉相反 :真值 0° 时返回 w=120, h=240,真值 90° 时返回 w=240, h=120。而且这 10 个是连续的一段 ------w<h 恰好对应真值 [0, 81](即 [0, 90) 内的前 10 个采样点),w>h 恰好对应其余 10 个。规律很清楚:
- 真值在
[0, 90)→ 返回angle = 真值 - 90,且w < h(短边在 w) - 真值在
[90, 180)→ 返回angle = 真值 - 180,且w > h(长边在 w)
这就是为什么直接把 rect.size.width 当"长边"是错的------它随角度在长短边之间来回跳。归一化要把"边长顺序"和"角度"当成一件事来处理:
cpp
// ✅ 本机用 g++ 实际编译运行验证(纯 STL 版,见文末说明):
// 20 个测试角(0~171 度,步长 9)归一化后与真值最大偏差 0.1114 度,
// 且 C++ 与 Python 两侧实现逐组一致(最大差 4.2e-11)
static void normalizeRotatedRect(const cv::RotatedRect& rr,
double& longSide, double& shortSide, double& angle) {
double a = rr.angle;
double w = rr.size.width, h = rr.size.height;
// 第一步:让 w 成为长边;交换边长的同时必须把角度跟着转 90 度
// 注意 C++ 的 % 只能用于整数,double 必须用 std::fmod
if (w < h) { std::swap(w, h); a = std::fmod(a + 90.0, 180.0); }
// 第二步:折进 [0, 180)------不是 [0, 90),这是本函数最容易写错的地方
if (a < 0.0) a += 180.0;
if (a >= 180.0) a -= 180.0;
longSide = w; shortSide = h; angle = a;
}
为什么是 [0, 180) 而不是 [0, 90)------这一条我写错过,值得单独说。 直觉上"矩形的长边方向每 90° 等价"(因为绕中心转 90° 后长边指向另一个边?不------对 240×120 这种非正方形,转 90° 后长边指向的是原来短边的方向,两者不是 同一条轴),但长轴方向只在 mod 180° 下唯一,从来不在 mod 90° 下唯一。
我最初写的是"先 if (a < 0) a += 90.0 折回 [0,90),再按长短边交换"。这个版本编译通过、单元测试也过了,但对非正方形有一半输入是错的 :truth=135° 实测返回 a=-45、w>h 所以不触发交换 → 被折成 45°,而真实长轴方向是 135°,差整整 90° 。它之所以"看起来对",是因为对正方形(长宽相等)长轴短轴本来就等价------这正是正圆返回 -45° 还能说得通的同一个原因。
我是怎么发现的:把归一化结果与真值逐个比对,最大偏差打印出 90.0000 ------一个可疑的整数,而不是某个小数。第四步才反推出根因。"偏差恰好等于 90°"是一个特征明显的信号,它意味着你把二面角的两个分支混成了一个。
一个必须做的阳性对照:拿正圆去测,角度应该失去意义。
| 形状 | 返回 angle |
返回 size |
|---|---|---|
| 正圆 r=120 | -45.0000 | (239.00, 239.00) |
正圆返回 -45° + 正方形 size,这是退化情形 而不是 bug------旋转不变的东西,角本来就无定义。如果你的代码在正圆上算出"角度 45°",那说明角度来源根本不是 minAreaRect(比如你误读了 approxPolyDP 的结果)。上线前一定要拿正圆跑一次,这是最便宜的健全性检查。
三、moments:角度最稳的来源是协方差主轴
moments 容易被人当成"算面积和质心的",其实它算的原始矩里已经包含了全部二阶信息 ------而二阶矩就是协方差矩阵,它的主轴方向就是形状的主方向。实测 240×120 矩形旋转后,从 mu20 / mu11 / mu02 组协方差做本征分解,主轴角与真值的偏差全部在 0.113° 以内 (20 个测试角实测最大 0.1124°,C++ 与 Python 两侧本征分解逐组一致,最大差 2.6e-11):
cpp
// ✅ 从中心矩直接得到主轴角,不需要任何拟合
cv::Moments m = cv::moments(contour, true); // 注意 5.0 的 C++ 侧返回 Moments 结构体
double m00 = m.m00;
cv::Mat cov = (cv::Mat_<double>(2, 2) << m.mu20 / m00, m.mu11 / m00,
m.mu11 / m00, m.mu02 / m00);
cv::Mat eigenValues, eigenVectors;
cv::eigen(cov, eigenValues, eigenVectors);
cv::Vec2d major = eigenVectors.col(1); // 较大特征值对应的特征向量
double angle = std::atan2(major[1], major[0]) * 180.0 / CV_PI;
这里有两个坑,两个都是我自己测错才定性的,过程比结论更值得记:
坑一:moments(contour) 的 m00 不是"边界像素计数",而是多边形面积------它和 contourArea 精确相等。 我原先写的是"m00 只统计边界上的像素,比面积少 311"。这个说法被一个巧合直接否掉了:同一份轮廓上
| 量 | 30° 矩形实测 |
|---|---|
moments(contour)['m00'] |
28832.0 |
contourArea(contour) |
28832.0 |
moments(image)['m00'](二值图) |
29145.0 |
| 差值(image − contour) | 313.0(1.07%) |
m00 与 contourArea 一位都不差 。原因是两者走的是同一套 shoelace 多边形递推:moments 对轮廓输入时算的是穿过像素中心的那个多边形的面积,不是"数了多少钱素"。
所以正确的说法是:轮廓矩的面积天然偏小 ,因为边界多边形穿过像素中心 而不是像素边界 ,每个方向都少算半个像素的边。30° 的 240×120 矩形上这个缺口是 313 px(1.07%);轴对齐的 0° 情形下因为顶点正好落在整数像素上,缺口为零。想拿真实面积用二值图的矩,或用 contourArea 并接受这 1% 量级的低估。
坑二:我报告的"半像素质心偏移"根本不存在------那是我自己的夹具 bug。 我曾测出 0° 时质心精确是 (400.00, 300.00)、m00=28800,而 30° 变成 (399.50, 299.50)、m00=28521,并据此写下"栅格化的余数摊到了质心上"。这个结论听起来完全合理,但我后来为了对齐夹具把它复算了一遍:
| 30° 矩 | astype(np.int32)(原夹具) |
np.round |
np.floor |
|---|---|---|---|
m00 |
28521.0 | 28832.0 | 28521.0 |
| 质心 | (399.50, 299.50) | (399.99, 299.98) | (399.50, 299.50) |
contourArea |
28521.0 | 28832.0 | 28521.0 |
截断和取整的结果相差整整 1 个像素 (266.077 → 266 vs 266),整幅轮廓因此整体偏移不到 1 px,而矩是面积量级的量------m00 差了 311,质心差了正好半个像素。我原来那个"半像素偏移"是 astype(np.int32) 向零截断造成的,而 np.floor 复现出完全相同的数字,进一步坐实了成因。改用 np.round 后 30° 的质心是 (399.99, 299.98),偏移不到 0.02 px,所谓的偏移消失了。
这一条本系列已经栽了三次,性质完全相同:不是物理效应,是造 fixture 的方式让实验测出了别的东西。 第一次是第 7 篇 那版"对比度阶梯"------背景本来带 40+60x/W+30y/H 渐变,我却把局部背景硬编码成 180,结果每级台阶的真实对比度全落在 ~80..230,测出来的阶梯是全平的 ;改成实测局部背景后阶梯才按预期单调消失(那一篇的 §四 就是修正后的结果)。第二次第三次都在本篇:这里的截断("半像素偏移"),和 §二 那个"折进 [0,90)"(非正方形差 90°)。三次的判据是同一条:当你的结论恰好等于一个"整齐"的值(全平、半像素、正好 90.0000°)时,先怀疑自己的背景假设、坐标取整或区间选择,而不是急着给它编一个物理解释。 顺带一条:boxPoints 返回 float32,astype(np.int32) 是截断不是取整,构造多边形应该显式 np.round(...).astype(np.int32),否则整条轮廓带一个系统性偏移。
下面是本篇唯一权威的夹具 (canon_08.py),§二 的角度表、§三 的矩表都由它产出,§八--§十 的工程结论也与它对齐。三个要点一个都不能少:
python
# ✅ 本篇唯一权威夹具:第 3 点用 np.round 而非 astype(np.int32)
def make_contour(truth_deg, L=240.0, W=120.0, center=(400, 300)):
# 1. boxPoints 自己就带旋转角度,绝不能再叠一次 getRotationMatrix2D
pts = cv2.boxPoints(((float(center[0]), float(center[1])), (L, W), truth_deg))
# 2. 显式取整:astype(np.int32) 是截断,会给整条轮廓一个系统性亚像素偏移
pts = np.round(pts).astype(np.int32)
img = np.zeros((600, 800), np.uint8)
cv2.fillPoly(img, [pts], 255)
cnts, _ = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
c = max(cnts, key=cv2.contourArea) # 3. 必须取最大轮廓
return img, c
第 1 点和第 2 点各对应一个我自己踩过的 bug:
boxPoints的角度已含在返回值里,再转一次会让 20 个角全部返回-90.0;截断则让 30° 的m00从 28832 掉到 28521。写测量类实验时,夹具本身就该和测量代码一样被审一遍。
而 moments 角度最硬的地方在噪声下才显现------这张表是本篇"用哪测角度"的最终依据(数据来自 §九 的同一套夹具,每档 10 次平均):
| 噪声 σ | minAreaRect |
boxPoints |
cornerSubPix |
fitLine |
moments 主轴 |
|---|---|---|---|---|---|
| 0 | 0.0147° | 0.0147° | 0.0103° | 0.0706° | 0.0169° |
| 4 | 0.0116° | 0.0116° | 0.0126° | 0.1302° | 0.0157° |
| 8 | 0.0111° | 0.0111° | 0.0166° | 0.3378° | 0.0158° |
| 16 | 0.0262° | 0.0262° | 0.0244° | 0.4236° | 0.0190° |
| 32 | 0.0490° | 0.0490° | 0.0406° | 0.8150° | 0.0231° |
| 劣化倍数 | 3.3× | 3.3× | 3.9× | 11.5× | 1.47× |
σ 从 0 加到 32(噪声标准差放大 32 倍),moments 主轴的误差只从 0.0169° 涨到 0.0231°,劣化 1.47 倍 ;fitLine 劣化 11.5 倍,是最脆的。原因也直白:主轴是对协方差做全局加权平均 ,噪声被几百个边界像素摊薄了;而 fitLine 算的是投影跨度的两端点,极值统计量天生怕离群点。
四、fitEllipse 的 5 点门槛,以及"够 5 点但不够用"
fitEllipse 少于 5 个点会抛异常,这个错误信息给得很具体:
text
OpenCV(5.0.0) .../modules/geometry/src/shapedescr.cpp:261: error:
(-201:Incorrect size of input array) There should be at least 5 points
to fit the ellipse in function 'cv::fitEllipseNoDirect'
但5 点只是必要条件,不是充分条件。我把一条圆弧上连续的 5/6/8/10 个点喂进去,全部"成功"返回,得到的却全是退化椭圆:
| 喂入点数 | 返回 axes |
推算面积 |
|---|---|---|
| 3 | 抛异常 | --- |
| 4 | 抛异常 | --- |
| 5 | (0.989, 25.505) |
79.2 |
| 6 | (0.22, 0.12) |
0.1 |
| 8 | (0.67, 0.60) |
1.3 |
| 10 | (1.10, 0.07) |
0.2 |
不抛异常,但结果是垃圾。 原因是我取的是轮廓上连续的少量点,它们几乎落在同一小段弧上------5 个近共线点对 5 个参数(圆心 2 + 角度 1 + 两轴 2)的约束是病态的,解在数值上存在但不唯一,库随便给你一个。
所以正确的判据不是"点数够不够",而是点是否张得开:
cpp
// ✅ 拟合前先看"张开程度",而不是只数点数
static bool ellipseFittable(const std::vector<cv::Point>& pts) {
if (pts.size() < 5) return false;
cv::Rect r = cv::boundingRect(pts);
// 至少要横跨有意义的范围,否则 5 个点就是共线的一小段
return r.width > 2 && r.height > 2;
}
顺带一个反直觉的实测:把矩形 喂给 fitEllipse,它不会报错,而是给出一个特定结果------240×120 的矩形得到 axes=(299.01, 135.89),短长轴比 0.4544;而同样长短轴比的真椭圆是 0.5。也就是说矩形拟合出的椭圆既不是内接也不是外接 ,而是落在这两者之间的一个最小二乘解。别指望用 fitEllipse 去"框住"一个矩形。
五、圆度 4πA/P² 测的是周长估计器,不是形状
圆度 4πA/P² 是判"这玩意儿是不是圆"最常用的指标。它在数学上等于 1.0 当且仅当是圆。那实测一个完美的圆呢?
表里那个"周长估计器"不是几个不同的函数,而是同一个 cv2.arcLength 的 method 参数 ------P 永远来自 arcLength(contour, method),换的是怎么数这条阶梯轮廓的周长:
| 周长估计器 | 面积 A | 周长 P | 4πA/P² |
|---|---|---|---|
CHAIN_APPROX_NONE |
44886.0 | 793.6 | 0.8955 |
CHAIN_APPROX_SIMPLE |
44886.0 | 793.6 | 0.8955 |
CHAIN_APPROX_TC89_L1 |
45082.0 | 756.3 | 0.9904 |
CHAIN_APPROX_TC89_KCOS |
45122.0 | 753.3 | 0.9992 |
真值是面积 45238.9、周长 753.98。所以:
- 用默认的
CHAIN_APPROX_SIMPLE,一个完美的圆只能测出0.8955------比很多真圆的"不合格椭圆"还低 。如果你把阈值设成0.95来筛圆,一个几何上完美的圆会被判不合格。 - 换成
TC89_KCOS才是0.9992。
原因不在面积(面积只差 -0.78%),在周长 :像素级阶梯轮廓的周长被系统性地高估了 +5.25%。4πA/P² 对周长是平方关系,+5.25% 直接吃掉了 10% 的圆度。
再叠一层 approxPolyDP 抽稀,也能修:
| 处理方式 | 顶点数 | 圆度 |
|---|---|---|
| 原始阶梯轮廓 | --- | 0.8955 |
approxPolyDP eps=0.5 |
164 | 0.9474 |
approxPolyDP eps=1.0 |
32 | 0.9966 |
approxPolyDP eps=2.0 |
24 | 0.9932 |
approxPolyDP eps=4.0 |
16 | 0.9866 |
但一定要对照直边形状,否则你会得出错误结论。同样四个估计器跑 240×240 的正方形:
| 周长估计器 | 4πA/P² |
|---|---|
CHAIN_APPROX_NONE |
0.7854 |
CHAIN_APPROX_SIMPLE |
0.7854 |
CHAIN_APPROX_TC89_L1 |
0.7854 |
CHAIN_APPROX_TC89_KCOS |
0.7854 |
四个全精确 (理论值 π/4 = 0.7854)。差别很清楚:直边多边形的周长是解析精确的,曲线不是。 你的圆度偏差几乎全部来自曲线的周长估计误差。
所以圆度阈值的物理含义是"在你的周长估计器下,形状有多接近圆"。换估计器就要重标定阈值。参考值:
| 形状 | 4πA/P²(CHAIN_APPROX_SIMPLE) |
|---|---|
| 正圆 r=120 | 0.8955 |
| 正方形 240 | 0.7854 |
| 扁椭圆 200×60 | 0.5586 |
| 长条 480×120 | 0.5027 |
| 实心五角星 | 0.2853 |
一个 5.0 兼容性提醒 :实测 5.0.0 的 Python 绑定里,常量名是
CHAIN_APPROX_TC89_L1/CHAIN_APPROX_TC89_KCOS(中间有下划线)。写成 4.x 时代的CHAIN_APPROX_TC89L1会直接AttributeError。
六、fitLine(DIST_L2) 确实就是总最小二乘
这条是反向辟谣 。网上有不少说法称 cv2.fitLine 的 DIST_L2 不是真正的总最小二乘(TLS),而是某种有偏估计。我第一次看到时也信了,于是专门去验。
先在 30° 直线上加各向同性 高斯噪声,200 次重复,比对 DIST_L2 和我自己用 SVD 写的标准 TLS(中心化 + PCA 主成分):
| 噪声 σ | DIST_L2 平均 |Δ角| |
我实现的 TLS |
|---|---|---|
| 0.0 | 0.000° | 0.000° |
| 0.5 | 0.026° | 0.026° |
| 1.0 | 0.044° | 0.044° |
| 2.0 | 0.090° | 0.090° |
| 4.0 | 0.212° | 0.212° |
完全一致。但各向同性下"很多估计量恰好重合"是很常见的,所以我又加了一轮各向异性噪声(法向 σ 与切向 σ 差 10 倍),这能暴露加权方式不同导致的偏差:
| 法向 σ | DIST_L2 平均 |Δ角| |
我实现的 TLS |
|---|---|---|
| 0.50 | 0.027° | 0.027° |
| 1.00 | 0.054° | 0.054° |
| 2.00 | 0.096° | 0.096° |
| 4.00 | 0.203° | 0.203° |
依然逐位相同。 结论:DIST_L2 就是经典的总最小二乘,没有偏差。顺带测出 DIST_HUBER(0.236°)和 DIST_WELSCH(0.245°)在 σ=4 时略差------因为它们是稳健估计,主动牺牲效率换抗离群点 。有离群点用它们,纯高斯噪声用 DIST_L2。
真正需要注意的是 fitLine 返回的 vx, vy 是 1 元素数组 ,写 atan2(vy, vx) 会得到 only 0-dimensional arrays can be converted to Python scalars;要写 float(vy[0])。
七、cornerSubPix 只吃 CV_8U 和 CV_32F------而 numpy 默认给你 float64
这一节是本篇花时间最多的地方,因为我自己的结论被自己的实验推翻了两次,最后是靠读源码定性的。这个过程本身就是最好的踩坑教材,所以完整记下来。
7.1 源码层面:支持哪些类型
cornerSubPix 内部取窗口的方式在 cornersubpix.cpp:112:
cpp
Mat maskm(win_h, win_w, CV_32F), subpix_buf(win_h+2, win_w+2, CV_32F); // 第 68 行
...
getRectSubPix(src, Size(win_w+2, win_h+2), cI, subpix_buf, subpix_buf.type()); // 第 112 行
subpix_buf 声明为 CV_32F,所以 ddepth 恒为 CV_32F。再看 samplers.cpp 里 cv::getRectSubPix 的分派:
cpp
if( depth == CV_8U && ddepth == CV_8U ) // 309
else if( depth == CV_8U && ddepth == CV_32F ) // 312
else if( depth == CV_32F && ddepth == CV_32F ) // 315
else
CV_Error( cv::Error::StsUnsupportedFormat,
"Unsupported combination of input and output formats" ); // 319(构建版本为 328 行)
穷举三类,其余全抛。实测逐一验证:
| 源图 dtype | 结果 |
|---|---|
uint8 |
OK |
float32 |
OK |
float64 |
ERR (-210) |
float16 |
ERR (-210) |
int16 |
ERR (-210) |
7.2 我错在哪:numpy 的隐式升型
第一轮实验我观察到"干净 float32 能过,加噪 float32 抛 -210 ",于是先猜是负值(因为加噪后背景出现了负数),np.clip 到 [0, 255] 后仍然抛 。我当时几乎就把"CV_32F 遇到噪声会崩"这个结论写进文章了。
直到我把异常的完整文本打出来,发现了两件事:
- 我截断后只看到
(-210),其实所有失败案例的异常文本完全一致(指纹长度都是 235) - 打印 dtype 时发现------那根本不是
float32:
| 变量 | 我以为的 dtype | 实际 dtype |
|---|---|---|
| 干净图 | float32 |
float32 |
| "加噪的 float32" | float32 |
float64 |
加噪后 np.clip |
float32 |
float64 |
加噪后 astype(float32) |
float32 |
float32 → OK |
根因是这一行:
python
g = img_float32 + np.random.default_rng(seed).normal(0, noise, img.shape)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# rng.normal() 返回 float64,float32 + float64 => float64
numpy 会把运算结果升型到 float64,而 float64 恰好是 cornerSubPix 不支持的类型。 所以"加噪"只是触发条件,"结果是 float64"才是真正原因。改成 rng.normal(...).astype(np.float32) 就一切正常。
这个坑的现实杀伤力在于:它和 float32 支持与否毫无关系,纯粹是 numpy 的默认行为 。你只要在预处理里写了一句 img = img - mean 或者 img = img / 255.0(Python 里的 255.0 是 float),图就变成 float64 了,然后 cornerSubPix 抛一个完全不提 dtype 的错 ------Unsupported format or combination of formats,你很难联想到"我只是乘了个 255.0"。
规范写法 :喂
cornerSubPix之前显式img = img.astype(np.float32),或者干脆走uint8通路。实测uint8通路非常稳,噪声 σ 从 0 加到 20,最长边测得299.840 / 299.824 / 299.762 / 299.623(真值 300),全程可用。
7.3 窗口尺寸:合法性与"静默失效"
winSize 的约束是只校验 > 0,不校验奇偶、不校验合理性:
winSize |
实测行为 |
|---|---|
(0, 0) |
抛异常 cornersubpix.cpp:64: error: (-215:Assertion failed) win.width > 0 && win.height > 0 |
(1, 1) |
OK,但不移动(梯度窗口退化) |
(2, 2) |
OK(偶数窗口,语义上是错的,无人报错) |
(4, 6) |
OK(非方形窗口,同样不报错) |
更麻烦的是贴边时的静默失效 。实测把角点放在 (2, 2) 或 (509, 509)、窗口用 (31, 31):
| 初始点 | 返回值 | 发生了什么 |
|---|---|---|
(2.0, 2.0) |
(2.000, 2.000) |
原样返回,一点没动,也不报错 |
(509.0, 509.0) |
(509.000, 509.000) |
同上 |
源码里 cornersubpix.cpp:99 会对完全在图外 的点抛 StsOutOfRange,但对"在图内、离边太近"的点是放行的。getRectSubPix 里的 adjustRect 会把越界部分裁掉,于是你拿到一个宽度被截断甚至为 0 的窗口,梯度算出来是 0,迭代不收敛,直接原样返回。
cpp
// ✅ 上线前的健全性检查:点是否离边界足够远
static bool subPixSafe(const cv::Point2f& pt, cv::Size win, cv::Size img) {
int half = std::max(win.width, win.height) / 2 + 1;
return pt.x - half >= 0 && pt.y - half >= 0 &&
pt.x + half < img.width && pt.y + half < img.height;
}
7.4 窗口越大越好?------ 两个指标给出相反答案
这是本节最容易误导人的地方。同一份代码、同一套数据,只换指标,结论是相反的。
指标 A:干净合成图上,单个孤立角点到真值的残差(越大窗口越平滑,理应越准)
winSize |
残差 |
|---|---|
| (3,3) | 0.0530 px |
| (5,5) | 0.0327 px |
| (7,7) | 0.0928 px |
| (11,11) | 0.1631 px |
| (15,15) | 0.2008 px |
| (21,21) | 0.2328 px |
| (31,31) | 0.2605 px |
单调变差,越小越好。
指标 B:噪声 σ=4 下,由精化角点推出的板材长边误差 / 角度误差(每档 20 次平均)
winSize |
长边误差 | 角度误差 |
|---|---|---|
| (3,3) | 0.2254 px | 0.0065° |
| (5,5) | 0.1614 px | 0.0102° |
| (9,9) | 0.1097 px | 0.0247° |
| (15,15) | 0.0867 px | 0.0450° |
| (21,21) | 0.0805 px | 0.0616° |
| (31,31) | 0.0839 px | 0.0933° |
长度要越大越好(21×21 最优),角度要越小越好(3×3 最优)。
这不是矛盾,是偏差-方差权衡在两个指标上的不同落点:
- 长度 是两个角点之差 。两个独立的定位误差不抵消,反而叠加 → 窗口大一点能压住方差,所以要大窗口。
- 角度 是同一个角点邻域内梯度分布的主方向 。窗口一小,局部梯度方向更"纯粹";窗口一大,邻域内的边缘弯曲和纹理会把主方向带偏 → 偏差主导,所以要小窗口。
所以"亚像素窗口开多大"没有通用答案,取决于你从角点导出的是长度还是角度。写代码前先想清楚你要哪个。
八、案例:旋转板材的长边 + 角度 + 圆度
前七节都是单点验证,这一节把算子串起来。夹具用 8 倍超采样 渲染一块 300×140、旋转 28° 的板材,上面开一个 r=34 的孔。8 倍超采样意味着夹具自身的量化底限是 1/8 = 0.125 px------这个数字很重要,它是我能声称的精度上限。
先做管线自校验,不通过就不往下写结论:
| 自检项 | 实测 | 判定 |
|---|---|---|
| 外轮廓面积 | 41668.0(理论 42000.0) |
相对 -0.79% |
| 4 条真值边长 | 140.00, 300.00, 140.00, 300.00 |
角 28.000° |
长边用四种方法测:
| 测法 | 测得长度 | 误差 | 测得角度 | 角度误差 |
|---|---|---|---|---|
boundingRect(轴对齐) |
330.000 | +10.00% | --- | --- |
minAreaRect 长边 |
300.066 | +0.0219% | 27.9853° | -0.0147° |
boxPoints 角点距离(像素级) |
300.066 | +0.0219% | 27.9853° | -0.0147° |
cornerSubPix(win=5) 后量 |
299.844 | -0.0520% | 28.010° | +0.010° |
fitLine 投影跨度 |
300.173 | +0.0577% | 28.0706° | +0.0706° |
moments 主轴 |
--- | --- | 27.9831° | -0.0169° |
两处需要说明:
其一:minAreaRect 长边和 boxPoints 角点距离的误差逐位相同(都是 +0.0219%)。 这不是巧合,而是它们本来就是同一个量 :minAreaRect.size 就是由 boxPoints 四个角点构成的矩形的边长。所以上表里它们不是两个独立的交叉验证,而是同一个测量的两种写法。我在 §九 的噪声表里也保留了这一列,正是为了让读者看清这一点。
其二:boundingRect 的 +10.00% 不是误差,是定义。 轴对齐外接框对 300×140 的板在 28° 旋转下必然是 300·cos28 + 140·sin28 = 330.7。它只在你不知道物体朝向 、且能接受 1/cos θ 量级放大时才有用。
角点定位的残差 (对真值角点):像素级 minAreaRect 角点残差均值 0.7112 px,cornerSubPix 精化后 0.7090 px------基本打平 。这和直觉相反,我原本预期精化后能降到 0.1 px 量级。没降的原因是 PSF 模糊 + 127 灰度阈值引入的系统性偏移(见 §十,cornerSubPix 在无畸变时自带约 -0.25 px 的负偏差),它大于亚像素本身带来的收益。这个数字我在 §九 会再用到。
孔的圆度与半径 (真值 r=34):
| 周长估计器 | 圆度 | r(面积) |
fitEllipse 半轴 |
minEnclosingCircle |
|---|---|---|---|---|
CHAIN_APPROX_SIMPLE |
0.9005 | 34.429 (+1.26%) | 34.478 (+1.41%) | 34.937 (+2.76%) |
CHAIN_APPROX_TC89_L1 |
0.9908 | 34.605 (+1.78%) | 34.782 (+2.30%) | 34.937 (+2.76%) |
CHAIN_APPROX_TC89_KCOS |
0.9953 | 34.623 (+1.83%) | 34.808 (+2.38%) | 34.937 (+2.76%) |
这里能看到 §一 和 §五 两条结论同时生效:用默认 CHAIN_APPROX_SIMPLE,圆度只有 0.9005 (换成 TC89_KCOS 是 0.9953),而半径一律偏大 +1.26% ~ +2.76%。三种测半径的方法没有一种能给出 34.0,最接近的 +1.26% 也已经是夹具底限 0.125 px 的 2 倍多。
(注意这里的 0.9005 和 §五 那个 0.8955 不是矛盾:§五 是原生填充的 r=120 正圆,§八 是 8 倍超采样的 r=34 小孔 。两者都是"默认 CHAIN_APPROX_SIMPLE 严重低估圆度"这个结论的独立复现,量级一致。)
九、亚像素到底能买到多少精度(本篇核心结论)
前面所有的铺垫都是为了这张表。同一块板材,人工加高斯噪声 ,每档 10 次平均,测长边绝对误差(px):
| 噪声 σ | boundingRect |
minAreaRect |
boxPoints |
cornerSubPix |
fitLine |
|---|---|---|---|---|---|
| 0 | 30.0000 | 0.0657 | 0.0657 | 0.1561 | 0.1731 |
| 1 | 30.0000 | 0.0917 | 0.0917 | 0.1514 | 0.1702 |
| 2 | 30.0000 | 0.0923 | 0.0923 | 0.1469 | 0.1516 |
| 4 | 30.0000 | 0.1090 | 0.1090 | 0.1405 | 0.3121 |
| 8 | 30.0000 | 0.2277 | 0.2278 | 0.1334 | 0.7503 |
| 16 | 30.0000 | 0.5336 | 0.5336 | 0.1413 | 1.0761 |
| 32 | 30.0000 | 1.2267 | 1.2267 | 0.2288 | 2.3874 |
| σ=32 相对 σ=0 | --- | 劣化 18.7× | 18.7× | 劣化 1.47× | 劣化 13.8×** |
读这张表请注意三件事:
第一,干净图上全局拟合赢。 σ=0 时 minAreaRect 的 0.0657 px 是 cornerSubPix 的 0.1561 px 的 2.4 倍好 。原因很实在:minAreaRect 是对**整条轮廓(约 724 个边界像素)**做一次全局最小二乘拟合,栅格化的随机误差被平均掉了;而 cornerSubPix 只用 4 个角点 × 局部 5×5 窗口。谁的样本多,谁在干净图上更准。
第二,交叉点在 σ≈4~6,之后完全反转。
| 噪声 σ | minAreaRect |
cornerSubPix |
谁更好 |
|---|---|---|---|
| 4 | 0.1090 | 0.1405 | minAreaRect 1.29× |
| 8 | 0.2277 | 0.1334 | cornerSubPix 1.71× |
| 16 | 0.5336 | 0.1413 | cornerSubPix 3.78× |
| 32 | 1.2267 | 0.2288 | cornerSubPix 5.36× |
σ=0 到 σ=32,minAreaRect 劣化 18.7 倍 ,而 cornerSubPix 只劣化 1.47 倍(0.1561 → 0.2288,中间 σ=8 时甚至比 σ=0 更好)。
为什么全局拟合这么怕噪声? 因为它对每个 边界像素的扰动都敏感------噪声直接进拟合残差。而 cornerSubPix 的判据是梯度极值点 ,这是个低阶、局部 的统计量:高斯加权窗口把邻域噪声摊薄了,噪声不改变"亮度变化最陡的位置",只让这个位置的估计抖动零点几个像素。这就是"局部低阶统计量抗噪、整体高阶拟合怕噪"的教科书式体现 ,而它同时解释了为什么角度测量上 moments 也能做到 1.47 倍劣化(§三 那张表)。
第三,fitLine 是最差的选择。 σ=0 时它就落后(0.1731),σ=32 时劣化到 2.3874 px。原因是投影跨度取的是极值 :极值统计量对离群点零容忍,噪声抬高了 max、压低了 min,误差直接进结果。如果你要测"两个端点之间的距离",fitLine 是这几种里最不该用的。
9.1 抽稀会打乱排名
approxPolyDP 是很多人"提速"的标准动作,但它对不同测法的影响完全相反(噪声 σ=4):
approxPolyDP eps |
顶点数 | minAreaRect |
cornerSubPix |
fitLine |
|---|---|---|---|---|
| 0(不抽稀) | 724 | 0.0986 | 0.1521 | 0.3034 |
| 0.5 | 94 | 0.0983 | 0.1521 | 1.6897 |
| 1.0 | 4 | 0.9749 | 0.1535 | 0.9193 |
| 2.0 | 4 | 0.9749 | 0.1535 | 0.9193 |
| 4.0 | 4 | 0.9749 | 0.1535 | 0.9193 |
| 8.0 | 4 | 0.9749 | 0.1535 | 0.9193 |
eps=1.0 会把一个 724 点的矩形轮廓直接抽成 4 个点。此时:
minAreaRect从0.0986退化到0.9749,劣化 9.9 倍------只剩 4 个点,全局拟合没有样本可平均了,退化成量这 4 个点的整数像素位置cornerSubPix几乎不受影响(0.1521→0.1535)------它本来就只用 4 个角点fitLine在eps=0.5时先炸到1.6897(5.6 倍 ),到eps=1.0又回到0.9193------因为 4 点时fitLine实际是在拟合一个四边形
结论:如果你打算用 cornerSubPix,抽稀无害甚至有益;如果你打算用 minAreaRect,approxPolyDP 的 eps 是一个必须实测的参数,不能随手设。
十、亚像素救不了的部分:镜头畸变残留
前面九节都在争"亚像素能买到多少"。这一节讲它买不到的东西。
我把同一块板材过一遍已知的桶形畸变(k1 从 0 到 -0.30),看各测法的系统性偏差(不是随机误差,是有偏):
畸变 k1 |
minAreaRect |
boxPoints |
cornerSubPix |
fitLine |
moments(角度) |
|---|---|---|---|---|---|
| 0.00 | +0.0657 | +0.0657 | -0.2505 | +0.6171 | -0.0266° |
| -0.05 | +0.5008 | +0.5008 | +0.2272 | +0.6932 | -0.0207° |
| -0.12 | +1.3220 | +1.3220 | +0.9564 | +1.5614 | -0.0236° |
| -0.20 | +1.9980 | +1.9980 | +1.7614 | +2.4673 | -0.0218° |
| -0.30 | +3.1435 | +3.1435 | +2.8788 | +3.5255 | -0.0182° |
三件事:
一、长度偏差随 |k1| 近似线性增长,且亚像素精化几乎无能为力。 k1=-0.30 时 minAreaRect 偏 +3.14 px、cornerSubPix 偏 +2.88 px------300 px 的边长上 +1% 的系统偏差 。cornerSubPix 只把它从 3.14 改善到 2.88(8%),因为它修的是"像素落在哪",而畸变改的是"真实几何是什么 "。直线在传感器上就是弯的,梯度极值点忠实地报告了这个弯的位置------算法没有错,是模型错了。
二、moments 角度对畸变几乎免疫。 从 -0.0266° 到 -0.0182°,|k1| 放大 30 倍,角度偏差不降反微升 。原因是弓形的边仍然关于其对称轴对称 ,而对称轴就是主轴,moments 只看二阶矩分布的方向,对"边有多弯"这个一阶形状信息不敏感。这是本篇最有工程价值的一条:畸变影响长度,但几乎不影响矩方法测出的角度。
三、畸变偏差随视场半径增长。 同一组畸变下换板材位置:
| 板材位置 | k1 |
minAreaRect |
cornerSubPix |
|---|---|---|---|
| 视场中心 | 0.00 | +0.0657 | -0.2505 |
| 偏心 80 px | 0.00 | +0.0657 | -0.2505 |
| 视场中心 | -0.20 | +1.9980 | +1.7614 |
| 偏心 80 px | -0.20 | +3.6576 | +3.4930 |
k1=0 时两者完全一致(说明没有引入额外偏置),k1=-0.20 时偏心 80 px 比中心多出 +1.66 px 的误差。所以"全视场一个标定系数"是不够的 ,尤其当你按 §三 用 moments 测角度、按长度做公差判定的时候。
10.1 像素当量:连栅格化本身都在量化
最后是所有不确定度的地板------栅格化。我用 8 倍超采样造出"连续"半径的圆盘,再看轮廓面积反推半径:
| 真值 r | 轮廓面积 A | r=sqrt(A/π) |
误差 |
|---|---|---|---|
| 60.00 | 11331.0 | 60.0564 | +0.0564 |
| 60.05 | 11331.0 | 60.0564 | +0.0064(面积完全相同) |
| 60.10 | 11388.5 | 60.2086 | +0.1086 |
| 60.15 | 11388.5 | 60.2086 | +0.0586(同面积) |
| 60.20 | 11453.5 | 60.3801 | +0.1801 |
| 60.25 | 11453.5 | 60.3801 | +0.1301(同面积) |
21 档里有 12 档的面积与前一档完全相同。 半径从 60.00 走到 61.00(1 px 的跨度),面积只跳了 7 次台阶。面积反推半径的死区约 0.15 px ------真值变了但测量值不动。这意味着你把半径真值连续扫描,测出来的是阶梯而不是曲线。
用周长反推更糟:
| 真值 r | 测得 P | r = P/2π |
误差 |
|---|---|---|---|
| 60.00 | 397.99 | 63.3421 | +3.3421 |
同一个圆盘(8 倍超采样夹具 ,与 §五 的原生填充不是同一套,见下),周长反推的半径偏了 +3.34%,是面积反推 +0.09% 的 36 倍。原因是周长本身被高估 +5.57%,而半径正比于周长。
周长高估的比例与尺寸几乎无关,只和"每步至少多走 √2"的阶梯效应有关:
| 真值 r | 真周长 | 像素级周长估计 | 相对误差 |
|---|---|---|---|
| 30 | 188.5 | 200.2 | +6.19% |
| 60 | 377.0 | 398.0 | +5.57% |
| 120 | 754.0 | 796.0 | +5.57% |
| 190 | 1193.8 | 1259.9 | +5.54% |
所以周长类指标天生带 +5.5% ~ +6% 的正偏 ,而面积类几乎无偏。要测尺寸就用面积(或矩),不要用周长除以常数。
这里有个我一开始搞混的地方,值得单独记一笔:同一个 r=120 的圆,在两套夹具上周长偏差不一样。 §五 用的是原生
cv2.circle填充 ,本节这张表用的是8 倍超采样。实测四个半径在两套夹具上的周长偏差:
真值 r 原生填充(§五 夹具) 8 倍超采样(本节夹具) 30 +4.95% +6.19% 60 +4.95% +5.57% 120 +5.25% +5.57% 190 +5.34% +5.54% 所以你在 §五 看到
+5.25%、在这里看到+5.57%,不是前后矛盾,是两个夹具的差别 。两者的结论完全一致 (周长正偏约 5~6%、与尺寸几乎无关、面积几乎无偏),但如果你要拿某个具体数字去卡公差,就必须先说清楚是哪套夹具。这正是本篇开头立的规矩:跨节引用实测数字时,先确认夹具同源。
这一节的数字也解释了一个常见困惑:为什么"同一个圆"在CHAIN_APPROX_SIMPLE下算出来半径总是偏大。偏大不是因为算法笨,而是因为周长被阶梯高估了+5.5%------这跟相机标定无关,是离散化的固有代价。
避坑指南
坑 1:同一族里"轴长"有四种单位约定
fitEllipse 返回全轴 (直径),minEnclosingCircle 返回半径 (半轴),cv2.ellipse 画图用半轴 ,minAreaRect 用整边长 。混用差 2 倍,拿全轴算面积差 4 倍(实测 +299.24%)。拿 fitEllipse 的 axes[0] 和 minEnclosingCircle 的 r 直接相除,你会得到一个恒等于 2 的"比值",然后困惑很久。
坑 2:minAreaRect 的 angle 实测只落在 [-90, 0)
5.0.0 上 20 个测试角,返回值全部落在 [-90, 0),落在 (0, 90] 的是 0 个 。教程里写的"角度范围是 (0, 90]"在 5.0 上不成立。如果你看到正的 angle,先怀疑自己是不是在 4.x 上测的,或者把 +90 补了两次。
坑 3:minAreaRect 的 size 长短边会反,且归一化必须两步同做
实测 20 个角里有 10 个 w < h(恰好是真值 [0, 81] 那一段),不能直接把 size.width 当长边。
归一化的正确写法有两步,顺序和折进区间都不能错:
- 先交换长短边并把角度跟着转 90°;
- 再折进
[0, 180)------不是[0, 90)。
第 2 步是我写错的地方。直观的"折回 [0,90)"会把长轴和短轴混为一谈:truth=135° 实测返回 a=-45 且 w>h(不触发交换),被折成 45°,而真实长轴方向是 135°,差整整 90° 。它对正方形无害(长轴短轴本来就等价),所以单元测试和正圆对照都发现不了------只有拿非正方形逐个角度对真值,才会看到最大偏差打印成 90.0000。长轴方向只在 mod 180° 下唯一,从来不在 mod 90° 下唯一。
另外 C++ 里的 % 只能用于整数 ,(a + 90.0) % 180.0 编译不过(本机 clang 前端直接报 invalid operands to binary expression ('double' and 'double')),必须写 std::fmod(a + 90.0, 180.0)。
坑 4:拿正圆当上线前的健全性检查
实测正圆返回 angle=-45.0000、size=(239.00, 239.00)------这是退化情形 (旋转不变的东西角无定义),不是 bug。如果你的代码在正圆上算出"45°",说明角度根本不是来自 minAreaRect(比如误读了 approxPolyDP)。这个检查成本一行,挡的是整类 bug。
坑 5:把 minAreaRect.size 和 boxPoints 角点距离当两个独立测法
实测两者误差逐位相同 (都是 +0.0219%),因为 size 就是 boxPoints 四个角点构成的矩形的边长。写进报告当"交叉验证"会被打回。
坑 6:轮廓矩的 m00 天然偏小,而"整齐"的偏移值通常是你的取整 bug
30° 的 240×120 矩形上,moments(contour)['m00'] 与 contourArea 精确相等(都是 28832.0) ,而二值图矩是 29145.0,缺口 313.0(1.07%) 。两者同值说明轮廓矩算的是穿过像素中心的多边形面积 (同一套 shoelace 递推),不是"数了多少钱素";而这个多边形比真实填充区域每边少半个像素,所以轮廓矩的面积天然偏小,斜边越斜越明显。0° 时顶点落在整数像素上,缺口为零。
配套的坐标取整坑:boxPoints 返回 float32,astype(np.int32) 是向零截断不是取整。实测 30° 矩在三种取整下的差异:
| 30° 矩 | astype(np.int32) |
np.round |
np.floor |
|---|---|---|---|
m00 |
28521.0 | 28832.0 | 28521.0 |
| 质心 | (399.50, 299.50) | (399.99, 299.98) | (399.50, 299.50) |
我曾把 (399.50, 299.50) 这个"半像素偏移"当成栅格化性质写进正文,实际上它是截断造成的;改用 np.round 后偏移不到 0.02 px。构造多边形要显式 np.round(...).astype(np.int32)。 判据:当结论恰好等于半像素、正好 1 px 这类"整齐"值时,先怀疑取整方式,而不是急着给它编一个物理解释。
坑 7:fitEllipse 的 5 点门槛只挡异常,不挡垃圾
少于 5 点会抛 (-201),但喂圆弧上连续的 5/6/8/10 个点全部"成功" ,返回 (0.989, 25.505)、(0.22, 0.12) 这样的退化椭圆。5 个近共线点对 5 个参数的约束是病态的,解存在但不唯一。判据应该是"点是否张得开"(boundingRect 范围),不是"点数够不够"。
顺带:把矩形 喂给 fitEllipse 也不报错,240×120 得到短长轴比 0.4544(真椭圆是 0.5)------既非内接也非外接,别指望它去"框住"矩形。
坑 8:默认周长估计器下,完美圆的圆度只有 0.8955
CHAIN_APPROX_SIMPLE 是默认值,而它算出的圆度是 0.8955(TC89_KCOS 才是 0.9992)。阈值设成 0.95 会把几何上完美的圆判为不合格 。原因不在面积(只差 -0.78%),在周长被高估 +5.25%,而 4πA/P² 对周长是平方关系。
对照直边形状才看得出规律:正方形在四种估计器下全是 0.7854 (理论 π/4)。直边周长是解析精确的,曲线不是。换周长估计器就必须重标定圆度阈值。
坑 9:5.0 的轮廓近似常量名多了下划线
实测是 CHAIN_APPROX_TC89_L1 / CHAIN_APPROX_TC89_KCOS。写成 4.x 时代的 CHAIN_APPROX_TC89L1 会直接 AttributeError。这条不涉及任何原理,纯粹是升级时的字符串改动,grep 一遍就能过。
坑 10:cornerSubPix 只吃 CV_8U 和 CV_32F,而 numpy 默认给你 float64
源在 samplers.cpp 的 getRectSubPix:只穷举了 (8U→8U)、(8U→32F)、(32F→32F) 三种组合,float64/float16/int16 全抛 (-210)。而 float32 + rng.normal(...) 或 / 255.0(Python 的 255.0 是浮点)会把整幅图升型成 float64 ,然后抛一个完全不提 dtype 的 Unsupported format or combination of formats。
这条最坑的地方在于因果是反的 :我第一轮实验观察到"干净 float32 能过、加噪 float32 抛错",几乎就把"CV_32F 遇噪声会崩"写进文章了。实际把异常完整打出来、打印 dtype 才发现那根本不是 float32。加噪只是触发条件,"结果是 float64"才是原因。 规范写法是喂之前显式 img.astype(np.float32),或者走实测同样很稳的 uint8 通路(σ=0→20 测得 299.840/299.824/299.762/299.623)。
坑 11:winSize 只校验 > 0,且贴边时静默失效
(0,0) 抛 (-215),但 (2,2)(偶数)、(4,6)(非方形)、(1,1)(退化)全部静默接受 。更麻烦的是贴边:点放 (2, 2) 配 winSize=(31,31),原样返回 (2.000, 2.000),不报错也不精化 ------getRectSubPix 里的 adjustRect 把越界部分裁掉,你拿到一个宽度被截断甚至为 0 的窗口,梯度为 0,迭代不收敛。完全在图外的点会抛 StsOutOfRange,但"在图内、离边太近"是放行的。上线前检查 pt 离边界是否大于 winSize/2 + 1。
坑 12:以为亚像素窗口"越大越准"
同一份数据只换指标,结论是相反 的:干净图上单点残差 (5,5)=0.0327 最好、(31,31)=0.2605 最差(越小越好);但噪声 σ=4 下由精化角点推出的长边 误差在 (21,21)=0.0805 最优(越大越好),而角度 误差在 (3,3)=0.0065 最优(越小越好)。
原因是长度是两点之差 (两个独立误差叠加,要大窗口压方差),角度是同一邻域梯度分布的主方向 (窗口一小方向更纯粹,大了被边缘弯曲带偏)。没有通用答案,取决于你从角点导出的是长度还是角度。
坑 13:approxPolyDP 的 eps 会打乱测法排名
eps≥1.0 会把 724 点的矩形轮廓直接抽成 4 个点 。此时 minAreaRect 从 0.0986 劣化到 0.9749(9.9 倍 ),因为只剩 4 个整数像素位置可平均;而 cornerSubPix 几乎不受影响(0.1521→0.1535),它本来就只用 4 个角点。fitLine 在 eps=0.5 时先劣化 5.6 倍到 1.6897,到 eps=1.0 又回到 0.9193(因为实际在拟合四边形)。要用手 minAreaRect,eps 就是必须实测的参数,不能随手设。
坑 14:用周长反推尺寸,以及面积反推的死区
周长类指标天生带 +5.5% ~ +6.2% 正偏 (实测 r=30/60/120/190 分别为 +6.19%/+5.57%/+5.57%/+5.54%),比例与尺寸无关,只和"每步至少多走 √2"的阶梯效应有关。r=60 的圆用周长反推得 63.3421,偏 +3.34% ,是面积反推(+0.09%)的 36 倍。要测尺寸就用面积或矩。
而面积反推也不是连续的:8 倍超采样下扫描 r=60.00→61.00,21 档里有 12 档的面积与前一档完全相同 ,死区约 0.15 px。真值连续变,测量值不动------你得到的是阶梯不是曲线。任何"扫参数找最优点"的脚本都会踩到这个假平台。
坑 15:以为亚像素能修镜头畸变
k1 从 0 到 -0.30,长边偏差 minAreaRect 从 +0.0657 涨到 +3.1435、cornerSubPix 从 -0.2505 涨到 +2.8788------300 px 边长上 +1% 的系统偏差 ,而 cornerSubPix 只把它改善了 8%。因为它修的是"我的像素落在哪",而畸变改的是"真实几何是什么 ";直线在传感器上就是弯的,梯度极值点忠实地报告了这个弯。算法没错,是模型错了。
而且偏差随视场半径增长 (k1=-0.20 时视场中心 +1.9980、偏心 80 px 处 +3.6576),所以"全视场一个标定系数"不够用。
反过来有个好消息:moments 测的角度对畸变几乎免疫 (-0.0266° → -0.0182°,|k1| 放大 30 倍而不升反微降),因为弓形的边仍关于其对称轴对称,而主轴只看二阶矩分布的方向。畸变影响长度,但几乎不影响矩方法测出的角度------这条对"公差卡角度"的应用很实用。
坑 16:fitLine 测"端点距离"是最差选择,返回值还是 1 元素数组
fitLine 算的是投影跨度 ,取的是极值,而极值统计量对离群点零容忍:噪声抬高 max、压低 min,误差直接进结果。实测 σ=32 时劣化到 2.3874 px(minAreaRect 是 1.2267),而且 σ=0 时就已经落后(0.1731 vs 0.0657)。
另外 fitLine 返回的 vx, vy 是 1 元素数组 ,写 atan2(vy, vx) 会报 only 0-dimensional arrays can be converted to Python scalars,必须 float(vy[0])。
(附带一条反向辟谣:实测 DIST_L2 与标准 TLS 在等方和各向异性 噪声下逐位相同,它就是 总最小二乘,没有偏差。DIST_HUBER(0.236°)和 DIST_WELSCH(0.245°)在 σ=4 时略差,因为它们是稳健估计、主动牺牲效率换抗离群点。)
优秀实践 / 可改进之处
优秀实践:先定单位,再写公式
拿到任何一个"轴长"先问一句半轴还是全轴,并把答案写在函数名或变量名里。本篇四族函数四种约定,混用一次就是 2 倍或 4 倍,而这种错误在日志里完全看不出来------+299.24% 看着刺眼,反而好发现;难发现的是差 2 倍但"看起来还挺顺眼"的那种。
优秀实践:用已知形状做阳性对照
正圆测角度(应无定义)、已知半径测轴长(r=120 的圆应给 239.05)、k1=0 测畸变(应与有畸变时完全一致,实测中心与偏心 80 px 都是 +0.0657)------每个算法入口挂一个,成本一行,能挡住整类 bug。这些对照的输出值要写进注释 ,否则半年后没人记得为什么是 239.05 而不是 240。
优秀实践:按"量"和"噪声水平"分别选方法
角度用 moments 主轴(σ 0→32 只劣化 1.47 倍,最稳);长度在干净图上用 minAreaRect(0.0657 px),有噪声用 cornerSubPix(σ=32 时好 5.36 倍)。圆度判圆前先把周长估计器定下来并重新标定阈值------阈值和估计器是一个整体,不要在 0.95 上"感觉差不多"。
对 cornerSubPix 做三件例行事:astype(np.float32)、确认 winSize 是奇数且方形、确认角点离边界大于 winSize/2 + 1。三件都是静默或误导性失败模式。
可改进之处:全部数字来自合成夹具
夹具边缘是超采样渲染的理想阶跃边缘 + 高斯 PSF,没有真实相机的 MTF、散斑噪声和 gamma 。真实图像上 cornerSubPix 表现会差一些,§九 那条"σ≥8 反超"的交叉线大概率往左移。§七 和 §九 的数字只能在合成数据上成立,落到真机必须重测。
可改进之处:畸变只测了径向,噪声只用高斯
切向畸变(p1/p2)会让形状产生非对称剪切,moments 角度的"免疫性"是否还成立我没有验证,不能外推 。噪声模型也只有高斯------moments 之所以稳,是因为高斯噪声没有方向偏好;椒盐噪声或照明条纹会破坏这个前提,优势可能大幅缩水。
可改进之处:窗口最优值是粗扫,且未评估部分可见
§七 那个"长度要大窗口、角度要小窗口"的结论只在 (3,3) 到 (31,31) 的 8 个网格点上验证过,真实最优可能在网格点之间,且随 PSF 宽度变化。另外没有评估抗遮挡/部分可见 :板材部分出画时 minAreaRect 会给出一个仍然"看起来合理"的矩形,而 cornerSubPix 会因 winSize 越界静默失效------这个组合可能产生最危险的静默错误,我没测。
本期互动
三个可以直接落地验证的点:
- 把你的阈值代码改成"圆度 + 指定周长估计器" ,然后用正圆测一遍。如果你得到
0.8955而不是接近 1.0,说明你的圆度阈值和估计器不匹配------按 §五 那张表重新标定。 - 量一下你的图像预处理有没有把
uint8变成float64。 在喂cornerSubPix之前打一行print(img.dtype),成本一行代码,省掉一类极难排查的(-210)。 - 在真机上做一次"干净 vs 加噪"对照。 先测当前图的尺寸精度,再往图上叠已知 σ 的高斯噪声重测,看你的方法落在 §九 那张表的哪个位置。如果你在有噪声的图上得到
minAreaRect最优,那你的噪声比你想的低;如果得到cornerSubPix最优而你没加cornerSubPix,那这就是你能白拿的精度。
💬 评论区聊聊
说说"亚像素"这个词。
这篇最反直觉的结果是:cornerSubPix 在干净图上比 minAreaRect 差 2.4 倍,在 σ=32 的噪声图上却好 5.36 倍。 同一个函数,两种完全相反的评价,取决于你的图干不干净。
我原本以为"亚像素"是单调的一档精度阶梯------用上就更好。现在我觉得它更像是一个方差/偏差的旋钮 :局部低阶判据(梯度极值)偏差小但方差大,全局高阶拟合(minAreaRect 对整条轮廓)方差小但偏差大。噪声在的时候全局拟合的"平均优势"被噪声打穿;干净的时候它稳赢。
有意思的是 §十 的畸变数据:k1=-0.30 时 cornerSubPix 只把长度偏差从 +3.14 改善到 +2.88。亚像素能修"我的像素落在哪",修不了"世界本来是什么样"。 而 moments 的角度对畸变几乎免疫(-0.0266° → -0.0182°),因为对称轴不因弯曲而改变------这大概是本篇最实用的一条:如果你的公差卡的是角度,畸变标定的优先级可以降一降。
想听听大家:你们的测量流水线里,是从角点导出长度还是从整条轮廓拟合? 这两个选择的噪声特性差了一个数量级(§九),我很好奇实际项目里大家怎么选。另外------有没有人试过用 moments 的高阶矩(mu21/mu12/mu03)直接度量形状的非对称性 ?本篇只用了二阶矩,那三阶矩在圆度细分上应该能给出比 4πA/P² 更好的判据,因为它对"局部鼓包"敏感而对周长估计器不敏感。
👉 收藏 · 转发 · 系列目录
本篇把度量算子族做了一次实测清点,有几个结论和常见说法相反:fitEllipse 是这一族里唯一返回全轴的 (minEnclosingCircle 返回半径、cv2.ellipse 画图用半轴)、5.0 上 minAreaRect 的角度只落在 [-90, 0) 且 size 长短边会反 、默认周长估计器下完美圆的圆度只有 0.8955 、以及亚像素在干净图上不如全局拟合、噪声图上才碾压它。
如果你在做视觉测量、尺寸标定或缺陷几何分析,这篇有三张表值得存下来:§一 的四族单位对照表 (防止 2 倍/4 倍错误)、§九 的五法噪声对照表 (决定该用哪个测法)、§七 的窗口尺寸双向表 (长度要大窗口、角度要小窗口)。另外 §七 那个"numpy 隐式升型导致 cornerSubPix 抛 (-210)"的坑,我花了三轮实验才定位到,中间两次结论都是错的------如果你也遇到过这个报错,希望你能省掉那两轮。
本系列接下来会覆盖模板匹配与特征匹配 :matchTemplate 的归一化方式怎么选、ORB/AKAZE 在什么场景下真的比模板匹配快、以及 RANSAC 为什么是几何验证的必需品------顺便把本篇反复出现的"统计性质 vs 默认行为"这条线继续下去。