
做视觉检测这些年阈值分割是天天都要打交道的东西。很多人一开始用全局阈值 threshold 用得很顺手直到某天遇到一个光照不均匀、背景灰度还在缓慢变化的图像阈值怎么调都像狗啃过一样这才回头来找局部阈值的方案。Halcon 里最有代表性的局部阈值算子就是 dyn_threshold今天就把这个算子从头到尾掰开揉碎讲清楚包括它背后的思路、参数含义、实际调试经验以及我踩过的坑。不废话直接进主题。1. 从一次真实的检测需求说起去年做过一个透明薄膜表面脏污检测的项目薄膜上有黑点、白点两种缺陷但是膜本身厚度不均导致图像背景灰度从 120 到 200 之间缓慢变化。一开始我用全局阈值把阈值设在 100结果膜薄的地方背景直接被切进缺陷区域膜厚的地方同一种脏污却切不出来。试了 binary_thresholdOtsu也一样因为 Otsu 本质上还是全局统计背景灰度分布一旦展宽类间方差的最优解依然是一个“中间靠拢”的折中阈值局部暗背景必然漏检。后来把方案改成 dyn_threshold效果立竿见影。这个算子的核心思路很简单不要拿一个固定灰度值去切全图而是为每个像素构造一个“局部阈值”拿原图像素和这个局部阈值逐点比较。因为局部阈值是根据该像素周围邻域的灰度算出来的所以背景即使整体明暗变化但只要缺陷像素相对于周围邻域有足够大的灰度差就能被分离出来。1.1 全局阈值为什么扛不住光照不均要理解 dyn_threshold 的价值得先明白全局阈值失效的数学本质。threshold 的判决条件只有一个固定变量 T像素灰度 g(x,y) ≥ T 就保留。当照明不均匀时背景灰度本身是空间位置的函数 B(x,y)如果 B(x,y) 的波动范围超过目标与背景的真实灰度差那么任何单一 T 都无法同时在亮背景处区分目标和暗背景处区分目标。这里有个生活化的类比你在室内摄影窗外阳光照进来室内一半明亮一半昏暗。如果你只用一个固定曝光值去拍整张照片要么窗户过曝要么室内漆黑。你想把所有人都拍清楚必须对明亮区域和昏暗区域分别设置曝光。dyn_threshold 做的事情就是“逐像素分别定曝光”不过它定的不是曝光时间而是灰度比较的基准值。1.2 dyn_threshold 的定位它不是孤立函数而是一种策略在 Halcon 的阈值算子家族里threshold 是固定的binary_threshold 是自动算一个全局阈值var_threshold 是用局部均值和方差动态构造区间dyn_threshold 则是用“原图减去背景估计图”的思路。从使用频率上看dyn_threshold 是局部阈值里最常用、最容易理解和调试的一个因为它只有两个真正需要调试的参数背景估计的滤波核尺寸 MaskSize以及灰度偏移量 Offset。真正的工程难点往往不在算子本身而在“背景估计图”怎么做。这也是很多新手容易卡住的地方——dyn_threshold 需要两张输入图一张是原图另一张是所谓的 ThresholdImage。ThresholdImage 构造得好不好直接决定检测效果。下面单独用一章来讲。2. 阈值图像 ThresholdImage 的选择与构造dyn_threshold 的签名是 dyn_threshold(OrigImage, ThresholdImage, Region, Offset, LightDark)。很多人第一次看到 ThresholdImage 这个概念会觉得抽象我换个说法ThresholdImage 就是你对“这幅图像在没有缺陷的情况下应该长什么样”的估计。让背景的慢变化被完整保留让缺陷的快变化被滤掉这就是背景估计的目标。2.1 最常见方案mean_image 均值滤波工程里最常用的 ThresholdImage 构造方式就是均值滤波代码写起来就一行mean_image(Image, ImageMean, 21, 21) dyn_threshold(Image, ImageMean, Regions, 5, not_equal)mean_image 用 MaskWidth x MaskHeight 的矩形窗口对图像做邻域平均。窗口内亮度平均之后孤立的小缺陷贡献会被周围背景“稀释”掉窗口越大缺陷在背景估计图里的残留越少。但窗口也不能无限大窗口太大时背景估计跟不上背景本身的真实变化会把背景的缓慢灰度起伏也当作“缺陷”输出导致一大片假区域。MaskSize 的选取原则我一般按目标缺陷尺寸的 3 到 5 倍起步。比如要检测的脏污点直径约 5 个像素均值滤波窗口至少选 15 到 25。实际工作中我习惯从 21 开始试然后再根据效果上下调整。2.2 其他滤波器与形态学背景估计mean_image 是矩形窗口平均如果更在意方向性纹理背景还可以用以下方式生成 ThresholdImagebinomial_filter二项式滤波本质是可分离的高斯近似边缘保留比矩形均值好。背景整体平滑但缺陷边界不容易糊掉适合目标边缘比较锐利的场景。gauss_filter真正的高斯滤波sigma 控制平滑程度。理解起来和均值滤波一样只是权重的分布是高斯形的中间权重高、边缘低。median_image中值滤波。对孤立噪声点有极强的抑制作用但对较大缺陷的估计效果不如均值。如果图像本身噪声很大可以先中值滤波再用均值滤波效果会稳一些。gray_opening / gray_closing / gray_opening_shape灰度形态学操作这些算子特别适合提取划痕这类灰度与邻域差异很小但几何形状有明显特征的缺陷。灰度过大会让背景估计图在某些情况下更贴近“真实背景”因为形态学是按照几何结构元素去侵蚀或扩张灰度曲面而不是简单平均。实际选型时我会先看背景的纹理类型。均匀背景下mean_image 完全够用背景有轻微高斯纹理binomial_filter 表现更平滑如果是划痕检测或者目标带有方向性延伸我会用灰度形态学构造背景然后配合 dyn_threshold 的 light 或 dark 模式分别提取。2.3 背景估计的核心理念保留慢变化滤掉快变化把背景估计这步做对关键是想清楚频率域的概念。图像可以近似看成两部分背景的低频成分和缺陷/边缘的高频成分。mean_image 这类低通滤波就是把高频分量压掉保留低频背景。当原图减去背景估计图时高频缺陷被增强低频背景被抵消剩下就是我们想要的差异信号。这里必须提醒一个典型错误MaskSize 如果比缺陷尺寸还小均值滤波会把缺陷自身的灰度也平均进背景估计里相当于背景估计图里“也有缺陷”最后两者相减缺陷灰度差被磨平结果是该检的缺陷完全出不来。我在刚接触 dyn_threshold 时就在这上面栽过跟头MaskSize 取了 5去检一个直径 10 像素的黑点结果一个都检不到最后查了半天才发现是滤波窗口太小缺陷被背景估计“吸收”了。3. 参数逐项拆解Offset 和 LightDark 怎么定背景估计图的事情理清楚之后dyn_threshold 本身的参数就简单多了。Offset 决定敏感度LightDark 决定提取方向这两个参数是实际调试时的核心手感来源。3.1 Offset 偏移量灵敏度旋钮Offset 可以理解为比较时的“死区”。判决条件可以视为原图像素与阈值图像素之间的差必须超过 Offset 才认为是前景。具体到内部实现忽略边缘效果dyn_threshold 的判决逻辑可以等价成下面这段伪代码for each pixel (x, y): diff original(x, y) - smoothed(x, y) if mode light: result diff offset elif mode dark: result -diff offset elif mode not_equal: result abs(diff) offset elif mode equal: result abs(diff) offsetOffset 取多大取决于你关注的缺陷最小对比度以及图像的噪声水平。假如图像的随机噪声标准差是 3 个灰阶Offset 取 5 你就能把绝大多数噪声抑制在外面同时保住 10 个灰阶以上的缺陷Offset 取 40可能只有明显异常才会被提取轻微脏污会被漏掉。实际调试时我会把 Offset 从零开始往上加。零的时候全是噪点和背景纹理然后逐步提高直到背景区域安静下来只剩下真实缺陷候选。这一步没有公式可以一步到位因为每个项目的噪声底和缺陷对比度都不一样经验手感很重要。3.2 LightDark 四种模式的行为对照LightDark 参数决定了从哪个方向判定为目标区域下面这个表格把这四种模式整理得很直白LightDark 取值判决条件提取内容典型场景light原图比背景估计亮 Offset 以上亮目标、亮缺陷黑背景上的白点、反光异物dark原图比背景估计暗 Offset 以上暗目标、暗缺陷亮背景上的黑点、凹坑、污渍not_equal原图与背景估计差绝对值超过 Offset亮、暗目标一并提取分不清缺陷明暗或两类都要检equal原图与背景估计差绝对值不超过 Offset差异小于阈值的区域提取灰度均匀区域、去除异常目标我在缺陷检测项目里最常用的是 not_equal因为很多产品上既可能沾上暗的油污也可能出现亮的划痕先全部提出来后面再用形态学和灰度特征区分。如果产品背景复杂亮缺陷和暗缺陷的后续处理逻辑完全不一样那就分成两次 dyn_threshold分别用 light 和 dark 提流程更清晰。另外注意如果只想提取暗缺陷用 dark 模式时即使图像里存在很亮的反光点也会被直接忽略干扰会小很多计算区域也小速度还可以更快。3.3 输入图像类型与 Domain 的注意事项dyn_threshold 对输入图像的类型有一定要求OrigImage 和 ThresholdImage 的同尺寸、同类型是硬性约束。Halcon 里常见的是 byte 图像的缺陷检测但 uint2、int2、real 类型同样支持。如果你用的是 3D 高度图比如热词里有人搜 halcon 3D高度图缩放显示数据一般是 real 类型使用 dyn_threshold 之前要先确认两个输入都是同一类型必要时用 convert_image_type 转成统一的类型再做。另一个容易忽略的是 Region 的 Domain。dyn_threshold 只处理输入图像有效 Domain 内的像素。如果之前用 reduce_domain 限制了处理区域dyn_threshold 的计算就只在 ROI 内部进行这对大图速度优化很重要。比如整幅图是 5000x5000 的 PCB 板但目标只在某个局部区域先圈出 ROI 再做 dyn_threshold能省不少时间。我还常用一个等价写法来加深理解先做图像差分再用 threshold 提取效果差不多abs_diff(Image, ImageMean, ImageDiff) threshold(ImageDiff, Regions, Offset, 255)这种写法虽然多一步但调试的时候方便观察 ImageDiff 长什么样能直观看到背景消减后的效果。要是发现 ImageDiff 图上背景还有大片灰度残留说明 MaskSize 选得不够大背景估计没做彻底如果 ImageDiff 图上目标区域和背景几乎分不开说明 Offset 需要往下调。这种“可视化中间结果”的做法对新手快速上手 dyn_threshold 特别有帮助。4. 实操案例OLED 屏幕表面划痕检测全流程理论讲了半天不如直接看一个完整的案例。我们用 HDevelop 写一个 OLED 屏幕面板表面划痕检测的示例步骤就是从图像读入到缺陷区域输出完全是工程可用的流程。4.1 完整 HDevelop 代码与逐段注释* 1. 读入图像并转为灰度 read_image (Image, oled_screen_01.png) rgb1_to_gray (Image, GrayImage) * 2. 用均值滤波构造背景估计图 * 屏幕上的划痕宽度大概 2~3 像素长度在 10~30 像素 * 所以滤波核取 31保证划痕不会被吸收进背景 mean_image (GrayImage, ImageBackground, 31, 31) * 3. 动态阈值提取划痕候选区域 * OLED 表面灰度噪声较小Offset 取 8 足够 * 同时包含亮划痕和暗划痕这里选 not_equal dyn_threshold (GrayImage, ImageBackground, RegionsCand, 8, not_equal) * 4. 拆分连通域 connection (RegionsCand, ConnectedRegions) * 5. 用形状特征筛掉孤立噪点和非划痕区域 select_shape (ConnectedRegions, SelectedRegions, [area, width, height], and, [30, 1, 5], [5000, 50, 200]) * 6. 如果边缘有锯齿或毛刺可以用开运算修一下边界 opening_rectangle1 (SelectedRegions, RegionOpening, 1, 3) * 7. 输出结果 count_obj (RegionOpening, NumDefects)这段代码的逻辑很清晰先对每个像素求“局部异常程度”再用动态偏差筛掉背景最后靠形状特征把真正的划痕和脏点区分开。第 5 步的 select_shape 参数我按实际项目经验写的划痕面积一般在 30 到 5000 像素之间宽度大于 1 个像素高度大于 5 像素这样可以把零散噪声点过滤掉也不会把过短过细的碎划痕当成缺陷。4.2 参数为什么这样选简单解释一下几个参数的选择逻辑。MaskSize 取 31因为划痕最长的有约 30 像素如果滤波窗口小于划痕尺寸划痕自身会被平均进背景估计导致它在差分图中灰度差变小划痕会“消失”。窗口取 31 大于最大划痕尺寸的同时又能保证背景的灰度变化被跟随住是工程里比较稳的一个取值。Offset 取 8要先确认屏幕表面灰度噪声水平。OLED 面板在暗室、固定光源下成像背景噪声标准差通常在 23 个灰阶Offset 取 8 大约是三倍噪声标准差足以在数学上保证误检的随机概率非常低同时又能保住对比度在 10 个灰阶以上的浅划痕。select_shape 里的 area 下限 30 和 width、height 的约束也是从噪声特征来的。均值滤波之后孤立噪点在 31x31 窗口下一般很难形成大面积连通区域所以只留下面积大于 30、且形状具有方向性的区域作为划痕候选。这个阈值可以先用少部分带标记的样张标定再固定下来跑批量。4.3 后处理从初步区域到干净的缺陷dyn_threshold 输出的 Region 通常是很“碎”的直接看会吓一跳——密密麻麻全是杂点。这不奇怪因为动态阈值把任何局部灰度偏差都标记出来了但其中九成以上都是背景纹理带来的伪缺陷。所以后处理阶段几乎是必须的常用三板斧第一板斧是 connection 拆分连通域。dyn_threshold 输出的是一个总区域不拆开就没法一个一个评估。第二板斧是 select_shape 按几何特征过滤。面积、宽度、高度、矩形度、紧密度、方向都是常用特征。比如圆形脏污用 circularity 区分条形划痕用 width 和 height 的比例区分。第三板斧是灰度特征筛选。如果几何过滤之后还有残留可以对每个候选连通域用 select_gray统计它们在原图上的灰度均值、灰度方差等特征把和真实缺陷灰度表现不符的区域去掉。这个方法对区别“真脏污”和“色差纹理”尤其有效。我自己做项目时常把后处理做成一个独立的小模块输入是 dyn_threshold 的候选区域输出是缺陷分类结果。这样一来调试 dyn_threshold 参数的时候不用管后处理调后处理也不用反复动前面的算子思路清爽很多。5. 参数调试心得与组合拳dyn_threshold 上手容易但要调到“稳”必须形成一套调试节奏。这里把我压箱底的经验写出来。5.1 快速调节参数的思路先大后小拿到一张新图我建议按“先粗后细”的顺序来先用 MaskSize 取一个偏大的值比如 51Offset 也适当给大一点比如 30把最明显的缺陷先调出来。确认最明显的缺陷能够被完整提取之后逐步调小 Offset比如从 30 到 20、10、5看额外的候选区域主要是噪声还是真的缺陷。当出现很多背景纹理残留时再反过来调整 MaskSize 的大小直到背景残留最少、目标轮廓最完整。这个过程在 HDevelop 里配合滑块控件做很方便。你可以把 MaskSize 和 Offset 定义成变量用 HDevelop 的实时调参窗口拖滑块时立刻看到 Region 变化。调完再固化代码没必要反复改代码重新跑。5.2 性能优化ROI、金字塔与可分离滤波dyn_threshold 本身的计算量不大逐像素做减法比较速度瓶颈一般出在均值滤波这种大核卷积上。如果 MaskSize 要取到 51 甚至 101那 mean_image 会明显变慢。几个常用的加速思路先用 reduce_domain 限制到 ROI只对有效区域做滤波和阈值省掉背景区域的计算。背景估计改用可分离滤波算子比如 binomial_filter 或者 gauss_filter它们的卷积核可分离大核时比矩形均值快不少。对超大图像比如工业相机的 2900 万像素图可以先用 zoom_image_factor 或 gen_gauss_pyramid 降采样在低分辨率上把大致区域找出来再把低分辨率的 Region 映射回原图做精细定位。这里的坐标映射要乘回缩放系数而且在原图上还需要适当膨胀 Region 以覆盖映射误差。还有一个小技巧如果只需要找暗缺陷可以先取反图像 invert_image再统一用 light 模式这样代码逻辑简单同一分支处理所有情况速度上不会有本质区别但维护性强。5.3 组合方案什么时候用 var_threshold 或者直接用形态学dyn_threshold 不是万能药。它本质上假设目标和背景之间是一个“可估计的缓慢变化背景”加“局部偏差”。如果图像背景本身有很强的纹理而且目标对比度又不高dyn_threshold 的误检率就会上升。这种情况下我一般会考虑 var_threshold。var_threshold(Image, Region, MaskWidth, MaskHeight, StdDevScale, AbsThreshold, LightDark) 的思想是用局部均值加减若干倍局部标准差构成动态阈值。它多了 StdDevScale 这个尺度参数对局部灰度波动的适应性更强。但是代价是调参难度增加参数之间相互影响更大。我的经验是如果 dyn_threshold 通过调整 MaskSize 和 Offset 已经能稳定出结果就别换 var_threshold减少一个项目的不可控因素只有背景纹理实在太复杂的时候才切换到 var_threshold并且主要盯 StdDevScale 这一个参数调。灰度形态学的方案也有独特价值。比如要提划痕可以先 gray_opening(Image, ImageOpening, 15, 15)把细线状的划痕腐蚀掉剩下的 ImageOpening 就是“没有划痕的背景估计”再用 dyn_threshold 原始图和形态学背景图的差划痕反而会更突出。形态学背景估计对延展性目标尤其好用因为矩形窗口平均会把细线目标“拉宽”到窗口大小导致背景估计里出现线状伪影而形态学开运算不会出现这类问题。6. 常见问题排查实录从各个项目里把最容易踩的坑整理成一张速查表建议大家收藏现象可能原因解决办法该检的缺陷完全检不出来均值滤波核小于缺陷尺寸缺陷被吸收进背景估计增大 MaskSize至少为目标尺寸 3 倍以上背景大片被误检Offset 太小或 MaskSize 太大增大 Offset或者调小 MaskSize 让背景跟随更紧密图像全是细碎噪点图像噪声大、Offset 偏低先中值滤波去噪或提高 Offset缺陷边缘毛刺严重缺陷和背景灰度过渡比较平缓调小 Offset 会减少边缘截断后续用 opening_circle 修边边界处一条亮带均值滤波在图像边缘产生边界效应或者是 ROI 边界处邻域不完全对图像先做相关边界处理或者只统计缩进后的区域亮缺陷和暗缺陷分离在不同步骤没有用 not_equal 模式或者两类的差异方向不一致如果希望统一提取改用 not_equal结果区域被连接成一大片缺陷密集灰度变化连成片降低 Offset 让灰度级差变小或者用 watersheds 分水岭再分一次整个区域为空两个输入图像类型或尺寸不一致检查两图的 Type 和 Width/Height 是否完全一致只截到缺陷的上半截Offset 高于缺陷实际对比度的一半调小 Offset 或改用梯度预处理6.1 缺陷被背景估计吸收最隐蔽的坑这个现象前面提过但值得单独强调一次。MaskSize 选得不合适时缺陷区域的灰度被平均到背景估计里导致原图和背景估计在缺陷处几乎没有差别最终什么都检不出来。这种问题最迷惑的地方在于你单独看原图缺陷非常明显怎么看都觉得 dyn_threshold 应该一抓一个准但结果就是一片空白。排查思路很简单把 ImageBackground 单独显示出来如果缺陷在背景图里已经能隐约看到轮廓说明窗口小了立刻调大 MaskSize。6.2 光照不均严重时不要硬调 dyn_threshold如果图像左侧特别亮、右侧特别暗而且这种亮度差异在邻域尺度内就已经很陡峭mean_image 做背景估计时会跟不上dyn_threshold 会在明暗交界处输出一条很宽的假区域。遇到这种情况我的建议不是在 dyn_threshold 上死磕而是先做一级预处理用 illumination_normalize 或同态滤波把低频照明成分去掉再交给 dyn_threshold。记住dyn_threshold 对付的是“缓慢变化”的光照不均太快的变化它扛不住。6.3 多通道彩色图不能直接喂进去如果读进来的是彩色图绝不能直接把三个通道丢给 dyn_threshold。要先通过 rgb1_to_gray 或者 decompose3 取单通道。热词里有人搜 halcon 的 hsv 和 RGB 转换说明很多新手在这里犯迷糊。我的做法是如果缺陷在两个颜色通道上的对比度不一样就先做通道分离在对比度最高的那个通道上去做 dyn_threshold再把结果映射回原坐标。这样既规避了多通道输入的类型问题又往往比直接转灰度效果更好。7. 写在最后的经验dyn_threshold 是那种“看起来简单、用好了非常强大”的算子。它的设计理念不算复杂就是给每个像素找一个合适的局部比较基准但正因为这个“局部比较”的思路它把光照不均、背景灰度漂移这些常见成像问题消解在了算法内部。这些年我做表面缺陷检测从印刷品、薄膜、金属件到 PCB、OLEDdyn_threshold 一直是工具箱里的常客和形态学、特征筛选配合起来能解决绝大多数“背景不干净、目标不明显”的灰度分割问题。还有一点想特别提醒做项目落地的朋友算法选型一定要匹配图像的物理形成过程。如果缺陷的形成是“吸收了光线”或者“散射了光线”它一定会在灰度上留下局部变化这类问题非常适合 dyn_threshold如果缺陷的形成是几何形变或者颜色变化灰度变化不一定稳定这时候别只盯着阈值算子可以考虑边缘检测、频域特征或者深度学习辅助粗定位。先搞清楚缺陷在图像上是“怎么呈现”的再决定用哪个算子项目才能走得顺。