1. 为什么我把“图像预处理”单独拆成一份笔记
如果你也做过视觉检测项目,大概率遇到过这种情况:程序在实验室里跑得好好的,一到产线就误检率飙升;换一个光源角度,原本稳定的阈值分割直接失效。我之前做过一个金属表面划痕检测的项目,反复调了三天分割参数,最后发现根因根本不是分割算子,而是图像预处理环节出了问题。那之后我重新翻开《HALCON机器视觉与算法原理编程实践》第5章,把图像预处理从头到尾梳理了一遍,才有了这篇笔记。
这一章表面上是讲算子,实际上讲的是“如何把一个raw图变成机器视觉算法真正想要输入的图”。在HALCON的检测流程里,图像预处理是连接采图和分割的桥梁。它解决的不是“图好不好看”的问题,而是后续Blob分析、边缘提取、模板匹配是否稳定的问题。这篇笔记适合三类人:正在用HALCON做视觉检测的工程师,刚学完基础算子想系统整理预处理链路的初学者,以及被光照噪声折腾到想放弃的调参党。我会把算子选型、参数思路和实际踩过的坑一起写出来,你可以直接对照自己的项目去复现。
1.1 一次误检倒逼我重读第5章
那次项目检测的是金属拉丝表面的微小凹坑。最初我的流程特别简单:read_image之后直接threshold,再用select_shape筛面积。结果凹坑区域边缘大量断裂,同一凹坑经常被拆成两三块,面积特征完全不可靠。后来我试着在阈值分割之前加了一步中值滤波,再配合opening_circle整理区域边缘,误检率立刻降了一个量级。
这件事给我的冲击很大。同样是那些算子,为什么位置换一下就差这么多?说到底,图像预处理不是“可有可无的优化”,而是视觉流水线的地基。第5章里把预处理分成灰度转换、对比度增强、平滑去噪、形态学修正几个模块,我当时只看了算子说明,没把它们串成链路,落地时自然出问题。
1.2 第5章到底在处理哪些问题
我在重读时给自己列了一个问题清单:图像灰度范围是否合理?目标与背景的对比度是否足够?图像噪声是否会干扰边缘定位?阈值分割后的区域是否有孔洞和毛刺?这四个问题正好对应预处理的核心任务。书本的顺序是从灰度变换讲到滤波再到形态学,我学完之后更习惯把它倒过来理解:先想清楚下游需要什么样的图像,再决定上游用哪些算子。
所以在后来的项目里,我给自己定了五个固定步骤:
- 裁剪ROI并灰度化;
- 统一图像类型和灰度量程;
- 处理光照不均和对比度不足;
- 滤波去噪同时尽量保留边缘;
- 用形态学整理分割后的区域。
这份学习笔记就按这条主线展开,和课本的章节顺序不完全一样,但是更贴近我实际调程序的思考路径。
1.3 预处理链路先于算子记忆
第5章里会出现十几个算子,如果逐个背参数,很快就乱了。我的做法是先记住“链路的形状”,再把算子填进去。你想让视觉程序稳定,预处理必须让目标区域在灰度、几何、边缘三个层面都变得“可预测”。
灰度层面靠scale_image、直方图均衡来解决,几何层面靠滤波和形态学来解决,边缘层面靠平滑和锐化的配合来解决。这条链路走通之后,后端的threshold和select_shape往往不需要反复折腾。
2. 灰度化与图像类型转换:所有预处理的第一步
2.1 彩色图转灰度:选对通道比选对算子重要
工业相机大部分输出彩色图,但HALCON里很多测量算法最终处理的还是灰度图。直接用rgb1_to_gray(Image, GrayImage)是常规做法,它会按亮度加权公式把RGB三通道合成一个灰度值,比简单平均更符合人对亮度的感知。
但“常规做法”不代表“最优做法”。我在一个检测红色印刷字符的项目里发现,直接用rgb1_to_gray之后,红字和深蓝底板的灰度非常接近,阈值分割根本分不开。后来我用decompose3把原始图拆成R、G、B三个通道,只看R通道,字符和背景的对比度一下子就拉开了。
read_image(Image, 'part.png') decompose3(Image, R, G, B) scale_image_max(R, R_ScaleMax)如果你能确定目标特征主要靠某个颜色通道表达,就不要急着合成灰度,直接挑通道往往比任何增强都有效。特别是彩色滤光片成像、彩色标定场景,通道分离应该是写进预处理模板的第一步。
2.2 图像类型统一:byte还是uint2?
HALCON支持byte、uint2、int2、real等多种图像类型。很多滤波和形态学算子对类型有要求,或者在不同类型下计算行为不一样。我个人的习惯是:在正式进入预处理前,把图像统一成byte类型。
有人会问,16位精度不是更高吗,为什么要转成8位?原因是后续的直方图操作、threshold灰度范围、以及大量开发调试工具都以byte为基准,8位图处理起来更快,内存占用也更低。如果相机是12位深度的工业相机,一定要先做灰度映射再转类型,否则直接把uint2截断成byte会丢失暗部细节。
* 相机输出uint2,12位有效灰度范围0-4095 scale_image(Image16, ImageScaled, 255.0 / 4095.0, 0) convert_image_type(ImageScaled, ImageByte, 'byte')scale_image的Mult参数相当于斜率,Add参数相当于截距。这里把0-4095线性映射到0-255,再执行类型转换,就不会出现灰阶合并或丢失暗部信息的情况。
2.3 ROI裁剪:预处理不该全图做
预处理最容易被忽略的一步是reduce_domain。很多初学者把整张图输入滤波算子,其实大图的背景区域会严重拖慢处理速度,背景里的无关纹理还可能干扰中值滤波的排序结果。正确做法是先用一个固定ROI把感兴趣区域圈出来,再对ROI内的图像做预处理。
reduce_domain(Image, ROI, ImageROI)这一步不会改变像素值,只是缩小了后续算子的计算域。从效率角度看,一个5k乘5k的大图,如果ROI只占四分之一,滤波耗时通常能下降一大截,而且ROI外的噪点不再污染灰度统计。
3. 对比度增强:scale_image和直方图均衡的正确打开方式
3.1 为什么不能无脑用scale_image_max
刚学HALCON的时候,我最喜欢用scale_image_max,因为一行代码就能让图像灰度拉满,看起来“更清楚”。但实际项目里这个算子很容易坑人。它会把整张图的全局最小值和最大值当作映射边界,一旦画面里有一个反光点或者暗角,全局极值就被这个异常点带偏。
举个例子,目标区域灰度正常分布在120到140之间,全图中有一个高光噪声点的灰度是260。scale_image_max会把260映射成255,于是120到140之间的目标灰度差被压缩得非常小,原本肉眼可见的划痕在增强后反而看不清了。也就是说,全局极值并不是真实目标的灰度范围,用它做线性拉伸等于把噪声当成参考标准。
我在后来的项目里改用ROI统计的方式:先在干净的ROI上统计灰度范围,或者手工从直方图上取5%到95%的灰度分位点,再用scale_image做线性映射。
min_max_gray(ROI, Image, Min, Max, Range) Mult := 255.0 / (Max - Min) Add := -Mult * Min scale_image(Image, ImageScaled, Mult, Add)这种方式的映射边界来自目标区域本身,而不是来自某个偶然的高光点。如果ROI里依然有椒盐噪声,我会先做一步中值滤波,再统计灰度范围。
3.2 scale_image参数背后的线性映射公式
scale_image(Image, ImageScaled, Mult, Add)的数学本质是灰度线性变换,输出灰度等于输入灰度乘Mult再加Add。理解了这一条,调参就不需要瞎试了。
假设你想把灰度范围[min, max]映射到[0, 255],那么:
Mult = 255.0 / (max - min)Add = -Mult * min
更通用的场景是,我想把目标灰度稳定映射到某个中间区间,避免背景被判成目标。那就可以用黑白参考样的灰度值来反推Mult和Add。这种方法在生产环境里特别有用。设备调试好后,相机光源位置固定,图像灰度分布是相对稳定的,我一般会把Mult和Add固定下来,而不是每帧自适应。自适应增强在演示时好看,但在产线上会让不同批次同一个灰度范围产生不同的归一化结果,后续阈值就会跟着漂。
3.3 直方图均衡的适用边界
equ_histo_image是我调试图像时很喜欢用的算子,它能让灰度直方图在一帧内尽量摊开,把集中在暗区的信息分散到整个灰度空间。但它是一把双刃剑。
直方图均衡基于整幅图的灰度累积分布,如果目标区域占整图面积很小,直方图主要由背景主导,那么均衡化会把背景的亮度等级细致拉开,目标区域反而被压缩到很窄的灰度范围内。这种场景下做了均衡,目标对比度可能更差。所以我在实战里把它当作“快速预览工具”而不是“正式生产算子”。如果只给客户看增强效果,用equ_histo_image很直观;如果是做稳定测量,最好还是用固定参数或者ROI统计的scale_image。
equ_histo_image(Image, ImageEquHisto)这句话写起来很简单,但它背后的非线性映射会让灰度值失去可重复性。对我来说,预处理链路里的每一步都应该“可解释、可复现”,直方图均衡在这方面有明显短板。
3.4 光照不均场景的预处理组合拳
很多产线项目最头疼的问题不是噪声,而是光照不均匀。中间亮、边缘暗,或者产品表面本身有高低起伏导致反光不均。这时候全局阈值肯定做不好。我常用的方案是背景差分加动态阈值。
先用一个较大窗口的均值滤波估计背景亮度,再用原图减去背景,把背景拉平到统一的灰度水平。
mean_image(Image, Background, 51, 51) sub_image(Image, Background, ImageCorrected, 1, 128)这里窗口尺寸要远大于目标尺寸,否则会把目标本身的灰度也抹进背景里。窗口大小的估算公式是“目标最大宽度的两到三倍以上”。比如目标直径50像素,窗口至少要101×101。减去背景后再加128,是为了让灰度回到中间值附近,方便后续固定阈值分割。
如果光照问题比较复杂,我还会用dyn_threshold替代固定threshold。它比较的是原图和一张平滑图的灰度差,对局部光照变化有天然抗性,但前提是平滑窗口必须大于目标尺寸,否则目标会被平滑掉。动态阈值是预处理链路的延伸,适合把“图预处理”和“分割策略”打通来用。
4. 滤波去噪:均值、中值和高斯的选型逻辑
4.1 三种滤波在数学本质上的差异
图像滤波本质上是让每个像素的值参考周围像素重新计算。HALCN里长常用的三个方向分别是mean_image、median_image和smooth_image(...,'gauss',...)。三者的数学逻辑完全不同,用错场景等于白做。
- 均值滤波:用一个n×n窗口做卷积,窗口内所有像素等权相加再平均。它对高斯噪声有效,但会把边缘模糊成过渡带,边缘定位精度下降明显。
- 中值滤波:对窗口内像素排序后取中间值,对椒盐噪声、脉冲噪声特别有效。它几乎没有均值滤波那种线性模糊,边缘的位置能保住,但窗口太大会抹掉细小特征。
- 高斯滤波:窗口内权重按到中心点的距离呈高斯分布衰减,是一种更“柔和”的均值滤波。它的边缘保留能力比均值好一些,适合后续做边缘提取前使用。
我把三者的差异整理成一个对照表:
| 算子 | 噪声模型 | 边缘保留 | 计算量 | 典型场景 |
|---|---|---|---|---|
| mean_image | 高斯噪声 | 一般 | 低 | 快速平滑 |
| median_image | 椒盐/脉冲噪声 | 较好 | 中 | 去孤立噪点 |
| smooth_image('gauss') | 高斯噪声 | 较好 | 中 | 边缘提取前平滑 |
4.2 HALCON算子的Mask参数和边界处理
HDevelop里这三个算子写起来都很短,但参数细节决定了效果。均值滤波直接给定窗口宽高:
mean_image(Image, ImageMean, 3, 3)中值滤波可选掩膜形状,圆形掩膜在各方向上的响应更均匀,方形掩膜更容易保留水平垂直边缘。我默认用圆形:
median_image(Image, ImageMedian, 'circle', 2, 'mirrored')高斯平滑用smooth_image,FilterType传'gauss',后面的MaskSize一般用3或5,再大就很少有实际意义:
smooth_image(ImageMedian, ImageGauss, 'gauss', 3, 'mirrored')最后那个'mirrored'参数是边界处理方式。成像边缘的像素缺少邻居,直接填0会在图像四周生成一圈黑边,后续阈值分割会在边界处误检。'mirrored'是镜像反射来补齐边缘,对图像内容延续性更友好。我在所有滤波算子里都统一用'mirrored'。
4.3 我实测下来的参数边界
滤波参数最常踩的坑是“核尺寸宁大勿小”。实际经验恰恰相反,应该宁小勿大。3×3的均值或高斯,对多数工业图已经够用;5×5留给噪声确实明显的场景;7×7只有在暗光、高ISO噪声爆表时才考虑。
中值滤波的Radius参数尤其危险。如果最小缺陷宽度只有3个像素,Radius设成2(相当于5×5窗口),缺陷可能直接被排序结果淹没。有一次我在检测PCB上的细小划痕,划痕宽度大约2像素,中值滤波Radius用2,结果划痕全部消失。换成Radius 1之后,划痕立刻回来了。
这里有个简单的经验公式:结构元素的等效直径要小于最小目标特征尺寸。比如最小划痕宽3像素,中值滤波的窗口就不能超过3×3,否则这个划痕在统计意义上“不占多数”,会被当作噪声排掉。滤波和特征提取天生是一对矛盾,我们需要捏着这个边界去选参数。
4.4 锐化和滤波的位置关系
滤波平滑了噪声,也同时模糊了边缘。解决方式是滤波之后加一步锐化,把丢失的高频边缘分量补回来。HALCON的emphasize算子可以在保留平滑结果的同时增强边缘对比度。
emphasize(ImageGauss, ImageSharp, 3, 3, 1.2)注意,锐化必须放在滤波之后,不能反过来。如果先锐化,噪声的高频分量会被放大,后面再滤波也救不回来。这个顺序就像先清理桌面再画线,桌面不平整,画出来的线也会歪。
现在这套组合已经成了我的标准模板:中值滤波去脉冲噪声,高斯滤波去高斯噪声,最后一步emphasize恢复边缘。三步参数从小到大调整,稳定性和效果都比较可控。
5. 形态学预处理:让二值区域更干净的收尾操作
5.1 阈值分割之后为什么还要做形态学
很多纯新手会以为threshold输出区域后就可以直接测量了。但凡做过真实产品都会知道,threshold之后经常出现三种问题:目标边缘有毛刺,内部有孔洞,背景噪点被误判成小碎片。这些问题不是分割阈值错了,而是图像预处理还不够彻底。形态学操作在HALCON里处理的是Region,不是灰度图,所以我习惯把形态学看作“二值域上的预处理”。
比如一个塑料卡扣表面有细密纹理,阈值分割后目标边缘像锯齿一样,直接测宽度会忽大忽小。用一次开运算,再用一次闭运算,边缘立刻就顺滑了,测量结果也不再跳变。
5.2 我常用的形态学算子串
我的标准模板是这样的:
threshold(Image, Region, 0, 90) connection(Region, ConnectedRegions) select_shape(ConnectedRegions, SelectedRegions, 'area', 'and', 200, 99999) opening_circle(SelectedRegions, RegionOpening, 2.5) closing_circle(RegionOpening, RegionClosing, 3.5)connection是为了把连通域拆开,不然多个目标连成一个区域,后续筛选无从谈起。select_shape用面积筛掉小的噪声连通域,这一步比形态学更能处理“孤立噪点”。opening_circle先腐蚀后膨胀,可以去掉边缘的毛刺和细长突起;closing_circle先膨胀后腐蚀,可以填平内部细小孔洞。两件事一先一后,区域就干净了。
为什么不是只做开运算或只做闭运算?因为实际图像往往是开放问题同时存在:既有多余的突起,也有不该有的空洞。先开再闭是处理这类噪声的标准顺序,能保证在去除外部毛刺之后再把内部破洞补回来。
5.3 结构元素尺寸如何估算
形态学结构元素的半径不能拍脑袋。我一般采用一个简单约束:噪声尺寸小于半径,最小目标特征尺寸大于两倍半径。假设相机标定后一个像素是0.02毫米,产品表面的纹理噪点大约0.1毫米,目标最窄特征0.6毫米。那么:
- 噪点折算5像素;
- 目标最小特征折算30像素;
- 结构元素半径取5到15像素之间,比较安全。
我会先试Radius=6的开运算,如果噪点还没清干净就逐步增加,但不会超过15,否则会把目标本身切开。滤波和形态学其实是一家人,选参数的逻辑都围绕“特征尺寸和噪声尺寸的比值”展开。
6. 预处理结果验证与调参避坑清单
6.1 用灰度直方图而不是肉眼验证
很多工程师调预处理时全靠眼睛看,觉得图像“看起来清晰”就往下走。但眼睛对灰度差的感知是非线性的,不同显示器亮度和对比度设置也会骗人。我建议每次调整完预处理后,第一时间调出灰度直方图来验证。
get_domain(Image, Domain) gray_histo(Domain, Image, AbsoluteHisto, RelativeHisto)如果目标与背景在直方图上呈现两个清晰的峰,说明后续阈值分割有充足空间。如果直方图只有一个宽峰,或者目标峰和背景峰严重重叠,就不要急着调threshold,回头检查一下预处理链路是不是缺少了对比度增强或滤波。直方图是客观指标,肉眼不是。
6.2 预处理顺序为什么不能乱
我整理过的预处理顺序可以浓缩成下面这张表:
| 阶段 | 典型算子 | 目的 |
|---|---|---|
| ROI与灰度化 | reduce_domain, rgb1_to_gray, decompose3 | 缩小计算范围,突出主特征 |
| 类型统一 | scale_image, convert_image_type | 保证后续算子输入类型稳定 |
| 去噪 | median_image, smooth_image | 去除噪声,避免增强时被放大 |
| 对比度增强 | scale_image, sub_image | 拉大目标与背景灰度差 |
| 分割 | threshold, dyn_threshold | 生成二值区域 |
| 形态学整理 | opening_circle, closing_circle | 去毛刺,补空洞 |
这个顺序不是随便定的。先增强后去噪,会把噪声一起增强;先分割后整理,毛刺就会干扰面积和宽度测量。滤波去噪需要放在对比度增强之前,因为线性增强对噪声一视同仁,噪声也会被放大。
6.3 常见算子坑与规避
整理一下我实际踩过和帮别人排查过的坑:
scale_image_max对全图极值敏感,异常高光点会毁掉整个线性拉伸,必须换成ROI统计或固定参数。mean_image窗口不要用偶数,一般用3、5、7这类奇数值,保证窗口中心有明确的对应像素。- 滤波边界默认填0会在图像四周形成黑边,统一用
'mirrored'边界处理。 dyn_threshold的Offset不是随便选的,它应该大于噪声的灰度振幅。可以先在平坦区域取一块ROI,统计灰度标准差,Offset取三倍标准差左右。connection要放在select_shape之前。如果两个目标通过一个噪点桥接在一起,不拆连通域就直接筛面积,会把两个目标误判成一个。- 形态学半径一旦超过最小特征尺寸的一半,目标就会被破坏,这是个硬约束。
6.4 我的调参顺序建议
最后分享一个调参习惯,不一定适用所有人,但对我自己的项目帮助很大。我不会一上来就直奔最终效果,而是把预处理拆成两个阶段反复切换:先看全局渲染图,再逐ROI看细节。具体操作是先用默认参数把链路跑通,记录每个阶段的中间结果,然后从最后一步往前调。
原因很简单,越往后的算子对最终结果影响越直接。如果形态学半径已经吃掉了目标,前面的滤波再精也没用。先固定分割阈值,再调滤波,最后调对比度增强,每一步都用write_image保存中间产物,出了问题能很快定位是哪一层引入的。
这本书第5章我前后读了三遍,每一遍的理解都不太一样。第一遍记住算子名字,第二遍看懂参数,第三遍才真正意识到预处理是一条有顺序的流水线。顺序一旦理顺,后面的分割和测量算子其实不太需要反复折腾。如果你现在正在被误检问题折磨,不妨先别急着调检测算法,回头看看自己的预处理链路是不是在某一步断了。