OpenCV 实战第 7 篇:Canny 阈值自适配、findContours 层级与 approxPolyDP

为什么固定阈值的 Canny 换个光源就全变成噪点?
阈值分割解决的是"多亮算前景",边缘检测解决的是"哪里亮度突变"。这篇把 Canny / findContours / approxPolyDP 三件套摊开,用实测数据 回答三个问题:Canny 到底有几个参数、阈值能不能自己算、轮廓层级选错会丢什么。其中有两个结论和主流说法相反:Canny 根本没有 sigma 参数 ,以及 findContours 在 5.0.0 上不改写你的输入图。
版本说明 :本文所有像素级输出、阈值数值、耗时和异常行为均在 OpenCV 5.0.0 实测获得(Python 绑定,opencv-python-headless)。API 语义以 4.13.0 官方文档发布为准。两者不一致处,本文保留实测结果并标出"实测",不做隐藏。

代码说明 :本文的 C++ 阈值统计逻辑已用 g++(llvm-mingw,UCRT,x86_64)实际编译运行 验证------喂入 Python 侧算好的梯度矩阵,120 组结果与 numpy 在 1e-4 相对误差内一致。但本机没有 OpenCV C++ 头文件 ,所以 cv:: 调用本身未在此机编译执行;凡涉及 cv:: 行为的数字均来自 Python 实测。


上篇回顾

上一篇讲几何变换,核心是一个反直觉结果:对单应矩阵几何,remap 预计算映射表比 warpPerspective + WARP_INVERSE_MAP 慢 1.88 倍 ;只有非线性位移(镜头畸变)复用映射表才快 15.17 倍。结论是别把非线性场景的优化经验直接搬到线性场景。

本篇换到边缘与轮廓,但底层是同一个道理:参数不是"越大越好"或"照抄教程",而是要和数据的实际分布匹配 。Canny 的阈值就是典型------你抄来的 50, 150 在 A 光源下刚好,在 B 光源下要么全是噪点,要么一条边都不剩。


一、Canny 的真实参数:5 个,没有 sigma 也没有"连通性"

先破一个几乎所有中文教程都写错的地方。Canny 的完整签名是:

text 复制代码
Canny(image, threshold1, threshold2[, edges[, apertureSize[, L2gradient]]])

一共 5 个参数。 我把流传最广的三个"参数"逐个传了一遍,看 OpenCV 认不认:

cpp 复制代码
// 后文反复用到的小工具:两幅二值图逐像素不同的像素数
static int countDiff(const cv::Mat& a, const cv::Mat& b) {
    cv::Mat d;
    cv::compare(a, b, d, cv::CMP_NE);      // 不等处为 255
    return cv::countNonZero(d);
}

// 教程常见的四个说法。在 C++ 里它们连编译都过不去 ------ 这反而是好事,
// 比"传进去没报错但悄悄失效"强得多。
cv::Canny(src, dst, 60, 160, 3, /*L2gradient=*/false, /*sigma=*/1.2);
// error: no matching function for call to
//   'Canny(InputArray, OutputArray, int, int, int, bool, double)'
// note: candidate function not viable: no known conversion from 'double' to 'bool'
//
// cv::Canny(src, dst, 60, 160, apertureSize = 3, sigma = 1.2);
//                                        ^^^^^^^^^ C++ 也没有具名参数
//
// Python 绑定侧实测(把四个关键字逐个传进去,看 OpenCV 认不认):
//   sigma        -> rejected
//   ksize        -> rejected
//   connectivity -> rejected
//   blocksize    -> rejected

实测输出(4 个全部 rejected):

关键字 实测结果 流传说法的问题
sigma ❌ rejected 不存在 。而且比"不可配置"更强:现代 OpenCV 的 Canny 内部根本不做高斯平滑
ksize ❌ rejected 不存在 。不是 GaussianBlur 的那个 ksize
connectivity ❌ rejected 不存在。连通性由滞后阈值机制隐式决定
blocksize ❌ rejected 不存在 。那是 adaptiveThreshold 的参数

⚠️ 这四个错误参数的来源基本能猜到:把 Canny 和 adaptiveThreshold(blockSize)、GaussianBlur(ksize/sigma)混在了一起。如果你在 C++ 里写了 cv::Canny(src, dst, 50, 150, 3, false, 1.2),第 7 个参数是 L2gradient(布尔),不是 sigma。 多传一个 double 会直接编译不过------这反而是好事,至少不会静默生效。

真实的 5 个参数

参数 实测/文档含义 默认 踩坑要点
image 输入,必须 CV_8U 单通道 --- 传 16U/32F/8S 抛异常
threshold1 / threshold2 双阈值滞后:小的做连接(linking),大的做强边缘种子 必需 实测与大小顺序无关,见下
edges 输出图;传回输入即原地改写 新建 危险,见坑 1
apertureSize Sobel 算子的孔径,不是高斯核 3 源码校验原文:"Aperture size should be odd between 3 and 7";-1 额外表示 Scharr
L2gradient 用 L2 范数替代 L1 近似 false 实测边缘像素数 3967 → 3873

双阈值与顺序无关,但 apertureSize 会放大 8 倍

三个能失败的测试:

cpp 复制代码
// ① 顺序无关吗?------交换两个阈值,比较输出
cv::Mat e_ab, e_ba, e_hi;
cv::Canny(src, e_ab,  60, 160, 3);
cv::Canny(src, e_ba, 160,  60, 3);
std::cout << "swap   differing px = " << countDiff(e_ab, e_ba) << "\n";  // 实测 0

// ② 阳性对照:阈值真的有用吗?------换一个大阈值必须有差异,
//    否则上面那个"差异 0"只是因为阈值在这张图上无关紧要,测试等于没做
cv::Canny(src, e_hi, 60, 200, 3);
std::cout << "raise  differing px = " << countDiff(e_ab, e_hi) << "\n";

实测 (60,160) 与 (160,60) 差异 0 像素------源码里有一行直白的解释:

cpp 复制代码
// canny.cpp
if (low_thresh > high_thresh) std::swap(low_thresh, high_thresh);

OpenCV 自己把小的那个当连接阈值、大的那个当强边缘种子,所以你传反了不会错,也不会有警告。 而 (60,160) 与 (60,200) 在这张图上差异也是 0(这张图的梯度分布是双峰的,正好落在空隙里,这也是我下面要造对比度阶梯的原因)。

apertureSize 则完全不同------它不是"精调旋钮",是量级开关:

