OpenCV 实战第 8 篇:几何测量算子族,从 boundingRect 到亚像素定位

为什么 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 遇到噪声会崩"这个结论写进文章了。

直到我把异常的完整文本打出来,发现了两件事:

  1. 我截断后只看到 (-210),其实所有失败案例的异常文本完全一致(指纹长度都是 235)
  2. 打印 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 当长边。

归一化的正确写法有两步,顺序和折进区间都不能错:

  1. 先交换长短边并把角度跟着转 90°;
  2. 再折进 [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 越界静默失效------这个组合可能产生最危险的静默错误,我没测。


本期互动

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

  1. 把你的阈值代码改成"圆度 + 指定周长估计器" ,然后用正圆测一遍。如果你得到 0.8955 而不是接近 1.0,说明你的圆度阈值和估计器不匹配------按 §五 那张表重新标定。
  2. 量一下你的图像预处理有没有把 uint8 变成 float64。 在喂 cornerSubPix 之前打一行 print(img.dtype),成本一行代码,省掉一类极难排查的 (-210)。
  3. 在真机上做一次"干净 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 默认行为"这条线继续下去。

相关推荐
2601_956743681 小时前
上海GEO营销公司技术评估方法|以盾码无界公开产品路线为例,拆解企业知识库语义检索、资料版本核验、内容人工审核与大模型监测复测的验证要点及适用边界
人工智能·geo·上海·企业服务
中伟视界2 小时前
AI视频监控矿山落地实践:从算法部署到误报调优全流程
人工智能
打工仔折腾 AI2 小时前
FaceFusion本地换脸实战:Windows整合包、模型选择与遮罩调参记录
人工智能·windows·后端·python·深度学习·性能优化·ai agent 实战
DeviceHub2 小时前
AI服务器电源设计进阶:TLVR电感的应用解析与选型验证指南
运维·服务器·人工智能
阿明副业观察2 小时前
AI视频生成工具功能与作用全面解析:赋能高效内容创作
大数据·人工智能·aigc·音视频·ai写作
数智工坊2 小时前
视觉SLAM第5讲|相机成像模型:针孔投影、畸变修正与深度感知全拆解
人工智能·深度学习·数码相机·机器人
坐吃山猪2 小时前
Meta为Personal Agent推出一个新协议PAP
人工智能
要加油哦~2 小时前
论文 | vision2web 基于代理验证的可视化网站开发分层基准测试
人工智能·论文
重生之高冷汉堡包2 小时前
AI 智能体如何通过 auth.md 注册 Bright Date:完整实操指南
人工智能