
二值化只有一行代码,但它是整个视觉流水线里最容易"换台设备就废"的一环。这篇把阈值类型全家族、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 下算背景。这个 > 和 >= 的差一位,在做「阈值以下算合格」的业务上会让你整批数据反着来。
把四个真二值化的行为对齐成表:
| flag | src > thresh 时 | 否则 | 输出是二值图吗 |
|---|---|---|---|
THRESH_BINARY | maxval | 0 | ✅ 是 |
THRESH_BINARY_INV | 0 | maxval | ✅ 是 |
THRESH_TRUNC | 截断到 thresh | 原值 | ❌ 否,保留原动态范围 |
THRESH_TOZERO | 原值 | 0 | ❌ 否,只清零低端 |
THRESH_TOZERO_INV | 0 | 原值 | ❌ 否,只清零高端 |
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.0300002553000030000.07000025565535← 撞 16U 上限70000.0← 不截断655352556553565535.00全零 全零 全零 三个容易踩的点:① 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_BINARY | 0 |
THRESH_BINARY_INV | 1 |
THRESH_TRUNC | 2 |
THRESH_TOZERO | 3 |
THRESH_TOZERO_INV | 4 |
THRESH_OTSU | 8 |
THRESH_TRIANGLE | 16 |
THRESH_MASK | 7 ← 不在文档枚举里 |
注意 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 纯色 128 | t = 0 | 256 / 256(全选) |
| 16×16 0…255 均匀分布 | t = 127 | 128 / 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_BINARY | THRESH_OTSU | THRESH_TRIANGLE |
|---|---|---|---|
CV_8UC1 | ✅ | ✅ | ✅ |
CV_16UC1 | ✅ | ✅ 可用 | ❌ 抛异常 |
CV_32FC1(0…1) | ✅ | ❌ 抛异常 | ❌ 抛异常 |
CV_32FC1(0…255) | ✅ | ❌ 抛异常 | ❌ 抛异常 |
CV_64FC1 | ✅ | ❌ 抛异常 | ❌ 抛异常 |
CV_8SC1 | ❌ 抛异常 | ❌ 抛异常 | ❌ 抛异常 |
CV_32SC1 | ❌ 抛异常 | ❌ 抛异常 | ❌ 抛异常 |
三个和文档不一致的地方,都值得记住:
- Otsu 在 16U 上是可用的(实测一张
arange(16)*4000的 16 位图,Otsu 返回t = 28000)。文档那句"仅 8 位"对 Otsu 是错的。 - Triangle 在 16U 上会抛异常,异常信息是
src.type() == CV_8UC1——这一半文档说对了。 - 固定阈值比两个自动方法都宽松: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 | 前景像素数 | 掩膜 |
|---|---|---|
255 | 15 / 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) |
|---|---|
| 20 | 4096(全前景) |
| 10 | 4096(全前景) |
| 0 | 256 |
| -10 | 0 |
为什么 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 家族有三个函数,区别只在"要不要多给你点信息":
| 函数 | 返回连通域数 | labels | stats | centroids |
|---|---|---|---|---|
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 |
|---|---|---|
| 4 | 2(各算一个) | [[1,0],[0,2]] |
| 8 | 1(连成一体) | [[1,0],[0,1]] |
场景 B:一条 5 点 45° 对角线
connectivity | 连通域数(不含背景) |
|---|---|
| 4 | 5(每个点自成一域) |
| 8 | 1(连成一条) |
选型依据:
- 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=255 | H=120,S=255 | H=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_BINARY | thresh, maxval | 阈值必须与位深同量程 |
| 提取暗于目标的区域 | THRESH_BINARY_INV | 同上 | 与上一行互为取反,别选错 |
| 想压暗部但保留层次 | THRESH_TRUNC | thresh | 输出不是二值图 |
| 想只清零低端保留亮部 | THRESH_TOZERO | thresh | 输出不是二值图 |
| 前景要套用另一张图的值 | THRESH_BINARY + bitwise_and | thresh | 别用 THRESH_MASK,它静默输出全零图 |
| 直方图双峰的灰度图 | THRESH_BINARY|THRESH_OTSU | maxval | 仅 CV_8UC1/CV_16UC1(Triangle 仅 8U);必须接返回值 |
| 光照不均(工业常态) | adaptiveThreshold | blockSize, C | blockSize 奇数 ≥3;C 决定均匀区算不算前景 |
| 缓慢变化的光照 | adaptiveThreshold + GAUSS_C | 同上 | 比 MEAN_C 慢,过渡更自然 |
| 相机固定、治具不动 | absdiff + THRESH_BINARY | 差值阈值 | 工件一动就全废 |
| 按颜色抓(HSV) | inRange | Scalar 上下限 | H 是 0–179 线性量,环形两端都要写 |
| 统计每个目标的面积/位置 | connectedComponentsWithStats | connectivity | stats[0] 是背景,循环从 1 开始 |
| 只要标签图 | connectedComponents | connectivity | 无统计量,省内存 |
| 细长斜向目标(划痕) | 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