apertureSize 实测边缘像素数 相对 3 倍数
1 / 2 / 4 / 9 ❌ 抛异常 ---
3 3,967 1.00×
5 32,176 8.11×
7 57,511 14.50×

孔径越大,Sobel 核覆盖的邻域越宽,梯度累加越多。从 3 改到 5,边缘数量涨 8 倍,而你一个字节的图都没换。

但注意这个倍率不是单调加速的 ------3→5 涨 8.11×,5→7 只再涨 1.79×。原因在源码里,而且这个归一化只做了一半:

cpp 复制代码
// canny.cpp,两处并不相邻:
// (1) 在 Canny() 里------阈值先除
if (aperture_size == 7) { low_thresh /= 16.0; high_thresh /= 16.0; }

// (2) 在 parallelCanny::operator() 里------梯度也除
scale = (aperture_size == 7) ? (1 / 16.0) : 1.0;

也就是说 OpenCV 想让梯度与阈值同比例缩放 ,好让阈值跨孔径保持可移植------但它只对 apertureSize=7 做了,对 5 一点都没做 。所以 3→5 是"核变宽、梯度膨胀、阈值照旧"的裸涨(8.11×),5→7 则被 /16 抵消掉了一大半(只剩 1.79×)。

这才是"改孔径必须重调阈值"的准确理由 :不是因为孔径和阈值天然耦合,而是因为 OpenCV 的补偿逻辑有缺口 。你若指望 apertureSize 在 3/5/7 之间自由切换而阈值照搬,行为是不一致的------而且这个不一致没有任何文档说明,只能从源码看出来。


二、算子选型速查表

需求 用什么 别用什么 实测依据
只要二值边缘(0/255) Canny Sobel + threshold 后者边缘双线、宽度随梯度变化
只要梯度幅值(自定阈值) Sobel + magnitude Canny Canny 的双阈值你无法直接控制
图像有噪声 GaussianBlur → Canny 直接 Canny 实测伪边缘占 97.9%
目标内孔洞也算独立目标 RETR_TREE / RETR_CCOMP RETR_EXTERNAL 实测 EXTERNAL 丢 2/3 轮廓
只关心最外层包围 RETR_EXTERNAL RETR_TREE 省层级,实测 1 vs 3 轮廓
后续要算面积/周长/逼近多边形 CHAIN_APPROX_SIMPLE CHAIN_APPROX_NONE 实测顶点数 1736 → 204
需要每个边界像素 CHAIN_APPROX_NONE CHAIN_APPROX_SIMPLE SIMPLE 会丢掉共线中间点
输入是 16U/32F/8S 先 convertTo(CV_8U) 直接调 实测四种位深全部抛异常
场景 640×480、只要外轮廓 RETR_EXTERNAL + SIMPLE TREE + NONE 实测耗时差异在噪声内(0.092--0.116 ms)

三、为什么必须先 GaussianBlur 再 Canny:把"假边缘"量化出来

Canny 用一阶差分算梯度,一阶差分对噪声极其敏感。这不是定性说法,我把它量化了。

测试设计:先用干净图算出"真边缘"作为基准(3,967 px),再加入 σ=25 的高斯噪声,然后对比三种处理。

cpp 复制代码
cv::Mat clean = src;                        // 无噪声基准
cv::Mat noisy, blurred;                     // σ=25 噪声 / 先降噪
addGaussianNoise(clean, noisy, 25.0);
cv::GaussianBlur(noisy, blurred, cv::Size(5, 5), 1.4);

cv::Mat e_clean, e_raw, e_blur;
cv::Canny(clean,   e_clean,  60, 160, 3);
cv::Canny(noisy,   e_raw,    60, 160, 3);
cv::Canny(blurred, e_blur,   60, 160, 3);

std::cout << "clean   = " << cv::countNonZero(e_clean)  << "\n";   // 实测 3967
std::cout << "raw     = " << cv::countNonZero(e_raw)    << "\n";   // 实测 109518
std::cout << "blurred = " << cv::countNonZero(e_blur)   << "\n";   // 实测 3765
// 差集:噪声图有、干净图没有的边缘 = 纯噪声制造的假边缘
std::cout << "spurious= " << countDiff(e_raw, e_clean)   << "\n";   // 实测 107190

实测结果:

场景 边缘像素 相对干净图 结论
干净基准 3,967 1.00× 真边缘
σ=25 噪声,不降噪 109,518 27.61× 边缘数量暴涨 27 倍
σ=25 噪声,先 GaussianBlur 3,765 0.95× 几乎完全恢复

其中 107,190 个像素(占原始的 97.9%)是干净图里根本不存在的边缘------它们是噪声梯度被双阈值机制"确认"后留下的。

这解释了工业视觉里一个常见的翻车现场 :实验室样本拍得干净,参数调好了一整版;产线样本表面有细微纹理,同样的参数一上去,满屏毛刺。问题不在 Canny,在样本的噪声水平变了,而你把阈值当成了定值。

但"多模糊一点"不是解法------一个会失败的配平测试

上面那句很容易被读成"那就多 blur"。这是错的。模糊过强会把真边缘一起抹掉。我用一条 3 px 宽的细杆做守门测试:

cpp 复制代码
// kThinBarRows = 细杆所在的行区间;用它做守门测试
for (auto [ksize, sigma] : {std::pair{3, 1.1}, {5, 1.4}, {9, 3.0},
                            {15, 5.0}, {21, 7.0}}) {
    cv::Mat b, edges;
    cv::GaussianBlur(src, b, cv::Size(ksize, ksize), sigma);
    cv::Canny(b, edges, 60, 160, 3);
    std::cout << "blur(" << ksize << ") sigma=" << sigma
              << "  total=" << cv::countNonZero(edges)
              << "  thin_bar=" << cv::countNonZero(edges(kThinBarRows, cv::Range::all()))
              << "\n";
}

实测:

GaussianBlur σ 总边缘像素 细杆带边缘像素
(3, 3) 1.1 3,847 746
(5, 5) 1.4 3,447 746
(9, 9) 3.0 3,125 750
(15, 15) 5.0 0 0
(21, 21) 7.0 0 0

(15,15) σ=5.0 时全图边缘归零 。原因很直白:σ=5 的高斯把 3 px 宽的杆的对比度稀释到梯度阈值以下,Canny 一个边缘都找不到。

⚠️ 关键陷阱:σ=5 的 GaussianBlur 配上本例阈值会给出"全零",看起来像"代码没跑"或"图是空的",实际是参数配错。 排查顺序应该是:先确认输出尺寸和 countNonZero,再怀疑阈值,最后才怀疑图。

