richard_yuu头像
关注
OpenCV 实战第 5 篇:threshold 全家族 + Otsu + 自适应阈值,参数怎么定封面图

OpenCV 实战第 5 篇:threshold 全家族 + Otsu + 自适应阈值,参数怎么定

在这里插入图片描述

二值化只有一行代码,但它是整个视觉流水线里最容易"换台设备就废"的一环。这篇把阈值类型全家族、Otsu、自适应阈值、连通域一次讲透——包括三个我实测出来、和文档对不上的地方。


上篇回顾

上一篇我们把划痕抓出来了:用 MORPH_BLACKHAT 做减法,把"比邻域暗"的部分提取成灰度图,最后用自适应阈值二值化,再用 connectedComponentsWithStats 筛出细长的连通域。

那篇里我留了一个"这里先不展开"的地方——自适应阈值的 blockSize 和 C 到底怎么定。当时只是给了个 blockSize=31, C=-4 的经验值,没有解释依据。

这一篇把它连同前面欠下的账一起算清:固定阈值的致命缺陷、Otsu 的双峰假设和返回值陷阱、adaptiveThreshold 的调参逻辑、连通域的输出结构,以及一个我踩过的位深量程的坑。


一、先分清你要二值图,还是要压缩动态范围

cv::threshold 的第 4 个参数是阈值类型标志。很多人只知道 THRESH_BINARY,但另外几个做出来的不是二值图——用错会得到"看起来能跑、结果全错"的代码。

(cv2 里一共有 8 个 THRESH_ 常量,但其中 THRESH_MASK 是个不可用的遗留值,本文后面会专门拆。真正能用的基础 flag 是 5 个,另外两个 OTSU / TRIANGLE 是要按位或上去的修饰位。)

官方签名:

double cv::threshold(InputArray src, OutputArray dst, double thresh,
                      double maxval, int type);

thresh 是阈值,maxval 是「满足条件时写进去的那个值」。先看这 6 个 flag 对同一组输入分别做什么。我用 src = [0, 50, 99, 100, 101, 150, 200, 255]、thresh = 100 实测:

// 下面每一行都是真实运行结果,不是手推的
cv::Mat src = (cv::Mat_<uchar>(1, 8) << 0, 50, 99, 100, 101, 150, 200, 255);

cv::threshold(src, dst, 100, 255, cv::THRESH_BINARY);      // [0, 0, 0, 0, 255, 255, 255, 255]
cv::threshold(src, dst, 100, 255, cv::THRESH_BINARY_INV);  // [255, 255, 255, 255, 0, 0, 0, 0]
cv::threshold(src, dst, 100, 255, cv::THRESH_TRUNC);       // [0, 50, 99, 100, 100, 100, 100, 100]
cv::threshold(src, dst, 100, 255, cv::THRESH_TOZERO);      // [0, 0, 0, 0, 101, 150, 200, 255]
cv::threshold(src, dst, 100, 255, cv::THRESH_TOZERO_INV);  // [0, 50, 99, 100, 0, 0, 0, 0]

注意一个细节:比较是 src > thresh(严格大于),所以 100 这个像素在 THRESH_BINARY 下算背景。这个 > 和 >= 的差一位,在做「阈值以下算合格」的业务上会让你整批数据反着来。

把四个真二值化的行为对齐成表:

flagsrc > thresh 时否则输出是二值图吗
THRESH_BINARYmaxval0✅ 是
THRESH_BINARY_INV0maxval✅ 是
THRESH_TRUNC截断到 thresh原值❌ 否,保留原动态范围
THRESH_TOZERO原值0❌ 否,只清零低端
THRESH_TOZERO_INV0原值❌ 否,只清零高端
THRESH_MASK—— 遗留常量,实际不可用——静默输出全零图

maxval 可以是任意值——OpenCV 根本不校验它。 实测(8U 输出、thresh=100):

maxval输出
0[0,0,0,0,0,0,0,0] ← 全零,不报错
-5[0,0,0,0,0,0,0,0]
1[0,0,0,0,1,1,1,1]
255[0,0,0,0,255,255,255,255]
256 / 300 / 65535[0,0,0,0,255,255,255,255] ← 饱和到 255

也就是说:超量程的值被静默截断到目标位深的上限,maxval <= 0 静默产出一张全零图。两种情况都不抛异常。 所以 maxval=1 输出掩膜是合法的(connectedComponents 只认非零),但如果你把 maxval 配成 0,你会拿到一张全黑图,而且没有任何东西会告诉你。

「上限」是跟着输出位深走的,不是固定 255。同一组 maxval 换成不同位深,前景像素写进去的值完全不同:

maxval8U 输出16U 输出32F 输出
255255255255.0
300002553000030000.0
7000025565535 ← 撞 16U 上限70000.0 ← 不截断
655352556553565535.0
0全零全零全零

三个容易踩的点:① 8U 下你写多大都会被压回 255,maxval=70000 和 maxval=255 效果完全一样,参数毫无作用;② 16U 下真的会按你写的值写进去,但超过 65535 就饱和;③ 32F 浮点输出根本不截断,maxval=70000 就真的写出 70000.0。所以「256 位深度下 maxval 会被截断」这种话只对整数位深成立,浮点输出是例外。

⚠️ 这里有个直接的工程后果:如果你要做灰度值量化(比如把 16 位拍片压成 8 位),别指望 threshold 帮你缩放——它只填 maxval,不做除法。缩放要用 convertTo(src, dst, alpha, beta) 或 normalize。

THRESH_MASK:一个会静默产出全零图的遗留常量

这一段是我写这篇文章时被打脸最狠的地方。我原先以为 THRESH_MASK 是"用一张 mask 当前景值模板"的正常功能,还专门纠正过别人说错的话。结果我自己全错了。

先看 cv::threshold 真实的签名——C++ 和 Python 都没有 mask 参数:

// C++:5 个参数,没有 mask
double cv::threshold(InputArray src, OutputArray dst, double thresh,
                      double maxval, int type);
