干机器视觉这行的老伙计应该都有体会:相机、镜头、光源折腾一圈,最后项目能不能顺利交付,往往不是靠什么花哨算法,而是最基础的那步图像分割做得到不到位。Halcon里做图像分割,绝大多数工业场景落到最后就是两个字——二值化。把目标从背景里干净利落地拎出来,变成一个黑白分明的Region,后续的测量、定位、计数、缺陷判定才有得玩。这篇文章不聊虚的,我结合这些年经手的几个实际视觉项目,把Halcon图像分割二值化的核心算子、参数背后的逻辑、预处理怎么配合、分割之后怎么后处理,还有那些文档里根本不会写的实战经验,一次性讲透。无论你是刚接触Halcon的新人,还是被阈值问题折磨过的老手,这文都值得你花十分钟读完。
1. 二值化在整个图像处理流程里的位置
1.1 分割、二值化、Region和Blob分析到底什么关系
很多人刚学Halcon容易把这几个概念搅在一起,我先理清:图像分割是一个大类,意思是把图像划分成若干个有意义的区域;二值化只是分割方法里最常用的一种,本质是根据灰度阈值把像素分成目标和背景两类,输出一个前景为1、背景为0的二值区域。这个区域在Halcon里叫Region,它不是一张图,而是一种基于游程编码的集合表示。
为什么工业上这么依赖二值化?因为生产线上的场景相对可控:光源固定、相机固定、产品位置大致固定。在这种条件下,目标和背景的灰度差异通常是稳定的,一个阈值就能解决90%的分割需求。后续的Blob分析(连通域分析)就是建立在二值化结果之上的典型套路:先阈值,再连接,再按特征筛选,最后输出结果。可以说二值化是整套视觉流程的地基,地基没打好,后面用什么高级算子都白搭。
1.2 为什么Halcon里坚持"先出Region,再做后面一切"
Halcon的核心编程范式和其他视觉库不太一样,它的中间表示是Region和XLD轮廓,而不是直接操作像素矩阵。这样做有几个实打实的好处:
- 处理速度快。Region是压缩表示,形态学、面积计算都只需要扫描轮廓或游程段,比逐个像素快得多。
- 特征提取极其方便。一个
area_center、smallest_rectangle1就能拿到面积、重心、外接矩形,不用自己写循环。 - 显示调试直观。
dev_display直接把Region叠加在图上,红色边缘一圈,问题一眼就能看出来。
所以二值化的产出质量,直接决定了后续所有算子的输入质量。如果你把阈值选在了一个不明智的位置,后面做测量、做匹配都会带着系统误差,而且这种误差很隐蔽,不容易排查。这也是我为什么一直强调:二值化这步多花十分钟调明白,后面可能给你省下一整天。
2. 全局阈值:threshold算子与灰度直方图
2.1 threshold算子的参数细节与图像位深陷阱
先看最基础的算子:
* 把灰度值在MinGray到MaxGray之间的像素选出来 threshold (Image, Region, MinGray, MaxGray)这个算子逻辑简单到不能再简单:一行代码,按灰度区间切分图像。但实际用起来有几个容易翻车的点。
第一个坑是图像位深。工业相机里8位图灰度范围是0到255,但很多全局快门相机输出12位甚至16位图,灰度范围可能到0到4095或者更大。如果你拿8位图的阈值习惯去设置12位图的阈值,比如阈值设成threshold(Image, Region, 0, 200),你可能把整个目标都选不进来,因为目标的灰度可能分布在300到800之间。我建议拿到图像先做两步:用min_max_gray看实际灰度范围,或者干脆用convert_image_type统一转成8位再做后续处理。
第二个坑是彩色图没有"灰度"一说。read_image读进来的彩色图是多通道的,直接用threshold会报错或者只处理一个通道。老老实实先用rgb1_to_gray转灰度,如果彩色信息对分割有帮助,也可以先用decompose3拆通道,选对比度最好的那个通道来二值化。
2.2 直方图怎么看,阈值定在哪里最稳
理论上阈值应该选在灰度直方图的"谷底",也就是两个峰之间的最低点。比如背景灰度集中在180附近,目标灰度集中在40附近,那你把阈值定在90到120之间随便一个值,都能把两者分开。问题是现实中的直方图很少这么教科书,经常有噪声毛刺、光照渐变,导致谷底不明显。
我的调试习惯是先在Halcon的灰度直方图窗口里用交互工具手动拖动阈值范围,快速确认"大概哪个区间能把目标完整抠出来",然后再把具体的MinGray、MaxGray固化到代码里。同时配合gray_histo算子拿到区域的直方图数据:
* 先框出一个目标区域,再看这个区域的灰度分布 gray_histo (ROI, Image, AbsoluteHisto, RelativeHisto)如果直方图出现多峰,说明图里可能不止两类物体,这时候一段式全局阈值就不够用了,得考虑多阈值或者分区域处理。
2.3 全局阈值最大的隐患:光照一变就翻车
固定阈值最大的问题是鲁棒性差。某汽车零部件工厂的项目,白天和夜间的环境光会透过防护罩缝隙漏进设备,同一个产品同一套参数,上午检测良率97%,下午就跌到82%。原因就是全局灰度整体偏移了十几个灰度级,固定阈值90把一半的目标都切碎了。
这种问题的根治办法是用相对阈值而不是绝对阈值:要么用自动阈值,要么做背景差分(动态阈值)。我后面专门讲这两种方案。这里先记住一个结论:如果项目现场的光照条件不是严格恒定的,就别偷懒用固定阈值上线。
3. 自动阈值:binary_threshold与auto_threshold怎么选
3.1 binary_threshold的max_separability为什么最常用
Halcon里最常见的自动阈值算子是binary_threshold:
binary_threshold (Image, Region, Method, LightDark, UsedThreshold)Method参数常用两种:'max_separability'和'smooth_histo'。其中max_separability就是工业界熟知的Otsu大津法思路,核心思想是:遍历所有可能的阈值,找一个让"目标类内部灰度方差 + 背景类内部灰度方差"之和最小的点。换句话说,让分割后的两类各自内部越紧凑越好,这样两类之间的界限就最清晰。
这个方法的优势在于它不要求直方图有特别明显的双峰,只要两类灰度的整体分布能拉开,它就能算出一个相对合理的分割点。我在实际项目里,凡是光照相对稳定、目标占比不过分悬殊的场景,首选都是'max_separability'。用它还有个好处:输出参数UsedThreshold会给出算法实际选中的阈值。这个值别浪费,把它写到日志里,后面做参数监控和SPC分析都有用。
LightDark参数我多说一句:'dark'表示目标比背景暗,'light'表示目标比背景亮。新手最常见的错误就是把方向选反了,结果分割出来的是背景而不是目标。选完之后用dev_display看一眼边缘叠加,方向反了立刻就能发现。
3.2 smooth_histo和isodata各自适合什么场景
smooth_histo的思路是先对直方图做平滑,然后在两个峰值之间找最小点作为阈值。它对直方图的形态要求比较高,必须是明显的双峰分布才靠谱。什么时候用它?比如透明瓶子里装深色液体,液体和背景灰度差巨大,直方图两个峰隔得很远,用smooth_histo能稳定地把液体区域切出来。
isodata是一种迭代阈值法:先取一个初始阈值,把像素分成两类,计算两类的平均灰度,取平均值作为新阈值,反复迭代直到阈值不再变化。它的特点是计算简单,但收敛结果对初始值敏感。说实话,在Halcon的自动阈值里,isodata用的场景相对少,多数情况下max_separability已经够用了。我的建议是:不要贪多,把max_separability用熟,遇到双峰特别明显的再试smooth_histo就行。
3.3 auto_threshold的多区域语义
auto_threshold和binary_threshold有个本质区别:它不是一个阈值切一刀,而是通过平滑直方图后找零交叉点,把直方图按多个谷底切成多个灰度区间,每个区间生成一个Region,最后返回一个Region数组。Sigma参数控制直方图平滑程度,Sigma越大,直方图峰谷越少,分割出的区域类别越少。
什么场景需要它?一次分割出多个灰度层级的物体。比如药片检测,同一张图里既有白色药片、又有浅黄药片、还有深色杂质,用binary_threshold只能分出一类,而auto_threshold可以一次把三类都粗分出来,然后再分别做select_shape筛选。实际使用中我一般会先count_obj看看返回了几个Region,再逐个调select_shape,而不是指望一次分割完美。
4. 光照不均的解法:动态阈值与背景差分
4.1 dyn_threshold的工作原理
当光照不均匀、背景灰度本身不是一个稳定的平面时,全局阈值哪怕是自动阈值也不一定可靠。比如金属零件表面有弧面反光,同一块背景灰度能差出60个灰度级,这时候固定阈值切下去,要么背景残留,要么目标丢失。
dyn_threshold解决的是这个问题。它的原理说白了一句话:拿原图去和一个"背景估计图"做逐像素比较,差异超过Offset的像素才是目标。背景估计图通常就是原图做均值滤波或中值滤波后的结果。
mean_image (GrayImage, Mean, 21, 21) dyn_threshold (GrayImage, Mean, RegionDiff, Offset, 'dark')这里'dark'表示选出"原图比背景估计图暗Offset以上"的像素,也就是局部暗斑。'light'则相反,选亮斑。Offset是灵敏度调节:Offset越大,越难被选中,漏检越多;Offset越小,越容易选中,误检越多。它本质上是要求"目标与周围背景的差异必须足够大才认",所以对全局光照漂移不敏感。
4.2 背景估计的核尺寸怎么选
dyn_threshold的效果60%取决于背景估计图做得好不好。均值滤波的核宽(MaskWidth和MaskHeight)是个关键参数。我总结的经验是:核尺寸至少要达到目标尺寸的2到3倍。原因是如果核比目标还小,均值滤波会把目标自身的灰度也平均进背景估计图里,导致"背景"里也包含了目标,差分之后目标边缘信号被削弱,小缺陷就漏掉了。
下面是我常用的核尺寸参考:
| 目标缺陷直径 | 均值核尺寸 | 备注 |
|---|---|---|
| 2~3像素 | 11~15 | 用于细小划痕、针孔 |
| 5~10像素 | 21~31 | 常规表面缺陷检测 |
| 20像素以上 | 51~101 | 大块污渍、渐变暗斑 |
注意核尺寸只是起点,实际还要结合图像分辨率调整。如果缺陷边缘有模糊过渡,可以适当增大核,让背景估计更"钝",这样差分出来的目标边缘更完整。
4.3 Offset的调参顺序和判断方法
Offset是动态阈值里唯一需要手调的参数,也是大家最没把握的参数。我建议按这个顺序来:
- 先设一个偏大的Offset,比如20,跑一遍看结果。这时候能检出的肯定都是特别明显的暗斑或亮斑。
- 逐步减小Offset,比如从20降到15、10、8、6,每档都观察目标是否完整、是否开始出现明显噪点块。
- 找到"缺陷刚好完整、噪点还没大面积出现"的临界区间,取中间值固化为最终参数。
这里有个判断噪点的好办法:分割出来的Region一定会有很多小碎块,观察这些碎块是随机分布还是集中在某些固定区域。随机分布的碎块大概率是噪声,集中在特定位置的碎块要小心,可能是真实缺陷。最终筛选噪点的任务不需要全靠Offset完成,后面用select_shape按面积过滤是最常规的手段,别指望一步到位。
5. 分割之后的区域后处理与特征筛选
5.1 connection:把一团Region拆成一个个独立对象
阈值分割出来的结果通常是一个整体Region,它可能包含了几十个互不相连的目标。connection算子会把一个Region按连通性拆分成多个独立Region。Halcon默认按8邻域连通判断。这个算子是Blob分析的分水岭:拆分之后,每个Region才能被单独测量和评判。
connection (RegionBinary, ConnectedRegions) count_obj (ConnectedRegions, NumObjects)拆分之后第一件事就是count_obj,看一眼目标数量是否合理。如果数量爆炸,说明分割过碎;如果数量为零,说明阈值方向反了或者Offset太大。这个快速校验习惯能帮你省掉大量调试时间。
5.2 select_shape:特征筛选才是二值化真正发力点
拆分之后的每个Region都有几十个几何和其他特征,select_shape按特征范围筛出符合条件的Region。这才是二值化的威力所在:它不只是把图像分开,而是能在分割的基础上直接把"我要的目标"和"我不要的干扰"分开。
select_shape (ConnectedRegions, SelectedRegions, ['area', 'circularity'], 'and', [100, 0.3], [5000, 1.0])常用特征我整理了一张表:
| 特征名 | 含义 | 典型用途 |
|---|---|---|
| area | 区域面积(像素数) | 过滤小噪点和超大团块 |
| circularity | 圆度(圆为1,越不规则越小) | 筛选圆形缺陷或圆形目标 |
| compactness | 紧凑度(圆为1,越不规则越大) | 与circularity互补使用 |
| width / height | 外接矩形宽高 | 尺寸粗筛 |
| row / column | 重心坐标 | 区域定位 |
| holes | 孔洞数量 | 判断区域内部是否异常 |
| contlength | 轮廓长度 | 筛选边界粗糙的目标 |
注意新版Halcon(13以后)支持一次传入多个特征的元组写法,旧版则要多次调用select_shape逐个条件筛选。老项目迁移到新版本时,这里是最容易报语法错的地方之一。
5.3 形态学处理:开运算消噪点、闭运算填空洞
二值化后的Region不一定干净,常见两种毛病:目标内部有黑洞,目标周围有毛刺和孤立噪点。形态学就是为这两类问题而生的。
* 开运算:先腐蚀再膨胀,去掉小噪点和细毛刺 opening_circle (RegionBinary, RegionOpened, 3.5) * 闭运算:先膨胀再腐蚀,填平小空洞和缺口 closing_circle (RegionBinary, RegionClosed, 3.5) * 直接把所有孔洞填满,只保留外轮廓 fill_up (RegionBinary, RegionFilled)开运算对二值区域的效果可以理解为:所有比结构元素小的高亮噪声区域会被直接移除,同时区域的细小突出也会被磨平。闭运算则相反,补上比结构元素小的黑色空洞。实际项目里我的常用顺序是:先connection,再按面积粗筛一次,再对每个目标做开闭运算,最后再select_shape精筛。先连后处理的好处是像素级的形态学操作量少,跑得快。
还有个小技巧:fill_up会把所有孔洞都填上,但如果有些孔洞其实是真实缺陷特征(比如药片上的破损),就不能无脑填充,要用fill_up_shape按孔洞的尺寸特征来选择性地填充,或者干脆保留孔洞并把holes特征纳入筛选。
6. 完整实战:金属零件表面暗斑缺陷检测
6.1 场景描述与图像预处理
用一个我实际做过的项目来串一遍全流程:某机械加工厂需要对车削后的金属圆盘做表面检测,主要找两类缺陷:暗色氧化斑和细小划痕。成像条件是环形光源低角度照明,缺陷表现为比背景暗的区域,但由于零件表面是弧面,背景灰度并不均匀,从边缘到中心有渐变。
拿到图像后我不急着做阈值,先走三步预处理:
read_image (Image, 'metal_disk.png') rgb1_to_gray (Image, GrayImage) median_image (GrayImage, Median, 'circle', 3, 'mirrored')中值滤波选半径3的圆形核,目的是去掉传感器噪点和极盐噪声,同时尽量保留边缘的锐利程度。直方图看一眼,背景主要灰度在150到220之间,缺陷区域在60到130之间,分布有重叠区域,直接全局阈值风险不小。这种"背景渐变 + 目标低对比"的场景,正是动态阈值的适用区。
6.2 先提取ROI,再做动态阈值分割
因为每次拍摄零件的位置有小幅偏移,我不直接在整图上做动态阈值,先做一步ROI提取,把零件本体圈出来,再做后续处理。这样有两个好处:一是大幅减少计算量,二是避免背景支架、夹具边缘的干扰。
* 第一步:提取零件主体作为ROI(亮目标) threshold (Median, Workpiece, 120, 255) connection (Workpiece, WorkpieceConn) select_shape_std (WorkpieceConn, ROI, 'max_area', 70) reduce_domain (Median, ROI, ImageReduced) * 第二步:在ROI内部做背景差分,分割暗缺陷 mean_image (ImageReduced, Mean, 21, 21) dyn_threshold (ImageReduced, Mean, DefectsRaw, 8, 'dark')select_shape_std里的'max_area'直接选出面积最大的Region,这比用select_shape手写面积上下限更省心。reduce_domain把后续处理限制在ROI内部,这一步千万别省略,它能让程序运行速度快一个量级。
动态阈值这一步选均值核21、Offset为8,是根据缺陷尺寸约5到10像素、图像噪声标准差约3这个前提试出来的。如果Offset设成5,碎块暴增;设成15,浅划痕就检不出来了。8是在这一组样本上的折中值。
6.3 特征筛选、计数与结果显示
拿到原始缺陷Region之后,我按面积和圆度把真正的缺陷筛出来:
* 第三步:连通域拆分与特征筛选 connection (DefectsRaw, DefectsConn) select_shape (DefectsConn, Defects, ['area', 'circularity'], 'and', [20, 0.15], [5000, 1.0]) * 第四步:计数与结果显示 count_obj (Defects, NumDefects) dev_clear_window () dev_display (Image) dev_set_color ('red') dev_set_draw ('margin') dev_set_line_width (3) dev_display (Defects)筛选逻辑说明:面积下限20是为了去掉动态阈值残留的小碎点;圆度下限0.15是为了排除细长形油污擦痕(这类不算缺陷,是擦拭不干净的工艺问题)。上限5000和1.0基本是放开限制,防止区域过大或完全圆形导致误排除。
这套流程上线后,在样本集中的检出率达到98%以上,过检率控制在2%以内。整个程序从读图到输出结果单张耗时80毫秒左右,满足产线节拍。关键不是单张跑得多快,而是参数在30张不同光照、不同位置样本上都稳定,这才是能上线的底线。
7. 常见问题与排查经验实录
7.1 分割结果忽大忽小,今天一个样明天一个样
先查光源,再查参数。我遇到过最离谱的一次,是产线工人把环形光源的角度拧歪了3度,缺陷对比度直接掉了三分之一。这种问题用动态阈值能减轻,但不能完全消除。正确的排查顺序是:先固定相机增益和曝光时间,确认打光结构没有变化,再用一张标准样件跑一遍binary_threshold拿UsedThreshold,连续记录几天。如果阈值漂移超过15个灰度级,就说明物理硬件有变化,优先处理现场,而不是改代码。
7.2 二值化后碎片噪点太多
噪点多通常有四个修复方向:预处理加中值滤波、开运算抹除小区域、select_shape加面积下限、动态阈值把Offset调大。按这个顺序试,90%的噪点问题能解决。需要特别注意:不要为了去噪把Offset调得过大,导致真正的细小缺陷也丢失了。去噪优先级永远是"先保住真实目标,再压缩噪点"。
7.3 目标和背景灰度几乎重合,怎么都分不开
这种情况要回到采集端想办法。最常见的原因是光源选型不对,比如用同轴光打在透明或有纹理的物体上,目标与背景反射差异太小。换个思路:尝试不同波长的光源(红光、蓝光、红外)或者改变照射角度(高角度、低角度、背光),目标对比度往往立刻改善。如果采集端没法改,再考虑用emphasize或scale_image_max提升对比度,或者用彩色图拆分通道,找到对比度最好的单通道来二值化。分割问题能在采图端解决,就别硬扛算法端。
7.4 新旧版本Halcon算子写法不兼容
Halcon 13之后很多算子改成了元组参数风格,最大的坑就在select_shape和connection这类算子。旧代码是这样写的:
select_shape (Regions, Selected, 'area', 'and', 100, 5000)新版推荐写成:
select_shape (Regions, Selected, ['area'], 'and', [100], [5000])功能一样,但旧写法在部分新版本里会出现参数位数不匹配的警告。迁移老项目时,第一步就是把所有select_shape、select_gray这类多特征算子统一改成元组形式。另外read_image读取多通道图的行为在新旧版本也有差异,建议统一走rgb1_to_gray或decompose3,不要依赖默认行为。
最后分享一个我个人的工作习惯:调参时永远不要用一张图调到完美就收工。每换一组阈值,至少要在一批覆盖了光照变化、位置偏移、缺陷形态差异的样本上跑一遍统计,把误检率、漏检率都算出来。二值化这步看着简单,但真正让它变得可靠的是反复验证和参数落档。把这个基础功夫做扎实了,你的视觉项目才谈得上稳定交付。