所以正确顺序是 GaussianBlur(小 σ,去传感器噪声)→ Canny(阈值)→ 后续处理。σ 的上限由你最细的结构决定,不是由噪声决定。


四、对比度阶梯:低对比目标是悄悄消失的

前两节反复出现一个现象:(60,160) 和 (60,200) 差异 0。为什么?因为这张图的梯度分布是双峰的(大片平坦背景 + 少量强边缘),两个阈值都落在峰之间的空隙里,怎么调都一样。

梯度分布是空隙的,阈值实验就是无效实验。 我在场景里加了一条对比度阶梯------对比度依次为 2、4、8、16、32、64、128 灰阶的矩形,把梯度分布填成连续的:

cpp 复制代码
// 局部背景必须实测,不能假设:背景有 40 + 60*x/W + 30*y/H 的渐变。
// 硬编码 bg=180 会让每级台阶的真实对比度都变成 ~80..230,阶梯测不出任何差异。
const int kLadder[] = {2, 4, 8, 16, 32, 64, 128};
for (int i = 0; i < 8; ++i) {
    const int x0 = 30 + i * 84;
    const int c  = kLadder[i];

    // 取台阶左侧一小块背景,求均值 = 这一级的真实局部背景
    cv::Rect patch(std::max(x0 - 14, 0), 16, 12, 98);
    cv::Scalar local_bg = cv::mean(img(patch));

    cv::rectangle(img, cv::Rect(x0,  16, 64, 44), local_bg[0] - c, cv::FILLED);  // 暗台阶
    cv::rectangle(img, cv::Rect(x0,  70, 64, 44), local_bg[0] + c, cv::FILLED);  // 亮台阶
}

然后扫高阈值,记录每级台阶的边缘像素(活着的台阶应读到约 128 px,因为它有上下两条横边):

threshold2 c=2 c=4 c=8 c=16 c=32 c=64 c=128
10 218 228 206 225 194 204 197
20 148 172 167 109 161 150 152
40 88 113 144 0 140 140 139
80 0 0 0 0 140 140 139
160 0 0 0 0 0 140 139
320 0 0 0 0 0 140 139

这是一张教科书级的表:低对比台阶按顺序消失 。c=2 在 threshold2=80 就没了,c=32 活到 160,只有 c=64 和 c=128 顶到 320。

⚠️ 这张表就是"换个光源全变噪点"的机制。 产线换了灯 → 目标与背景的对比度整体从 c=64 掉到 c=8 → 固定阈值下,要么低对比目标整个消失(假阴性) ,要么为了留住它把阈值压到噪声级别下 → 满屏假边缘(假阳性) 。两个方向都是坑,而且没有报错。


五、Canny 阈值自适配:为什么"取梯度中位数"是最差选项

既然固定阈值和光照耦合,那就让阈值跟着图像走。思路很清楚:先算梯度幅值图,用它的统计量当基准,再乘一个系数变成双阈值。

但"取中位数"这个直觉几乎是最差的实现。 我把 8 种统计量在同一个光照扫描下对比了一遍(gain ∈ {0.15, 0.25, 0.40, 0.70, 1.0} × offset ∈ {0, 40, 80},共 15 个条件)。指标是边缘像素数的变异系数 edgeCV%------越低说明越稳:

python 复制代码
def adaptive_thresholds(img_u8, mode, k=2.0, ratio=0.4):
    f = img_u8.astype(np.float32)
    gx = cv2.Sobel(f, cv2.CV_32F, 1, 0, ksize=3)
    gy = cv2.Sobel(f, cv2.CV_32F, 0, 1, ksize=3)
    g = cv2.magnitude(gx, gy)
    if mode == "median_all":
        base = float(np.median(g))
    elif mode.startswith("p"):
        base = float(np.percentile(g, int(mode[1:])))
    elif mode == "med_top10":
        thr = np.percentile(g, 90)              # 最强 10% 像素
        top = g[g >= thr]
        base = float(np.median(top)) if top.size else 0.0
    # ...
    hi = max(base * k, 1.0)     # 0/负阈值对 Canny 无意义,必须兜底
    return max(hi * ratio, 1.0), hi, base

先看梯度分布本身(干净图):

text 复制代码
median=2.000  p90=10.770  p99=489.001  max=656.201  zeros=145650/307200

中位数是 2.0,而 p99 是 489------差了 245 倍。 47% 的像素梯度精确为 0。这就是"中位数失效"的全部原因。

实测对比表:

统计量 平均基准梯度 edgeCV% 边缘数 min--max 平均边缘数
median_all(中位数) 0.68 39.26% 10,806--33,611 21,152
median_nonzero(非零中位数) 3.59 45.47% 5,084--13,969 7,925
p75 3.75 46.94% 4,902--13,969 7,892
p90 5.77 35.82% 4,699--10,150 5,917
med_top10(最强 10% 的中位数) 7.18 5.51% 4,296--5,039 4,602
p95 7.48 2.58% 4,296--4,635 4,477
p98 147.01 ❌ 全 0 0--0 0
p99 232.34 ❌ 全 0 0--0 0

同一扫描下的固定阈值基线:

固定阈值 edgeCV% 边缘数 min--max 平均边缘数
(30, 60) 21.55% 2,042--4,174 3,407
(60, 160) 82.99% 0--3,447 1,870
(45, 90) 53.50% 0--3,873 2,643

三个结论:

  1. p95 完胜 :2.58% vs 最好的固定阈值 21.55%,稳定性好 8.35 倍 ;和 p90 下的固定基线比更是 33 倍。
  2. "取中位数"是最差的:39.26%--46.94%,比最好的固定阈值还差。它取到的是平坦背景的梯度,阈值恒定贴在 1.0 兜底值附近,于是任何光照变化都会把它推成噪点爆发。
  3. p98/p99 会全灭 :取到 147 / 232,而干净图 p99 才 489------阈值贴近最强边缘,于是光照一变就什么都检不出(实测全部 15 个条件边缘数都是 0)。高百分位不是"更稳",是"过度自信"。

⚠️ 选百分位的直觉是"越高的百分位越贴近边缘",但梯度幅值分布极度长尾 :p95 = 7.48,p98 = 147.0,中间 3 个百分位跳了 20 倍 。这意味着百分位参数极其敏感 ,p97 可能和 p98 差出一个数量级。它必须作为一个待标定参数进配置文件,不能硬编码。

C++ 实现(nth_element 版本的坑,本机实测)