# Python:5 个参数,dst 是可选的第 5 个
retval, dst = cv2.threshold(src, thresh, maxval, type[, dst])
#                ^^^^^^^^ 返回值,不是 mask

那 THRESH_MASK 是什么?它是 cv2 里的一个遗留常量。实测 cv2 暴露的 ThresholdTypes 取值是:

常量数值
THRESH_BINARY0
THRESH_BINARY_INV1
THRESH_TRUNC2
THRESH_TOZERO3
THRESH_TOZERO_INV4
THRESH_OTSU8
THRESH_TRIANGLE16
THRESH_MASK7 ← 不在文档枚举里

注意 5、6 是空的,而 THRESH_MASK = 7 正好落在两个合法位标志的中间——它根本不是一个 type 标志位,而是被按位或进去就会污染整个 type 的东西。实测它的行为:

src = np.array([[0, 50, 99, 100, 101, 150, 200, 255]], np.uint8)

# ❌ 我原来的写法:以为第 5 个位置是 mask
retval, dst = cv2.threshold(src, 100, 255, cv2.THRESH_MASK, my_mask)
# retval = 100.0        ← 回显了 thresh
# dst    = [[0]*8]      ← 全零!不抛异常、不告警

这一行不报错、不告警,只是安静地给你一张全黑图。 你要是在产线上用它做分割,结果就是"一个缺陷都没检出"——而日志里干干净净。

再确认一下它确实不可用:

# ❌ 传 6 个位置参数想凑出 mask → 直接抛异常
cv2.threshold(src, 100, 255, cv2.THRESH_MASK, None, my_mask)
# error: (-5:Bad argument) in function 'threshold'

# ❌ 按关键字传 mask → 同样抛异常(Python 绑定没有这个参数)
cv2.threshold(src, 100, 255, cv2.THRESH_MASK, mask=my_mask)
# error: (-5:Bad argument) in function 'threshold'

顺便一提,第 5 个位置确实是 dst,不是 mask——传进去会被原地改写:

buf = np.zeros(src.shape, np.uint8)
retval, dst = cv2.threshold(src, 100, 255, cv2.THRESH_BINARY, buf)
# dst is buf  → True

结论:不要用 THRESH_MASK。 如果你想做的确实是"二值图前景取另一张图的值",那用 bitwise_and 两步做:

// ✅ 想让前景取 mask 里的值,自己拼
cv::Mat binary;
cv::threshold(src, binary, 100, 255, cv::THRESH_BINARY);
cv::bitwise_and(mask, binary, dst);   // 前景=mask 值,背景=0

我为这个"纠正别人"的段落付出了写错一整节的代价。教训是:遇到一个"看起来存在但我没查过文档"的 API,第一件事是去 help(cv2.threshold) 或官方文档确认它真的在签名里,而不是根据名字猜语义。


二、THRESH_OTSU:自动阈值的工作方式与返回值陷阱

Otsu 的思路来自类间方差最大化:把直方图按某个灰度切成前景和背景两类,让两类的类间距离尽可能大,等价于让两类的类内方差尽可能小。

// Otsu 的 flag 值是 8,必须和一个基础 flag 按位或
double t = cv::threshold(gray, mask, 0, 255,
                         cv::THRESH_BINARY | cv::THRESH_OTSU);
// t 就是 Otsu 实际用的阈值,可以直接读出来做后续判断

这里有三个坑,前两个我踩过。

坑 1:返回值必须接住

cv::threshold 返回值类型是 double——因为 Otsu 模式下它要"还"给你算出来的阈值。如果你写成 cv::threshold(...) 不用返回值,Otsu 照样算、照样输出掩膜,但你永远不知道它选了哪个阈值,也就没法做「阈值是否合理」的自检。

// ❌ 阈值被丢弃,Otsu 选了什么都不清楚,出问题无法定位
cv::threshold(gray, mask, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);

// ✅ 接住返回值,至少能打印出来看一眼
const double t = cv::threshold(gray, mask, 0, 255,
                               cv::THRESH_BINARY | cv::THRESH_OTSU);
if (t <= 1.0 || t >= 254.0) {
    // 阈值贴着量程端点,通常意味着这张图根本不适合 Otsu
}

那个端点检查不是我编的。实测:

输入Otsu 返回前景像素
16×16 纯色 128t = 0256 / 256(全选)
16×16 0…255 均匀分布t = 127128 / 256(硬劈一半)

纯色图只有一个峰,没有任何可分性,Otsu 退化成"把阈值压到 0",于是每一个像素都大于 0,整张图全成前景。均匀分布图则被硬生生从正中间劈开。它永远会给你一个数,但不代表这个数有物理意义。

坑 2:Otsu 假设直方图是双峰的,而工业图往往不是

Otsu 的前提是"前景和背景各自聚成一个峰"。这个前提在下面这些图上不成立:

  • 背景是渐变的(工件边缘光照衰减)——直方图是一个宽平台,不分峰
  • 前景占比很小(划痕只占 0.1% 像素)——前景那个峰矮到几乎看不见
  • 前景本身是多种灰度(深浅划痕同时存在)——前景不是单峰

这些情况下 Otsu 要么全切、要么全空。我在板材上试过:直方图上"划痕"那一坨和"轧制纹理 + 光照梯度"糊在一起,Otsu 切出来的掩膜一半是纹理。Otsu 适合同一光照下的自然图像分割,不适合光照不均的工业检测。

坑 3:自动阈值的位深支持,比文档说的更乱

官方文档里有一句话:“Otsu’s and Triangle methods are implemented only for 8-bit single-channel images.”(Otsu 和 Triangle 仅支持 8 位单通道。)

这句话实测下来只有一半对。 我把固定阈值、Otsu、Triangle 三列全跑了一遍:

src 位深固定 THRESH_BINARYTHRESH_OTSUTHRESH_TRIANGLE
CV_8UC1✅✅✅
CV_16UC1✅✅ 可用❌ 抛异常
CV_32FC1(0…1)✅❌ 抛异常❌ 抛异常
CV_32FC1(0…255)✅❌ 抛异常❌ 抛异常
CV_64FC1✅❌ 抛异常❌ 抛异常
CV_8SC1❌ 抛异常❌ 抛异常❌ 抛异常
CV_32SC1❌ 抛异常❌ 抛异常❌ 抛异常

三个和文档不一致的地方,都值得记住:

  1. Otsu 在 16U 上是可用的(实测一张 arange(16)*4000 的 16 位图,Otsu 返回 t = 28000)。文档那句"仅 8 位"对 Otsu 是错的。
  2. Triangle 在 16U 上会抛异常,异常信息是 src.type() == CV_8UC1——这一半文档说对了。
  3. 固定阈值比两个自动方法都宽松:32F、64F 都能跑,只有有符号整型和 32 位整型不行。

第 3 条最容易踩:很多流程会把图转成 32F 归一化后再处理。转完这一步,固定阈值还好好的,Otsu 突然就抛异常了——你会以为是代码改错了,其实是位深不支持。

// ❌ 图已经在 CV_32F:固定阈值 OK,Otsu 直接抛异常
cv::Mat f32;
img.convertTo(f32, CV_32F);
cv::threshold(f32, mask, 100.0, 1.0, cv::THRESH_BINARY);              // 可以
cv::threshold(f32, mask, 0, 1.0, cv::THRESH_BINARY | cv::THRESH_OTSU); // 抛异常

// ✅ 要用 Otsu 就回到 8U
cv::Mat u8;
img.convertTo(u8, CV_8U);
const double t = cv::threshold(u8, mask, 0, 255,
                               cv::THRESH_BINARY | cv::THRESH_OTSU);

实践含义:如果你的流水线里有 32F 归一化这一步,那么 THRESH_OTSU 要放在归一化之前,或者在归一化前先 convertTo(CV_8U) 留一份。


三、thresh 的值必须和图像位深同量程——一个不报错的静默 bug

这条是我在 16 位图上踩过的,它不抛任何异常,图也"能看",只是结果全错。

thresh 是个 double,OpenCV 不会检查它和 src 的值域是否匹配。对一张 16 位图(值域 0–65535)传 thresh = 255:

cv::Mat img16 = /* 16 位图,值域 0-65535 */;
cv::threshold(img16, mask, 255, 65535, cv::THRESH_BINARY);

不会抛异常。 实测一张 16 位测试图(arange(16) * 4000,即 0, 4000, 8000, …, 60000,共 16 个像素):

thresh前景像素数掩膜
25515 / 16[0, 65535×15]
30000(真正想要的)8 / 16[0×8, 65535×8]

thresh=255 把 94% 的像素判成前景——看起来"分割结果几乎全白"。而真正该用的 30000 会得到一半。如果阈值再大一点,就会变成"几乎全黑"。两种都不报错,你只会觉得算法不对,不会怀疑量程。

正确的做法是让 thresh 跟着位深走:

// ✅ 16 位图的阈值就在 0-65535 里取
const double t = cv::threshold(img16, mask, 30000, 65535, cv::THRESH_BINARY);

// ✅ 或者用 Otsu——它返回的阈值天然落在图像自己的值域里
//    实测:同一张 16 位图,Otsu 返回 t=28000,量程自动匹配
const double t2 = cv::threshold(img16, mask, 0, 65535,
                                cv::THRESH_BINARY | cv::THRESH_OTSU);

maxval 不参与判定,只决定写入前景的值。 实测同一张 16 位图:maxval=255 和 maxval=65535 的前景像素数完全一样(都是 8/16),只是写进去的数值不同。所以 maxval 配错不会改变哪些像素被判为前景——但它配成 0 会让前景和背景都变成 0,整张图作废。

Otsu 的附带好处:它返回的阈值一定落在图像自己的值域里,所以 16 位图上不会出现"阈值和量程不匹配"这个静默 bug。这就是我建议 16 位图优先 Otsu 的原因之一。

配套的规范:配置里的阈值要和位深绑定。 不要让 YAML 里躺一个光秃秃的 thresh: 200,而是写清楚它对应哪个位深:

# thresholds.yaml —— 阈值必须标注适用位深
plate_scratch:
  bit_depth: 8            # thresh 的量程由它决定
  thresh: 180             # 仅当 bit_depth=8 时有意义

四、adaptiveThreshold:blockSize 与 C 的调参逻辑

固定阈值的致命缺陷是一张图只有一个数,而工业现场的成像亮度不均。左边亮右边暗,一个 thresh 必然一边过、一边欠。

自适应阈值的思路是:给每个像素单独算一个阈值,用它的邻域统计量当基准。

void cv::adaptiveThreshold(InputArray src, OutputArray dst, double maxValue,
                           int adaptiveMethod, int thresholdType,
                           int blockSize, double C);

核心公式(thresholdType 为 THRESH_BINARY):

dst(x,y) = ( src(x,y) > T(x,y) ) ? maxValue : 0

// MEAN_C:T(x,y) = 邻域均值 - C
// GAUSSIAN_C:T(x,y) = 邻域高斯加权和 - C

一句话记住 C:它是「减掉的那个偏置」。 C 越大,阈值越低,判为前景的像素越多。

blockSize 的两个硬约束

① 必须是大于 1 的奇数,且不能小于 3。 实测:

blockSize结果
1❌ 抛异常
2❌ 抛异常
3✅
5✅
31✅

② 可以大于图像尺寸。 这条和流传的说法相反——我以前以为只有 GAUSS_C 能容忍超出,MEAN_C 会报错。实测拿 5×5 的图配 blockSize = 31:MEAN_C 和 GAUSS_C 都正常工作。原因很简单,超出图像的部分按 borderType 扩展,取的仍是有效像素。