C++ 侧要把这段逻辑手写出来。核心是在梯度图上取百分位,最自然的想法是 std::nth_element(平均 O(N),不排序):

cpp 复制代码
// 按周长无关的百分位取阈值基准。n 为元素个数,返回插值百分位。
double percentileSorted(std::vector<float> v, double p) {
  if (v.empty()) return 0.0;
  const double n  = static_cast<double>(v.size());
  const double ix = p / 100.0 * (n - 1.0);
  const std::size_t lo = static_cast<std::size_t>(std::floor(ix));
  const std::size_t hi = static_cast<std::size_t>(std::ceil(ix));
  const double frac   = ix - static_cast<double>(lo);
  std::sort(v.begin(), v.end());
  return v[lo] + frac * (v[hi] - v[lo]);   // 与 numpy 默认线性插值一致
}

// nth_element 版本:平均 O(N),但取的是「顺序统计量」而非插值百分位
double percentileNth(std::vector<float>& v, double p) {
  if (v.empty()) return 0.0;
  const std::size_t n = v.size();
  std::size_t k = static_cast<std::size_t>(
      std::ceil(p / 100.0 * static_cast<double>(n)));
  k = std::clamp<std::size_t>(k, 1, n);
  std::nth_element(v.begin(), v.begin() + (k - 1), v.end());
  return static_cast<double>(v[k - 1]);
}

本机实测(g++ llvm-mingw 实际编译运行,喂入 Python 算好的梯度,15 条件 × 8 统计量 = 120 组):

统计量 线性插值 vs numpy 最大相对差 nth_element vs 插值最大绝对差 有差异的组数
median_all / median_nonzero / med_top10 ≤ 1e-4(全部一致) 0.000000 0/15
p75 / p90 / p95 ≤ 1e-4(全部一致) 0.000000 0/15
p98 ≤ 1e-4(全部一致) 0.019856 4/15
p99 ≤ 1e-4(全部一致) 0.000293 4/15

⚠️ nth_element 版本不是 np.percentile 的等价物。 它取的是"第 k 小的元素",而 numpy 默认做线性插值取"第 k 和第 k+1 之间的加权值"。低百分位上两者恰好重合(梯度值高度重复),到 p98 就分家了------最大相对差 9.5e-5。

实践建议:要复现 Python/论文里的数字就用插值版 (需要 std::sort,O(N log N));在线热路径上可以用 nth_element 版 ,但要接受阈值有 ~1e-5 级别的偏移,并且在注释里写明用的是哪种。混用两者去"对齐"实验结果,只会浪费一整天。


六、findContours:签名、位深、层级

findContours 是本系列第二个"返回值必须接住"的坑(第 5 篇讲过 adaptiveThreshold 的第一个)。它有三个互相独立的陷阱,其中一个和流传最广的说法相反。

陷阱一:返回值从 3 个变成 2 个(4.x 起)

python 复制代码
# 这个坑是 Python 绑定特有的:C++ 用出参,没有"元数"问题。
contours, hierarchy = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

实测返回元数 = 2 ,类型 ['tuple', 'ndarray']。OpenCV 3.x 及更早返回 3 个(含被改写的源图),4.x 移除了第三个返回值。C++ 侧对应写法是出参,不受此影响:

cpp 复制代码
std::vector<std::vector<cv::Point>> contours;
std::vector<cv::Vec4i> hierarchy;
cv::findContours(img, contours, hierarchy, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);

实测返回元数 = 2 ,类型 ['tuple', 'ndarray']。OpenCV 3.x 及更早返回 3 个(含被改写的源图),4.x 移除了第三个返回值。

⚠️ 3.x 时代的老代码 image, contours, hierarchy = cv2.findContours(...) 在 4.x+ 会抛 ValueError: not enough values to unpack。反过来,如果你的代码里还有一个变量接住了"源图",它现在接到的是 contours。 更隐蔽的是:如果某处写成了 contours, hierarchy = ... 而上游还在用改写后的源图,你会拿到一个"看起来正常但已经被削掉形状"的数组。

陷阱二(与主流说法相反):5.0.0 不改写输入

我在规划里原本把"输入会被改写"列为必讲坑,实测推翻了这个预期:

cpp 复制代码
// 传副本进去,比较调用前后是否逐像素相同
cv::Mat work = src.clone();
cv::Mat before = work.clone();
std::vector<std::vector<cv::Point>> cs;
std::vector<cv::Vec4i> h;
cv::findContours(work, cs, h, cv::RETR_LIST, cv::CHAIN_APPROX_SIMPLE);
std::cout << "input unchanged? " << (countDiff(work, before) == 0) << "\n";  // 实测 1

实测:findContours 在 5.0.0 上不修改输入数组(逐像素相等)。非连续的 ROI view 也能直接传,view 和它的 parent buffer 都不被修改:

cpp 复制代码
// ROI 切片在 C++ 里同样是 view(共享数据),不是拷贝
cv::Mat big = cv::Mat::zeros(H + 80, W + 120, CV_8U);
src.copyTo(big(cv::Rect(60, 40, W, H)));
cv::Mat roi = big(cv::Rect(60, 40, W, H));        // 非连续 view
cv::Mat roi_before = roi.clone();

std::vector<std::vector<cv::Point>> cs;
cv::findContours(roi, cs, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
// 实测:接受,1 个轮廓;view 未被改写;parent buffer 也未被改写
// roi.isSubmatrix() == true 表示它确实是父矩阵的一块内存
std::cout << "contours=" << cs.size()
          << " view unchanged=" << (countDiff(roi, roi_before) == 0)
          << " parent unchanged=" << (countDiff(big(cv::Rect(60, 40, W, H)), roi_before) == 0)
          << "\n";

⚠️ 这不等于可以依赖它。 官方文档把这条规则写得很清楚,但版本分界点很容易记错 :3.2 之前的文档写的是「findContours function modifies the source image」(并建议「先另存一份」);自 3.2 起文档改成了「Since OpenCV 3.2, findContours() no longer modifies the source image but returns a modified image as the first of three return parameters」 ------也就是说不改写是 3.2 开始的,不是 4.x ;4.x 做的只是把那个「改写后的副本」从返回值里去掉,三元组收敛成二元组。"当前版本不改写"是版本事实,不是 API 契约。 如果你的代码要跨版本跑,或者你依赖的是"改写后原图还能用"这种老版本副作用,显式 clone() 是唯一安全的写法------它在你自己的代码里,不在 OpenCV 的版本承诺里。

陷阱三:只接受 CV_8U,且 RETR_FLOODFILL 连 8U 都抛

实测 mode × 位深矩阵(X = 抛异常):

mode 8U 8S 16U 32F
RETR_LIST 2 轮廓 X X X
RETR_TREE 2 轮廓 X X X
RETR_EXTERNAL 1 轮廓 X X X
RETR_CCOMP 2 轮廓 X X X
RETR_FLOODFILL X X X X

64F 同样抛异常。注意最后一行 :异常信息里写着"除 RETR_FLOODFILL 和 RETR_CCOMP 外只支持 CV_8UC1",暗示这两个 mode 是位深兼容的例外------实测 5.0.0 上不成立 :RETR_CCOMP 对 16U 照样抛,RETR_FLOODFILL 对 8U 也抛。

⚠️ 异常信息本身在这个版本上是错的。 不要根据错误文本来推断你的代码该怎么改------按错误文本你以为 RETR_CCOMP 能吃 16U,实际不能。看矩阵,实测优先于文档措辞,也优先于异常文本。

RETR_* 模式:层级选错会静默丢轮廓

我造了一个三层嵌套场景:外框(level 1)→ 内孔(level 2)→ 孔内的岛(level 3):

cpp 复制代码
cv::Mat scene = cv::Mat::zeros(300, 400, CV_8U);
cv::rectangle(scene, {40, 40},  {360, 270}, 255, -1);   // level 1
cv::circle   (scene, {200, 150}, 70, 0,   -1);          // level 2: 孔
cv::rectangle(scene, {170, 120},{230, 180}, 255, -1);   // level 3: 岛(完全在孔内)

实测(岛必须完全在孔内,早期版本让岛跨越孔边界 x=140,层级就断了,测不出三层):

mode 轮廓数 有 parent 链接 有 child 链接 最大层级深度
RETR_EXTERNAL 1 0 0 0
RETR_LIST 3 0 0 0
RETR_CCOMP 3 1 1 1(两层)
RETR_TREE 3 2 2 2(三层)

RETR_EXTERNAL 返回 1 个轮廓------孔和岛全部消失,而且不报错。 这就是那个经典 bug:"我明明画了个带孔的零件,为什么只框出外面一层"。

选择依据:

mode 何时用 代价(实测 640×480,200 次)
RETR_EXTERNAL 只关心最外层包围(整体定位、计数) 0.092--0.105 ms
RETR_LIST 平铺所有轮廓,不需要知道谁套着谁 0.092--0.101 ms
RETR_CCOMP 只要两层(对象 + 孔),典型是"零件带孔" 0.109--0.116 ms
RETR_TREE 完整层级,嵌套结构必须还原 0.106--0.108 ms

⚠️ 耗时差异全部落在 0.092--0.116 ms 之间,在噪声内。 很多人以为 RETR_TREE 比 RETR_LIST 慢很多、为了省时间要用 EXTERNAL------在这个分辨率和这个图上,测不出这种差别。 选 mode 应该按语义(你需不需要层级),不是按性能。真正的大图(4000×3000、多目标)上差距才会显现,那时候应该实测而不是照抄。


七、CHAIN_APPROX_NONE vs CHAIN_APPROX_SIMPLE:10 倍顶点数

mode 轮廓数 总顶点数 相对 NONE 每轮廓顶点数
CHAIN_APPROX_NONE 3 1,736 1.00× [240, 396, 1100]
CHAIN_APPROX_SIMPLE 3 204 0.12× [4, 196, 4]
  • NONE:存每个边界像素,所以直边上会出现大量共线中间点(实测第 3 个轮廓 1,100 个点)。
  • SIMPLE:只保留共线段的端点,直边压成 2 个点。实测第 1 个轮廓 240 → 4 个点。
  • 实测 contourArea 和 boundingRect 在两种 mode 下结果完全相同(边缘的共线中间点不影响面积和包围盒)。

什么时候必须用 NONE? 两个场景:一是需要沿边界逐像素做亚像素定位或找法向;二是轮廓被噪声打断成短线段时,SIMPLE 会把短线段的点数压到接近轮廓本身的数量,信息全丢。第 8 篇的亚像素测量会详细展开这个区别。

⚠️ 那个 196 点的圆孔是重点:SIMPLE 只能压掉严格共线 的点,压不掉数字圆周的阶梯锯齿 。所以"用了 SIMPLE 就等于 4 个点"是错觉------只有真正的直线才降到 2 个点,曲边仍会有大量拐点。这也是下一节 approxPolyDP 存在的理由。


八、contourArea / arcLength / boundingRect:三个度量,三个不同的"真值"

我拿一个旋转 30° 的 240×120 矩形(真面积 28,800)做对照:

cpp 复制代码
// 旋转 30° 的矩形,中心 (200,150)
cv::Point2f box = cv::RotatedRect(cv::Point2f(200, 150), cv::Size2f(240, 120), 30).box;
cv::Mat scene = cv::Mat::zeros(300, 400, CV_8U);
cv::rectangle(scene, box, 255, -1, cv::LINE_AA);
std::vector<std::vector<cv::Point>> contours;
cv::findContours(scene, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
const auto& cnt = contours[0];

实测:

度量 实测值 真值 偏差 原因
contourArea 28,521.0 28,800 -0.97% 鞋带公式算的是像素中心构成的多边形,栅格化时边界像素中心落在理想边界内侧
arcLength(闭合) 769.1 720 +6.82% 阶梯化的斜边在像素格上走"曼哈顿路径"而不是直线,长度必然偏大
arcLength(closed=false) 767.7 720 +6.63% 少一条边的闭合代价
boundingRect 面积 60,032 28,800 +108.5%(2.08×) 轴对齐包围盒,旋转 30° 时把空角全算进去了
minAreaRect 面积 28,854.5 28,800 +0.19% 最小外接旋转矩形
moments["m00"] 28,521.0 28,800 -0.97% 实测与 contourArea 逐位相同

还有一条容易被忽略的实测结果,以及它的正确用法:

cpp 复制代码
// 直接判:一个理想矩形在栅格化之后被判为非凸
std::cout << cv::isContourConvex(cnt) << "\n";            // 实测 0(False)

// 正确用法:先简化到 4 点,再判凸性
std::vector<cv::Point> poly;
cv::approxPolyDP(cnt, poly, 0.02 * cv::arcLength(cnt, true), true);
std::cout << poly.size() << " pts, convex = "
          << cv::isContourConvex(poly) << "\n";           // 实测 4 点, convex = 1

一个理想矩形,在栅格化之后 isContourConvex 返回 False。 因为阶梯化的边界让轮廓出现了极微小的凹陷(每级台阶的转角),凸性判定因此失败。

⚠️ isContourConvex 在栅格数据上不可靠。 需要判断"这个轮廓是不是凸的"(比如做手眼标定的标定板定位),必须先用 approxPolyDP 简化到 4 点再判,或者自己算 cv::contourArea(cnt) / cv::minEnclosingArea 类比值,或者直接用 minAreaRect 的角点。不要把 isContourConvex 当成几何真值。

选型速查:

你要的量 用什么 注意
面积(形状、缺陷占比) contourArea 有孔时它是含孔的外轮廓面积
周长(形状描述、逼近) arcLength(cnt, true) 栅格化后偏大 ~6%
快速粗筛包围盒 boundingRect 旋转物体偏大到 2 倍以上
旋转物体的真实包围范围 minAreaRect 偏差 0.19%,推荐用于测量
质心、二阶矩、椭圆拟合 moments 5.0.0 返回 dict

九、approxPolyDP:epsilon 按周长百分比,还是绝对值?

approxPolyDP 把轮廓简化成多边形,唯一要定的是 epsilon------允许的最大偏离。两种写法:

cpp 复制代码
// 按周长百分比(尺度无关,推荐)
double eps = cv::arcLength(cnt, true) * 0.02;
std::vector<cv::Point> poly;
cv::approxPolyDP(cnt, poly, eps, true);

// 绝对值(尺度相关)
cv::approxPolyDP(cnt, poly, 2.0, true);

实测三个形状。矩形 (原始 640 点,周长 640.00)在百分比和绝对值两种写法下全部输出 4 个点 ,所以它对 epsilon 不敏感------真正有信息量的是圆 (原始 564 点,周长 661.75)和不规则块(原始 508 点,周长 595.81):

周长百分比(尺度无关):

epsilon 0.1% 0.2% 0.5% 1.0% 2.0% 5.0%
圆 → 顶点数 64 32 16 12 8 4
不规则块 → 顶点数 88 32 16 12 8 4

绝对值(尺度相关):

epsilon 0.5 1.0 2.0 5.0 10.0
圆 → 顶点数 124 32 20 16 8
不规则块 → 顶点数 116 32 16 16 8

三个可直接用的结论:

  1. 绝对值和图像尺度、目标大小耦合。 eps=0.5 在圆上给 124 个点、在矩形上给 4 个点------同一条语句,输出差 31 倍。目标尺寸随镜头高度变化时,固定绝对值会让逼近点数不可控。
  2. 周长百分比是尺度无关的选择。 同一张图上 0.5% 对应 16 个点,1.0% 对应 12 个点,可预测。
  3. 矩形在任何 epsilon 下都是 4 点。 因为它的轮廓严格是 4 段直线,approxPolyDP 只需删掉 636 个共线中间点。所以**"逼近出 4 个点"不能作为参数调好的证据**------真实世界的圆角矩形会给出 8 点甚至更多,第 10 节的案例会看到这个区别。

⚠️ epsilon 偏大时不会报错,只会让多边形"看起来还行"但角度失真 。对圆取 5% 得到 4 个点,是一个正方形------顶点有,但形状全错。验证方式不是看顶点数,是量逼近后的面积/角度误差。


十、案例:板材零件轮廓提取,输出多边形顶点

把前面所有算子串成一条完整流水线。案例项目是金属板材表面缺陷检测与几何测量系统(教学简化)里的"零件轮廓提取"模块:给一张带光照渐变的板材图,输出每个零件的多边形顶点,供第 8 篇的几何测量使用。

cpp 复制代码
// 零件轮廓提取:blur -> adaptive Canny -> findContours -> approxPolyDP
struct PartResult {
    std::vector<cv::Point> polygon;   // 简化后的顶点(已按顺序)
    double area_px     = 0.0;
    double perimeter_px = 0.0;
    cv::RotatedRect box;              // 旋转包围矩形,供测量用
};

// p 是要传给 approxPolyDP 的周长百分比,作为标定参数而非硬编码
std::vector<PartResult> extractParts(const cv::Mat& gray8u,
                                     const cv::Rect& roi,
                                     double epsilon_percent = 0.02) {
    std::vector<PartResult> out;
    if (gray8u.empty() || gray8u.type() != CV_8UC1) {
        return out;                   // 显式挡掉非 8U:findContours 会抛
    }

    // 1) ROI 切片是 view,不是拷贝。跨版本安全起见显式 clone:
    //    3.2 之前 findContours 就地改写源图;3.2 起改为返回改写后的副本
    //    (3.x 三返回值,4.x 去掉第一个返回值)。
    cv::Mat work = gray8u(roi).clone();

    // 2) 小 sigma 降噪。sigma 上限由最细结构决定,不由噪声决定。
    cv::Mat smooth;
    cv::GaussianBlur(work, smooth, cv::Size(5, 5), 1.4);

    // 3) 阈值自适配:用 p95 梯度百分位当基准。p95 是实测最稳的选择
    //    (edgeCV 2.58% vs 固定阈值最好 21.55%)。
    cv::Mat gx, gy, gmag;
    cv::Sobel(smooth, gx, CV_32F, 1, 0, 3);
    cv::Sobel(smooth, gy, CV_32F, 0, 1, 3);
    cv::magnitude(gx, gy, gmag);

    const double base = percentileOf(gmag, 95.0);   // 插值版百分位
    const double hi   = std::max(base * 2.0, 1.0);  // 兜底:0 阈值无意义
    const double lo   = std::max(hi * 0.4, 1.0);

    cv::Mat edges;
    cv::Canny(smooth, edges, lo, hi, 3, false);

    // 4) TREE 模式:孔洞也是零件上的孔,必须保留层级
    std::vector<std::vector<cv::Point>> contours;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(edges, contours, hierarchy,
                     cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);

    for (size_t i = 0; i < contours.size(); ++i) {
        const auto& cnt = contours[i];
        if (cnt.size() < 3) continue;               // 多边形至少 3 点

        PartResult r;
        r.area_px      = std::abs(cv::contourArea(cnt));
        r.perimeter_px = cv::arcLength(cnt, true);
        r.box          = cv::minAreaRect(cnt);       // 旋转物体用它,不用 boundingRect

        // 5) epsilon 按周长百分比,尺度无关
        const double eps = r.perimeter_px * epsilon_percent;
        cv::approxPolyDP(cnt, r.polygon, eps, true);

        // 极小碎片(面积下限)通常是噪声,不是零件。
        // 阈值同样来自标定,不是魔法数字。
        if (r.area_px >= kMinPartAreaPx) {
            out.push_back(std::move(r));
        }
    }
    return out;
}

几个设计要点,都是前面实测结论的直接应用:

  • gray8u(roi).clone() 而不是直接传 view:实测 5.0.0 不改写,但这是版本事实不是契约(见第六节)。
  • 显式挡 type() != CV_8UC1 :实测四种位深全抛,且 RETR_FLOODFILL 连 8U 都抛。让错误在这行暴露比在 200 行后抛 Bad argument 好。
  • max(base * 2.0, 1.0) 兜底:中位数那档实测基准梯度低到 0.68,不兜底会算出接近 0 的阈值,行为不可预测。
  • 用 minAreaRect 而不是 boundingRect:实测旋转 30° 时后者面积偏大 2.08 倍。
  • 碎片按面积下限过滤 :Canny 在高梯度区域会产生短弧段(第 6 节实测 apertureSize=5 时边缘量涨 8 倍),它们会变成极小轮廓。

避坑指南

坑 1:Canny 传 edges=image 原地改写

现象 :想省一次拷贝,结果原图变成只剩边缘的图。原因 :实测 5.0.0 接受 edges 指向输入图,并且确实改写 它(shares_memory=True)。修正 :不要把 edges 传回输入;需要复用原图时先 clone()。

坑 2:apertureSize 从 3 改到 5,边缘暴涨 8 倍

现象 :只改了孔径没调阈值,边缘突然密一片。原因 :实测 3967 → 32,176 px(8.11× ),3→7 是 14.50×;源码里 /16 归一化只对孔径 7 生效、对 5 不生效 ,所以补偿有缺口。修正:孔径和阈值绑成一个配置对,一起标定。

坑 3:Canny 的"σ"根本不存在------而且它内部也不做平滑

现象 :想调平滑度找不到参数,或者在 C++ 里多传一个 double。原因 :实测 sigma/ksize/connectivity/blocksize 四个关键字全部 rejected;查源码(modules/imgproc/src/canny.cpp)确认整个 Canny 路径里没有任何 GaussianBlur 调用 ,梯度直接由 spatialGradient(即 Sobel)算出。"Canny 内部用了 5×5 高斯、sigma=1.2"是 OpenCV 1.x 时代 C 实现的老说法 ,现代实现早就不做了------流传的"内部高斯不可配置"其实是比事实更弱的一种错 :它暗示存在一个你调不了的内部平滑。修正 :要平滑就在 Canny 之前 显式 GaussianBlur,这正是官方 Canny 教程自己的写法(先 blur(...) 再 Canny(...))。

坑 4:σ=5 的 GaussianBlur 让边缘全灭

现象 :调试时输出"全零",像代码没跑。原因 :实测 (15,15) σ=5.0 时总边缘 0 、细杆带 0------3 px 宽结构被稀释到阈值以下。修正 :σ 的上限由最细结构决定;出现全零先查 countNonZero,再查阈值。

坑 5:阈值"取中位数"是最差的自适配实现

现象 :以为自适配就稳了,换个光源还是爆。原因 :实测梯度 median=2.000 而 p99=489,中位数取的是平坦背景,实测 edgeCV 39.26%,比最好的固定阈值还差。修正 :用 p95(实测 2.58%)。

坑 6:p98/p99 看起来"更稳",实际全灭

现象 :换成高百分位,边缘全没了。原因 :实测基准梯度 147 / 232,而干净图 p99 才 489;15 个光照条件下边缘数全为 0 。修正 :百分位必须扫参,p97 和 p98 可能差一个数量级。

坑 7:nth_element 百分位不是 np.percentile

现象 :C++ 和 Python 结果对不上,差 ~1e-5。原因 :实测 nth_element 取顺序统计量,numpy 做线性插值;p98 处最大绝对差 0.019856。修正 :要复现实验就用插值版;用 nth_element 就在注释里写明并接受偏移。

坑 8:RETR_EXTERNAL 静默丢掉所有内轮廓

现象 :带孔零件只框出最外一层,不报错 。原因 :实测三层嵌套场景下 EXTERNAL 返回 1 个轮廓,TREE 返回 3 个、深度 2。修正 :需要孔和嵌套就用 TREE/CCOMP。

坑 9:以为 mode 选错会有性能代价

现象 :为了"省时间"把 TREE 换成 EXTERNAL,结果丢了轮廓。原因 :实测 8 种 mode×chain 组合耗时 0.092--0.116 ms,差异在噪声内 。修正:按语义选 mode,不按想象中的性能。

坑 10:RETR_FLOODFILL 连 CV_8U 都抛

现象 :按异常文本以为它能吃别的位深。原因 :实测 RETR_FLOODFILL 在整个矩阵里全是 X ,包括 8U;而且异常文本声称的 RETR_CCOMP/RETR_FLOODFILL 位深例外在 5.0.0 不成立 。修正:不依赖异常文本推断行为,实测优先。

坑 11:依赖"findContours 不改写输入"

现象 :代码在 5.0 正常,降到老版本就发现原图被削掉形状。原因 :官方文档的时间线是「3.2 之前就地改写源图 → 3.2 起返回改写后的副本(三返回值)→ 4.x 去掉该返回值(二返回值)」;分界点是 3.2,不是 4.x ,实测 5.0 不改写。修正 :显式 clone(),把安全性放在自己的代码里。

坑 12:isContourConvex 对栅格轮廓返回 False

现象 :理想矩形被判为非凸。原因 :实测旋转矩形的 isContourConvex = False,阶梯化转角造成微凹陷。修正 :先 approxPolyDP 简化再判,或用 minAreaRect。

坑 13:boundingRect 面积偏大 2 倍以上

现象 :按包围盒算尺寸,旋转物体算出来大一倍。原因 :实测 60032 vs 真值 28800(2.08× )。修正 :旋转物体用 minAreaRect(实测偏差 0.19%)。

坑 14:arcLength 比真值偏大约 6%

现象 :周长算出来比设计图纸大。原因 :实测 769.1 vs 720(+6.82%),阶梯化的斜边走曼哈顿路径。修正 :周长只能当相对量;绝对尺寸靠标定的 mm/pixel 换算。

坑 15:approxPolyDP 固定绝对值 epsilon

现象 :目标尺寸一变,顶点数完全失控。原因 :实测 eps=0.5 在圆上给 124 点、在矩形上给 4 点。修正:用周长百分比(实测 0.5% → 16 点,可预测)。

坑 16:把"逼近出 4 个点"当成参数调好了

现象 :矩形永远 4 点,误以为 epsilon 无所谓。原因 :实测矩形在所有 epsilon 下都是 4 点,而圆在 5% 时给出的是一个正方形(4 点但形状全错)。修正:验证要看逼近后的面积/角度误差,不是顶点数。


优秀实践 / 可改进之处

优秀实践:阈值、孔径、σ、百分位绑成一个标定配置

本篇四个可调量------GaussianBlur 的 σ、apertureSize、双阈值(或百分位 + k + ratio)、approxPolyDP 的百分比------是互相耦合 的:改孔径必须重调阈值,改百分位要重调 k。它们应该作为一个整体进配置文件,由离线标定工具在真实样本上扫出来,不是散落在代码里的四个魔法数字。第 6 篇的几何参数同理。

优秀实践:用 p95 + 周长百分比 + TREE 作为默认起点

实测支撑的一套默认值:GaussianBlur(5,5,1.4) + apertureSize=3 + p95 × 2.0 / 0.4 + RETR_TREE + CHAIN_APPROX_SIMPLE + approxPolyDP(周长 × 2%)。在 15 个光照条件下 edgeCV 为 2.58%,比最好的固定阈值好 8.35 倍。但记住这些数字来自本篇这张合成图,落到你的样本上必须重扫。

优秀实践:把"输入契约"写成前置断言

findContours 前显式检查 type() == CV_8UC1、Canny 前显式确认 σ 上限、nth_element 注释里写明统计量类型。这些断言把版本相关的行为差异 挡在你自己的代码里,而不是让它们在 200 行后的 Bad argument 里现形。第十节的 extractParts 就是这个模式。

优秀实践:ROI 提取用 clone,边界留 2 像素余量

gray8u(roi).clone() 显式拷贝,跨版本安全;ROI 四周留 2 px 余量,避免在图像边界上产生假的强边缘------Canny 在图像边缘的填充行为会让边界像素产生非物理梯度,污染阈值统计。

可改进之处:百分位每帧全图排序

实测本篇的插值百分位版需要 std::sort 整张梯度图(O(N log N)),480×640 每帧都要做。改进方向 :nth_element 版(实测低百分位与插值版完全一致,O(N));或者只在下采样图 上估百分位再放大到全图;或者用上一帧的基准 + 少量修正(工业流水线的光照变化是缓变的,逐帧重估反而引入抖动)。

可改进之处:阈值统计里没有排除 ROI 外区域

第十节的流水线用整张 roi 的梯度算百分位,如果 ROI 里前景占比很小,分位数会被大量平坦背景拉低(实测平坦区占 47%)。改进方向 :先做一次粗分割,只在前景区域统计梯度百分位,或者直接用 med_top10(实测 5.51%,比 median_all 稳 7 倍,且不需要先分割)。


本期互动

  • 你的项目里 Canny 阈值是写死的常数,还是从样本统计出来的?如果是常数,你的样本光照波动范围是多少?
  • 你用过 RETR_CCOMP 或 RETR_TREE 处理带孔零件吗?踩过"内轮廓被丢掉"吗?
  • 你在 C++ 里手写过百分位吗?用 nth_element 还是 sort?

💬 评论区聊聊

说说"取梯度中位数当自适应阈值"这个做法。

这是流传很广的思路,本篇实测它是本篇最差 的一种:干净图的梯度中位数是 2.000,而 p99 是 489.001,p95 才是 7.48。中位数落在平坦背景上,算出的基准梯度低到 0.68,阈值几乎一直贴在兜底值上------实测 edgeCV 39.26%,比最好的一组固定阈值(21.55%)还差一倍。

我的猜测是这个思路来自"中位数抗离群值"这个统计直觉,但梯度幅值分布是极度长尾 的:前 95% 的像素挤在 0--8 这个窄区间里,剩下 5% 拉到几百。在长尾分布上,中位数不是"稳健的中心",它只是"平坦区的代表值"。 真正代表边缘强度的是 p90 以上的分位。

有意思的是,med_top10(先取最强 10% 再求中位数,实测 5.51%)和 p95(实测 2.58%)都落在边缘区且都稳定,而 p98/p99 又过头全灭------有效区间窄到只有几个百分位宽。

想听听大家:你们的自适应阈值是怎么定的?如果是"中位数 × 系数",有没有测过它在多光照条件下的边缘数量波动?以及------你们会把百分位当标定参数扫一遍,还是就固定一个值?


👉 收藏 · 转发 · 系列目录

本篇把边缘检测与轮廓提取的真实参数边界、阈值自适配、层级语义、多边形逼近 都做了实测,其中有几个结论和常见说法相反:Canny 根本没有 sigma 参数、RETR_EXTERNAL 静默丢掉内轮廓、findContours 在 5.0 并不改写你的输入图、isContourConvex 对栅格矩形返回 False、nth_element 百分位和 np.percentile 不是一回事。

如果你在做缺陷轮廓提取、零件定位或视觉测量,这篇的两张表值得存下来:对比度阶梯表 解释"换光源为什么全变噪点",edgeCV 对比表给出可直接用的自适应阈值依据。

本系列接下来会覆盖几何测量 :moments、minAreaRect、fitEllipse 怎么把本篇的像素级误差(arcLength 偏大 6.82%、boundingRect 偏大 2.08 倍)压到毫米级,以及亚像素定位到底能抠出多少精度。

相关推荐
不爱编程的小陈1 小时前
AI Agent的一些名词
人工智能
QYR_111 小时前
硝基乙烷市场规模持续扩张:2032年全球销售额预计达1.96亿美元,行业前景稳步向好
大数据·人工智能
知几蜗牛1 小时前
Java HttpClient 调用 Gemini 图像理解与金额字段校验
人工智能
HUIBUR科技1 小时前
多系统整合不必搭建中台:AI中枢带来轻量化数字化集成
大数据·运维·人工智能
知几蜗牛1 小时前
GPT-6 Intelligent UI:从文本响应到可验证交互的产品迁移
人工智能
天空鸟_时光不老1 小时前
09-RAG问答系统落地:从默认分割器的坑到Milvus召回调优
java·人工智能·spring boot·spring·spring cloud·maven·mybatis
知几蜗牛1 小时前
Java Agent 工具调用的 allowlist、参数校验与调用预算
人工智能
GlobalInfo1 小时前
2026年AIoT芯片及平台市场报告正式发布:市场规模、十五五趋势与产业链全景一键获取
大数据·人工智能·ai·半导体