调参逻辑(这是这一节最值钱的部分):

  • blockSize 要大于你要提取的目标尺寸,否则目标自己被当成"局部均值"给消掉了。目标宽度 3 像素,blockSize 至少 7。
  • 但 blockSize 不能远大于目标,否则局部均值被大片背景稀释,C 需要给得越来越大才能压出目标,最后整幅图都变前景。
  • 经验起点:**blockSize ≈ 目标最大跨度的 2 倍,且是奇数**。划痕宽度上限 15 → blockSize` 取 31。
  • C 从 0 开始往下调。C=0 前景太少就增大(更宽松),前景太多就减小(更严格)。常见落在 2–10。

C 的作用方向我实测过。拿一张 64×64 的水平斜坡图(0→63 渐变),blockSize=15、MEAN_C:

C前景像素数(共 4096)
204096(全前景)
104096(全前景)
0256
-100

为什么 C=0 在这张图上几乎什么都不剩? 因为斜坡图上每个像素的值恰好就等于它自己邻域的均值,于是公式退化成 src > src - 0,即 0 > 0,恒为假。这解释了一件我以前没想通的事——在完全均匀的区域里,自适应阈值的判据本来就是"跟自己比",全靠 C 打破平衡。C 为 0 时只有噪声抖动能凑出前景;C 给正了,等于强行要求"比邻域均值高 C 以上",均匀区域就整体通过了。

这也直接推出调参口诀:C 是唯一决定"均匀区域算不算前景"的参数,blockSize 决定"多均匀才算局部"。

MEAN_C 还是 GAUSS_C

MEAN_C 用方框均值,快,但邻域是硬边界,同一块区域里靠近边和靠近中心的像素拿到的均值差异生硬。GAUSS_C 用高斯加权(官方注明用 getGaussianKernel 的默认 sigma),慢一些但过渡自然。

我的选择:先 MEAN_C 跑通,噪声点多到需要形态学清场时再换 GAUSS_C。因为如果 MEAN_C 的结果已经能用,换 GAUSS_C 带来的画质提升抵不过多花的 CPU。

一个必须知道的限制

adaptiveThreshold 的 thresholdType 只接受 THRESH_BINARY 和 THRESH_BINARY_INV。实测把 8 个阈值类型全喂一遍:

thresholdType结果
THRESH_BINARY✅
THRESH_BINARY_INV✅
THRESH_TRUNC❌ 抛异常
THRESH_TOZERO❌ 抛异常
THRESH_TOZERO_INV❌ 抛异常
THRESH_MASK❌ 抛异常
THRESH_OTSU❌ 抛异常
THRESH_TRIANGLE❌ 抛异常

原因很直白:自适应阈值没有"截断""清零"这些中间态,它天生就是一个二值化——每个像素要么输出 maxValue 要么输出 0。

src 的位深限制也比 threshold 严:只接受 8 位单通道。实测 16U、32F、8S 全部抛异常。这是个容易忽略的组合坑——如果你的流水线为了动态范围把图转成了 16U,adaptiveThreshold 会直接罢工,得先转回 8U。

// ❌ 16 位图:adaptiveThreshold 不支持
cv::Mat img16;  // CV_16U
cv::adaptiveThreshold(img16, mask, 65535, cv::ADAPTIVE_THRESH_MEAN_C,
                      cv::THRESH_BINARY, 31, -4);

// ✅ 先回 8U
cv::Mat u8;
img16.convertTo(u8, CV_8U);
cv::adaptiveThreshold(u8, mask, 255, cv::ADAPTIVE_THRESH_MEAN_C,
                      cv::THRESH_BINARY, 31, -4);

它可以原地处理(官方文档说 The function can process the image in-place),这点实测确认成立——但前提是你必须把 src 再传一遍给末尾的 dst,否则拿到的还是新分配的一块内存:

img = np.zeros((16, 16), np.uint8); img[4:12, 4:12] = 200

out = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                           cv2.THRESH_BINARY, 5, 5)
# 不传 dst:img 原封不动,out 是新数组

out = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                           cv2.THRESH_BINARY, 5, 5, dst=img)   # ← 显式指定
# 实测 shares_memory(out, img) = True,此时 img 已被改写

⚠️ 别把「支持原地」理解成「省略 dst 就自动原地」:省略时 src 完全不被触碰,你以为省了内存,其实多分配了一整张图。cv::threshold 同理,必须写成 cv::threshold(src, src, thresh, maxval, type) 才真正原地。


五、Python 的参数顺序:为什么你的代码在 4.x 和 5.x 表现不同

C++ 和 Python 在这一组的差异比想象中大,而且是最容易让人踩的一类——因为 C++ 代码改写成 Python 时,参数顺序会静默错位。

C++ 是 dst 在第二位;Python 的绑定形式是 dst 在最后且可选:

// C++:dst 是第 2 个参数
cv::adaptiveThreshold(src, dst, 255, cv::ADAPTIVE_THRESH_MEAN_C,
                      cv::THRESH_BINARY, 31, -4);
# Python:maxValue 是第 2 个参数,dst 省略(返回值就是 dst)
#         这个形式从 OpenCV 2.4 起就是这样,不是新版本才变的
dst = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                            cv2.THRESH_BINARY, 31, -4)

# 也可以显式传 dst,但它在最后
dst = np.zeros_like(gray)
cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                      cv2.THRESH_BINARY, 31, -4, dst)

cv2.threshold 是另一种约定——thresh 在第 2 位,dst 也在最后,且返回二元组:

retval, dst = cv2.threshold(gray, 100, 255, cv2.THRESH_BINARY)
#          ^^^^^^^^ Otsu 模式下的阈值;固定阈值模式下也返回它,但没人用

我把 cv2.adaptiveThreshold 当成 cv2.threshold 那样把 dst 写在第二位,结果报了一个很有迷惑性的错误:

error: (-5:Bad argument) in function 'adaptiveThreshold'
> Argument 'maxValue' can not be treated as a double

读起来像是"类型传错了",实际上是我把 ndarray 传给了 maxValue 这个位置——位置错位伪装成了类型错误。遇到"明明传的是 int 却说不能转 double",先检查参数顺序,别去怀疑类型。

connectedComponents 家族的三份 Python 签名也各不相同,写对照代码时别想当然:

# connectedComponents:可选参数是 connectivity、ltype
retval, labels = cv2.connectedComponents(mask, connectivity=8, ltype=cv2.CV_32S)

# connectedComponentsWithStats:返回四元组,顺序是 n, labels, stats, centroids
retval, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)

# connectedComponentsWithAlgorithm:connectivity / ltype / ccltype 都是必填位置参数
retval, labels = cv2.connectedComponentsWithAlgorithm(mask, 8, cv2.CV_32S, cv2.CCL_SAUF)

六、连通域三兄弟与 stats 输出结构

cv::connectedComponents 家族有三个函数,区别只在"要不要多给你点信息":

函数返回连通域数labelsstatscentroids
connectedComponents✅✅❌❌
connectedComponentsWithStats✅✅✅✅
connectedComponentsWithAlgorithm✅✅❌❌

第三个的特别之处是让你选连通域算法的实现,不返回统计量:

// 四个算法枚举:CCL_DEFAULT / CCL_BBDT / CCL_SPAGHETTI / CCL_SAUF
int cv::connectedComponentsWithAlgorithm(InputArray image, OutputArray labels,
                                         int connectivity = 8,
                                         int ltype = CV_32S,
                                         int ccltype = CCL_DEFAULT);

实测这四个枚举都能正常工作,输出连通域数一致——所以默认用 CCL_DEFAULT 就行,只有实测发现默认算法在特定图上慢到不可接受时才值得换。这个函数我至今没在项目里用过非默认值。

stats 的 5 列是什么

int cv::connectedComponentsWithStats(InputArray image, OutputArray labels,
                                     OutputArray stats, OutputArray centroids,
                                     int connectivity = 8, int ltype = CV_32S);

stats 是 n × 5 的 int 矩阵,每一行是 [left, top, width, height, area]。实测输出(6×6 图上放两个 2×2 的方块):

stats.shape = (3, 5)   centroids.shape = (3, 2)   dtype = int32
  label 0: left=0  top=0  w=6  h=6  area=28  centroid=(2.5, 2.5)
  label 1: left=0  top=0  w=2  h=2  area=4   centroid=(0.5, 0.5)
  label 2: left=4  top=4  w=2  h=2  area=4   centroid=(4.5, 4.5)

最重要的一条:label 0 是背景,它也在 stats 里

上面 label 0 那一行 w=6 h=6 area=28——它不是目标,是那张图里的背景。实测一张 10×10 的图上放 9 个前景像素:

n = 2
  label 0: area=91  w=10  h=10   ← 背景,91 = 100 - 9
  label 1: area=9   w=3   h=3     ← 真正的目标

背景那一行的 area 等于全图零像素数,w/h 等于整图尺寸。 所以遍历时必须从 1 开始:

// ❌ 把背景当成了一个巨大的目标:面积永远最大,会挤掉所有真实目标
for (int i = 0; i < n; ++i) {
    if (stats.at<cv::Vec<int, 5>>(i)[CC_STAT_AREA] < minArea) continue;
    // ... 背景的 area = 全图像素数,几乎不可能被 minArea 过滤掉
}

// ✅ 从 1 开始,0 号是背景
for (int i = 1; i < n; ++i) {
    const cv::Vec<int, 5>& s = stats.at<cv::Vec<int, 5>>(i);
    if (s[CC_STAT_AREA] < minArea) continue;
}

centroids 是 n × 2 的浮点矩阵,第 0 行同样是背景的质心(也就是全图前景像素的平均位置)。

还有一个 Python 特有的坑:labels 是个 numpy 数组,越界索引抛的是 IndexError,不是 OpenCV 异常。我写过滤循环时用 labels[labels == target_id] 反查像素,结果在边缘处越界:

# ✅ 先用 n-1 卡住,labels 里的值域是 0..n-1
for i in range(1, n):
    comp = (labels == i)
    if comp.sum() < min_area:
        continue

七、4 连通还是 8 连通:漏检 vs 误连

connectivity 决定「两个前景像素算不算邻居」。这个参数没有"更好的那个",只有"你的目标长什么样"。

实测数据(最干净的一组):

场景 A:两个对角相邻的像素((0,0) 和 (1,1))

connectivity连通域数(不含背景)labels
42(各算一个)[[1,0],[0,2]]
81(连成一体)[[1,0],[0,1]]

场景 B:一条 5 点 45° 对角线

connectivity连通域数(不含背景)
45(每个点自成一域)
81(连成一条)

选型依据:

  • 8 连通:适合斜向、细长、可能轻微弯曲的目标(划痕、裂纹)。场景 B 里如果用 4 连通,5 个点会碎成 5 个域,面积过滤会把它们全删掉。
  • 4 连通:适合块状、正对格子、可能仅靠角落相接的目标。场景 A 里如果用 8 连通,两个本来独立的点会被连成一个,area 翻倍骗过过滤器。

我的默认选择是 8,然后靠面积和长宽比过滤兜底。 理由是工业检测里"漏检"比"误连"代价更高——漏检是批次直接判废,误连只是多一个候选被后续筛选掉。但前提是你的过滤器足够严,如果只按 area 单条件过滤而不过滤形状,8 连通的误连会严重放大面积。


八、inRange 的多通道写法,与背景差分

HSV 双通道阈值

上一篇提到过 inRange,这里补全多通道写法。inRange 收两个 Scalar,逐通道同时比较,两端都是闭区间:

// 抓低饱和度的红(H 在 OpenCV 里是 0-179 的环形量)
cv::inRange(hsv, cv::Scalar(0, 100, 100), cv::Scalar(10, 255, 255), mask);

实测(三个测试像素:(H=0,S=255,V=255)、(H=120,S=255,V=255)、(H=0,S=50,V=255)):

下限上限H=0,S=255H=120,S=255H=0,S=50
(0,100,100)(10,255,255)✅ 255❌ 0❌ 0
(0,100,100)(179,255,255)✅ 255✅ 255❌ 0
(170,100,100)(179,255,255)❌ 0❌ 0❌ 0

第三行值得注意:H=0 不在 [170, 179] 区间内。因为 OpenCV 的 H 是 0–179 的线性量而非环形量——红色在 0 附近和 179 附近各有一份,要同时抓这两份必须写两段:

// ✅ 环形的两端都要写
const cv::Scalar lo1(0, 100, 100),   hi1(10, 255, 255);
const cv::Scalar lo2(170, 100, 100), hi2(179, 255, 255);
cv::inRange(hsv, lo1, hi1, m1);
cv::inRange(hsv, lo2, hi2, m2);
cv::bitwise_or(m1, m2, mask);

(这里的前提是上一篇已确立的结论:红色在 OpenCV 的 H 空间被拆到 0 和 179 两端。)

背景差分

工位相机固定、背景板不动时,最稳的分割依据不是亮度而是和背景的差:

cv::Mat diff;
cv::absdiff(frame, background, diff);              // 逐通道绝对差
cv::threshold(diff, mask, 20, 255, cv::THRESH_BINARY);

实测:背景恒为 100,当前帧是 [100, 105, 95, 200, 0],absdiff 得 [0, 5, 5, 100, 100],再按 thresh=20 切得 [0, 0, 0, 255, 255]——变化 5 灰阶的像素被放过,变化 100 的被抓出来。

适用性要说清:背景差分要求相机和工件完全固定。工件位置一动,边缘就全是"差异",掩膜直接废掉。所以它适合「上料后夹紧、不再移动」的治具场景;产线连续流转的场合请用自适应阈值。thresh=20 这种小值也是因为它衡量的是"绝对差"而不是"相对亮度"——光照整体变亮 10 个灰阶不算缺陷。


九、案例:板材上的油墨残留区域提取

本案例的"金属板材表面缺陷检测与几何测量系统"是教学简化项目,不是我的实际产线系统。参数均为演示值,用于说明方法,不是生产参数。

上一篇我们用黑帽抓划痕。这一次换个需求:找出板材边角上未清除干净的油墨残留(生产时用掩膜盖住边角,油墨渗透到盖板下面)。

为什么不能用 Otsu

油墨残留只占千分之几的像素,而板材底色是带光照梯度的宽直方图。这正好命中 Otsu 的两个软肋:前景峰太矮 + 背景不是单峰。实测这种情况下 Otsu 切出来的掩膜一半是光照梯度。

完整流程

// ink_residue.cpp —— 板材油墨残留提取(教学简化版)
#include <opencv2/opencv.hpp>
#include <algorithm>

struct InkParams {
    int  blockSize = 31;   // 必须大于残留最大跨度,且是奇数
    int  C          = 4;   // 偏置;从 0 起调
    int  minArea    = 40;  // 小于它的"区域"是噪声
    double minRatio = 2.0; // 长宽比下限,方形的不是残留
};

std::vector<cv::Rect> detectInk(const cv::Mat& bgr, const InkParams& p) {
    cv::Mat gray, binary, small;
    cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);

    // 油墨是暗的,用负 C 把阈值往下压,抓比邻域暗的部分
    cv::adaptiveThreshold(gray, binary, 255,
                          cv::ADAPTIVE_THRESH_GAUSSIAN_C,
                          cv::THRESH_BINARY_INV, p.blockSize, p.C);

    // 残留是块状,先用 3x3 腐蚀去掉椒盐噪点再统计
    const cv::Mat k = cv::getStructuringElement(cv::MORPH_RECT, {3, 3});
    cv::morphologyEx(binary, small, cv::MORPH_OPEN, k);

    cv::Mat labels, stats, centroids;
    const int n = cv::connectedComponentsWithStats(
        small, labels, stats, centroids, 8, cv::CV_32S);

    std::vector<cv::Rect> out;
    for (int i = 1; i < n; ++i) {          // 0 号是背景,必须从 1 开始
        const cv::Vec<int, 5>& s = stats.at<cv::Vec<int, 5>>(i);
        if (s[cv::CC_STAT_AREA] < p.minArea) continue;

        const cv::Rect r(s[cv::CC_STAT_LEFT], s[cv::CC_STAT_TOP],
                         s[cv::CC_STAT_WIDTH], s[cv::CC_STAT_HEIGHT]);
        const double ratio = 1.0 * std::max(r.width, r.height)
                           / std::max(1, std::min(r.width, r.height));
        if (ratio < p.minRatio) continue;

        out.push_back(r);
    }
    return out;
}

注意 MORPH_OPEN 放在阈值之后、连通域之前——顺序不能反。先统计的话,每个椒盐噪点都是一个独立连通域,面积过滤能删掉但会拖慢;先开运算清理,同一帧的连通域数量能少一到两个数量级。

Python 对照

import cv2
import numpy as np

def detect_ink(bgr, block_size=31, c=4, min_area=40, min_ratio=2.0):
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)

    # maxValue 在第 2 位,dst 省略 —— 和 C++ 的参数顺序不一样
    binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                   cv2.THRESH_BINARY_INV, block_size, c)

    k = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))   # tuple 不是 list
    small = cv2.morphologyEx(binary, cv2.MORPH_OPEN, k)

    # 返回四元组:n, labels, stats, centroids
    n, labels, stats, _ = cv2.connectedComponentsWithStats(small, connectivity=8)

    boxes = []
    for i in range(1, n):                 # 0 号是背景
        x, y, w, h, area = stats[i]        # 每行直接解包成 5 个 int
        if area < min_area:
            continue
        if max(w, h) / max(1, min(w, h)) < min_ratio:
            continue
        boxes.append((x, y, w, h))
    return boxes

这一层 C++ 和 Python 只有 API 形状的差别,没有语义差别。 四处值得记:

  • 参数顺序:adaptiveThreshold 的 maxValue 在第 2 位(不是 dst),dst 省略
  • threshold 返回二元组:retval, dst = cv2.threshold(...),retval 是 Otsu 阈值
  • 尺寸类型:getStructuringElement 在 C++ 收 cv::Size,Python 收 tuple
  • stats 的索引:C++ 要 stats.at<Vec<int,5>>(i) 整行取出,Python 的 stats[i] 直接可解包

十、阈值参数必须配置化,而且要和位深绑定

上一篇讲形态学参数时我说过一遍,这里必须再强调,因为阈值的脆弱性比形态学更高。

// ❌ 全硬编码:换一批板材、或者相机换到 16 位模式,就全废
cv::threshold(gray, mask, 180, 255, cv::THRESH_BINARY);
cv::adaptiveThreshold(gray, mask, 255, cv::ADAPTIVE_THRESH_MEAN_C,
                      cv::THRESH_BINARY, 31, -4);

我见过真实的翻车:同一套代码,相机从 8 位模式切到 16 位模式,阈值全错但一行异常都没有,质检员看到的是"检测结果全选"。

# thresholds.yaml —— 阈值与位深绑定
plate_ink:
  bit_depth: 8              # thresh / maxval 的量程由它决定
  mode: adaptive            # fixed | otsu | adaptive
  thresh: 180               # mode=fixed 时生效
  block_size: 31            # mode=adaptive 时生效,必须是 >1 的奇数
  c: 4
  maxval: 255               # 必须与 bit_depth 同量程

对应的读取代码有两条必须遵守的规范:

// ✅ 1) blockSize 强制奇数且 >= 3,在读取处就拦掉非法配置
int bs = node["block_size"].asInt();
if (bs < 3 || bs % 2 == 0) {
    throw std::runtime_error("block_size must be odd and >= 3");
}

// ✅ 2) 16 位流程里 maxval 必须跟着位深走,不能写死 255
const int maxval = (node["bit_depth"].asInt() == 16) ? 65535 : 255;
cv::adaptiveThreshold(gray, mask, maxval, cv::ADAPTIVE_THRESH_MEAN_C,
                      cv::THRESH_BINARY, bs, c);

把校验放在配置读取处,而不是靠人记住。 非法配置应该在程序启动时崩掉,而不是产出一张全选的全黑图让质检员去发现。

再补一条:把 Otsu 的返回值也记进日志。 它不花额外开销,但出问题时你能立刻判断"是阈值算错了"还是"阈值算对了但下游错了":

const double t = cv::threshold(gray, mask, 0, maxval,
                               cv::THRESH_BINARY | cv::THRESH_OTSU);
LOG_INFO("otsu threshold = {}, foreground ratio = {:.4f}",
         t, cv::countNonZero(mask) / static_cast<double>(mask.total()));

算子选型速查表(本篇)

场景用什么关键参数陷阱
光照均匀的单阈值THRESH_BINARYthresh, maxval阈值必须与位深同量程
提取暗于目标的区域THRESH_BINARY_INV同上与上一行互为取反,别选错
想压暗部但保留层次THRESH_TRUNCthresh输出不是二值图
想只清零低端保留亮部THRESH_TOZEROthresh输出不是二值图
前景要套用另一张图的值THRESH_BINARY + bitwise_andthresh别用 THRESH_MASK,它静默输出全零图
直方图双峰的灰度图THRESH_BINARY|THRESH_OTSUmaxval仅 CV_8UC1/CV_16UC1(Triangle 仅 8U);必须接返回值
光照不均(工业常态)adaptiveThresholdblockSize, CblockSize 奇数 ≥3;C 决定均匀区算不算前景
缓慢变化的光照adaptiveThreshold + GAUSS_C同上比 MEAN_C 慢,过渡更自然
相机固定、治具不动absdiff + THRESH_BINARY差值阈值工件一动就全废
按颜色抓(HSV)inRangeScalar 上下限H 是 0–179 线性量,环形两端都要写
统计每个目标的面积/位置connectedComponentsWithStatsconnectivitystats[0] 是背景,循环从 1 开始
只要标签图connectedComponentsconnectivity无统计量,省内存
细长斜向目标(划痕)connectivity=8—4 连通会把目标碎成多段
独立块状目标connectivity=4—8 连通会误连相邻目标,area 虚高

避坑指南

坑 1:thresh 和图像位深量程不匹配(静默出错)

现象:不抛异常,结果几乎全前景或全背景。原因:thresh 不校验量程。修正:thresh 必须随位深走;maxval 只决定写入值、不影响判定,16 位图优先用 Otsu(返回值天然落在自身量域)。

❌ cv::threshold(img16, mask, 255, 65535, cv::THRESH_BINARY)   // 静默按 255 切
✅ cv::threshold(img16, mask, 0, 65535, cv::THRESH_BINARY | cv::THRESH_OTSU)

坑 2:Otsu 在浮点图上抛异常,但固定阈值不抛

现象:同一个位深,固定阈值正常,换成 Otsu 就抛 (-215:Assertion failed)。原因:自动阈值比固定阈值窄得多——实测固定阈值支持 8U/16U/32F/64F,Otsu 只支持 8U/16U(完整矩阵见第二节坑 3)。修正:转回 CV_8U 再用 Otsu,或改用自适应阈值。

坑 3:Otsu 的返回值被丢弃

现象:分割结果不对,但不知道阈值是多少。原因:cv::threshold 返回值就是 Otsu 阈值,丢掉就失去诊断依据。修正:const double t = cv::threshold(...),并在阈值贴近量程端点时告警。

坑 4:Otsu 假设双峰,用在不满足前提的图上

现象:掩膜一半是光照梯度。原因:前景占比小、背景有梯度,直方图不是双峰。修正:光照不均的工业图直接上 adaptiveThreshold,别指望 Otsu。

坑 5:THRESH_TRUNC / THRESH_TOZERO 当成二值化用

现象:下游 countNonZero 得到乱七八糟的数。原因:这两个 flag 输出不是二值图。修正:要二值图只用 THRESH_BINARY / THRESH_BINARY_INV。

坑 6:用 THRESH_MASK 拿到一张全零图

现象:不抛异常、不告警,dst 全是 0——“一个缺陷都没检出”。原因:THRESH_MASK = 7 是 cv2 里的遗留常量,cv::threshold 根本没有 mask 参数(C++ 和 Python 都没有)。你传的第 5 个参数其实是 dst,而 type = 7 又不是任何合法标志位的组合。修正:不要用 THRESH_MASK;要做"前景取另一张图的值"就用 bitwise_and 两步拼。

坑 7:adaptiveThreshold 的 blockSize 传偶数或过小

现象:抛异常。原因:必须是 >1 的奇数且 ≥3。修正:在配置读取处强制校验(bs < 3 || bs % 2 == 0 就抛)。

坑 8:blockSize 小于目标尺寸

现象:目标被当成局部均值消掉,一个都检不出来。原因:自适应阈值拿自己的邻域当基准,目标自己成了基准。修正:blockSize ≈ 目标最大跨度的 2 倍。

坑 9:C = 0 导致均匀区域几乎不产生前景

现象:大片平坦区域检不出东西。原因:均匀处 src ≈ 邻域均值,公式退化成 src > src,恒为假。修正:C 从 0 往上给小正值(2–10),它是唯一打破均匀区平衡的参数。

坑 10:stats[0] 是背景却当成了目标

现象:出现一个覆盖全图、面积等于全图像素数的"巨大目标"。原因:n 含背景,0 号是背景。修正:遍历一律从 i = 1 开始。

坑 11:Python 里 adaptiveThreshold 的 dst 位置写错

现象:Argument 'maxValue' can not be treated as a double。原因:Python 绑定的 dst 在最后且可选,写第二位会落到 maxValue。修正:cv2.adaptiveThreshold(gray, 255, method, type, blockSize, C)。这是位置错位伪装成类型错误,别去查类型。

坑 12:connectivity 选错,4 连通碎裂或 8 连通误连

现象:细长划痕被切成好几段(用 4 连通)/两个独立目标被连成一个、area 翻倍骗过过滤器(用 8 连通)。原因:4 连通只认上下左右,8 连通还认对角。修正:细长斜向目标用 8,块状独立目标用 4;用 8 连通时必须同时过滤长宽比。


优秀实践 / 可改进之处

优秀实践 ①:阈值策略按「光照是否均匀」分档,不按「感觉」选

我把这条写进了代码审查清单:进流水线时必须先回答**“这张图的亮度分布是单峰还是双峰、光照是否随位置变化”**,然后才允许写 threshold 调用。不接受"先固定阈值试试,不行再上自适应"——后者浪费的调参时间比一次判断多得多。

优秀实践 ②:所有阈值配置和位深绑定,校验放在读取处

bit_depth、maxval、thresh 三者必须同时出现在配置里。非法值(偶数 blockSize、超量程 thresh)在启动时就崩。让程序在启动时失败,比让它在产线上产出一张全选图便宜得多。

优秀实践 ③:Otsu 的返回值和前景占比都进日志

这两个数几乎零成本,但它们是"分割是否合理"的直接证据。我排查过一次 Otsu 失效,就是靠日志里 threshold=0 立刻定位到那张图不适合 Otsu。

可改进之处:阈值的自动寻优

现在 thresh、C、minArea 全靠手调。理想是给一批标注样本自动搜参。这我试过两个方向都没走通:用贝叶斯优化跑参数空间,但标注成本高、样本量不够;换成无监督指标(比如"连通域面积分布的双峰性"),但它和真值的相关性我没能验证。

可改进之处:背景差分没有建模光照变化

现在的背景差分假设"绝对灰阶差超过阈值就是缺陷",一旦照明整体漂移就会误报。可以改进的方向是先做光照归一化再差分(比如除以低通滤波结果,或估计一个平面光照模型后相除)——代价是归一化本身可能把真实的低频缺陷一起抹掉,需要谨慎评估。


本期互动

  • 你做二值化时,是先判断光照均匀性,还是直接上自适应阈值?
  • 你的项目里阈值是硬编码还是配置化?配置里有没有和位深绑定?
  • 你的相机/图像管线是 8 位还是 16 位?如果中间有 32F 归一化,Otsu 是放在归一化之前还是之后?

💬 评论区聊聊

你在项目里见过的最隐蔽的"不报错但结果全错"是什么?

我自己那个是 16 位图配 8 位阈值——thresh=255 在 0–65535 的图上被静默接受,程序跑得好好的,只是分割结果全选。它和另外一类"报错但原因写错"(就是我上面 maxValue 那个)有一个共同点:错误信息和真实原因不在一个层面上。

我很想知道评论区里做检测的同行有没有更系统的方案?比如:

  • 有没有人把阈值和光照模型一起做,用采集到的背景动态估计阈值,而不是固定一套参数?
  • 有没有人用产线反馈的误检/漏检做在线调参?比如根据统计结果自动微调 C?
  • 极端一点的——有没有人直接上深度学习分割,把阈值问题整个绕开?

👉 觉得有用,记得收藏 + 转发

尤其是第二节那张六个 flag 的实测输出对照表和第七节那组4/8 连通域的实测对比——我建议存下来。前者能省掉你逐个试一遍的时间,后者那两个数字(对角两点在 4 连通下是 2 个域、8 连通下是 1 个域)我自己也是写完文章才想明白的。

如果这篇对你有帮助,欢迎点赞转发,让更多做视觉的朋友少调几个小时的参数。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/richard_yuu/article/details/166946551

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--