简介:这份资源围绕Halcon图像清晰度计算展开,面向工业视觉、机器视觉方向的开发者与自动化工程师,用于解决相机自动对焦中图像质量量化评估的问题。内容涵盖方差法、拉普拉斯能量函数法、能量梯度函数法与Brenner函数法四种评价思路,并给出基于evaluate_definition自定义评判函数的实现方式,通过遍历不同焦距图像、比较返回值大小定位最清晰帧,可应用于工业相机对焦、视觉测量与图像预处理等场景。资源包共1个docx文件,约22KB,以文档形式集中整理算法原理与代码示例,便于对照查阅与二次开发。目前已有6159人学习下载,适合希望快速掌握清晰度评价函数选型与对焦流程的读者参考借鉴。
1. 从一组失焦的 PCB 图说起:Halcon 清晰度评价到底在算什么
产线上有台相机拍 PCB,镜头是手动变焦的,操作员得拧着对焦环来回找最清楚的位置。人眼判断这事特别玄学——盯着屏幕看久了,觉得哪张都差不多,换个人来调结果又不一样。更麻烦的是,同一批板子换了个工位、换了光照,之前调好的焦距又得重来。这个项目要解决的就是把"哪张最清楚"从人眼主观判断变成可量化的数值,用 Halcon 写一个清晰度评价函数,对一组连续拍摄的失焦到合焦图像逐张打分,分数最高的那张就是最清晰的。
项目本身不复杂,核心是一个evaluate_definition函数,内部实现了方差法、拉普拉斯能量函数法、能量梯度函数法、Brenner 函数法和 Tenegrad 函数法五种评价算子,通过Method参数切换。配套的主程序读取 Halcon 自带的pcb_focus序列图(121 张,从模糊到清晰再到模糊),循环调用评价函数,把每张图的清晰度值收集起来,最后用直方图可视化,并自动定位峰值对应的那张图。适合正在做自动对焦、图像质量筛选、或者需要给视觉系统加一道"图像合格性检查"的开发者参考。
2. 五种评价函数的实现拆解:从方差到 Tenegrad 的算子选择
2.1 统一入口与预处理:scale_image_max 和图像尺寸获取
evaluate_definition的签名很简洁:输入一张图和一个 Method 字符串,输出一个 Value 数值。所有方法共用两步预处理——scale_image_max把灰度拉伸到 0-255 满量程,get_image_size拿到宽高供后续裁剪用。灰度拉伸这步容易被忽略,但如果输入图像本身对比度低,不拉伸直接算梯度或方差,数值会偏小,不同方法之间的可比性也会变差。
* 统一预处理:灰度拉伸 + 获取尺寸 scale_image_max(Image, Image) get_image_size(Image, Width, Height)scale_image_max的作用是把图像灰度线性映射到最大范围,相当于自动对比度增强。对于清晰度评价来说,这步保证了不同曝光条件下拍到的图能在同一尺度上比较。Width和Height在后面能量梯度法和 Brenner 法里用来做crop_part的边界控制。
2.2 方差法:像素分布离散程度的直接度量
方差法的逻辑最直白——清晰图像的边缘锐利,像素值在边缘两侧跳变大,整体分布更分散,方差自然更大。实现上先求图像均值,再算每个像素与均值差的平方,最后用intensity算子取均值。
if (Method = 'Deviation') * 方差法:像素值离散程度越大越清晰 region_to_mean(Image, Image, ImageMean) * 求全图均值 convert_image_type(ImageMean, ImageMean, 'real') * 转实数避免溢出 convert_image_type(Image, Image, 'real') sub_image(Image, ImageMean, ImageSub, 1, 0) * 像素减均值 mult_image(ImageSub, ImageSub, ImageResult, 1, 0) * 差值平方 intensity(ImageResult, ImageResult, Value, Deviation) * 取均值即方差 endifregion_to_mean把整张图当作一个区域求平均灰度,输出一张所有像素都是均值的图。sub_image的1, 0是缩放和偏移参数,这里保持原值。mult_image做逐像素平方。最后intensity返回的Deviation就是方差。这个方法计算量小,但对光照变化敏感——如果图像整体偏暗,方差会被拉低,所以前面的scale_image_max很关键。
2.3 拉普拉斯能量法:边缘强度的平方和
拉普拉斯算子对图像做二阶微分,边缘处响应强烈。这里同时用了 4 邻域和 8 邻域两种拉普拉斯核,把结果叠加后平方求和。
elseif (Method = 'laplace') * 拉普拉斯能量:4邻域和8邻域核叠加后平方 laplace(Image, ImageLaplace4, 'signed', 3, 'n_4') * 4邻域拉普拉斯 laplace(Image, ImageLaplace8, 'signed', 3, 'n_8') * 8邻域拉普拉斯 add_image(ImageLaplace4, ImageLaplace4, ImageResult1, 1, 0) * 4邻域结果乘2 add_image(ImageLaplace4, ImageResult1, ImageResult1, 1, 0) * 再加一次,共3倍 add_image(ImageLaplace8, ImageResult1, ImageResult1, 1, 0) * 叠加8邻域 mult_image(ImageResult1, ImageResult1, ImageResult, 1, 0) * 平方 intensity(ImageResult, ImageResult, Value, Deviation) endiflaplace的'signed'表示保留正负号,3是滤波核尺寸,'n_4'和'n_8'分别指定邻域模式。4 邻域核只考虑上下左右,8 邻域核还包含对角方向。代码里 4 邻域结果被加了三次(自身一次、再加两次),8 邻域加一次,相当于给 4 邻域更高权重。这个权重分配不是固定的,实际调的时候可以根据图像纹理方向调整。平方操作放大了强边缘的贡献,让清晰图像的得分和模糊图像拉开差距。
2.4 能量梯度法与 Brenner 法:相邻像素差值的两种用法
能量梯度法用水平和垂直方向的相邻像素差来度量梯度,Brenner 法只算水平方向隔一个像素的差。两者结构相似,区别在差分方向和步长。
elseif (Method = 'energy') * 能量梯度:水平+垂直方向相邻像素差 crop_part(Image, ImagePart00, 0, 0, Width-1, Height-1) * 左上区域 crop_part(Image, ImagePart01, 0, 1, Width-1, Height-1) * 下移一行 crop_part(Image, ImagePart10, 1, 0, Width-1, Height-1) * 右移一列 convert_image_type(ImagePart00, ImagePart00, 'real') convert_image_type(ImagePart10, ImagePart10, 'real') convert_image_type(ImagePart01, ImagePart01, 'real') sub_image(ImagePart10, ImagePart00, ImageSub1, 1, 0) * 水平差 mult_image(ImageSub1, ImageSub1, ImageResult1, 1, 0) * 平方 sub_image(ImagePart01, ImagePart00, ImageSub2, 1, 0) * 垂直差 mult_image(ImageSub2, ImageSub2, ImageResult2, 1, 0) * 平方 add_image(ImageResult1, ImageResult2, ImageResult, 1, 0) * 合并 intensity(ImageResult, ImageResult, Value, Deviation) elseif (Method = 'Brenner') * Brenner:水平方向隔一个像素的差 crop_part(Image, ImagePart00, 0, 0, Width, Height-2) * 上半部分 convert_image_type(ImagePart00, ImagePart00, 'real') crop_part(Image, ImagePart20, 2, 0, Width, Height-2) * 右移两列 convert_image_type(ImagePart20, ImagePart20, 'real') sub_image(ImagePart20, ImagePart00, ImageSub, 1, 0) * 隔像素差 mult_image(ImageSub, ImageSub, ImageResult, 1, 0) * 平方 intensity(ImageResult, ImageResult, Value, Deviation) endifcrop_part的参数是(输入图, 输出图, 起始行, 起始列, 宽度, 高度)。能量梯度法裁出三个区域:基准区、下移一行、右移一列,分别做垂直和水平差分。Brenner 法只裁两个区域,列偏移为 2,对应"隔一个像素"的差分。注意 Brenner 的crop_part高度是Height-2,因为列偏移了 2,边界要留够。这两种方法对噪声比拉普拉斯法更敏感,如果图像有椒盐噪声,得分会被噪声带偏,实际用的时候前面加个中值滤波会更稳。
2.5 Tenegrad 法:Sobel 梯度加阈值筛选
Tenegrad 用 Sobel 算子算梯度幅值,然后设一个阈值把弱边缘滤掉,只保留强边缘做平方和。这个阈值筛选是它和其他方法最大的区别。
elseif (Method = 'Tenegrad') * Tenegrad:Sobel梯度 + 阈值筛选强边缘 sobel_amp(Image, EdgeAmplitude, 'sum_sqrt', 3) * Sobel梯度幅值 min_max_gray(EdgeAmplitude, EdgeAmplitude, 0, Min, Max, Range) threshold(EdgeAmplitude, Region1, 11.8, 255) * 阈值分割 region_to_bin(Region1, BinImage, 1, 0, Width, Height) * 区域转二值图 mult_image(EdgeAmplitude, BinImage, ImageResult4, 1, 0) * 掩膜 mult_image(ImageResult4, ImageResult4, ImageResult, 1, 0) * 平方 intensity(ImageResult, ImageResult, Value, Deviation) endifsobel_amp的'sum_sqrt'表示梯度幅值用平方和开根号计算,3是核尺寸。threshold的阈值 11.8 是个经验值,针对 PCB 图像调的——太低会把噪声算进去,太高会漏掉弱边缘。region_to_bin把筛选出的区域转成二值掩膜,和梯度图逐像素相乘,只保留强边缘的梯度值。这个方法在纹理丰富的图像上表现好,但如果图像本身边缘就少(比如均匀表面),阈值可能把所有边缘都滤掉,得分趋近于零。
3. 主程序流程:从 121 张序列图到清晰度峰值定位
3.1 图像读取与循环评价
主程序的核心是一个 121 次的循环,每次读一张图,调用评价函数,把返回值追加到数组里。
* 主循环:逐张读取并评价 Ret := [] get_image_size(Image, Width, Height) for Index := 1 to 121 by 1 read_image(Image, 'pcb_focus/pcb_focus_telecentric_' + Index$'03d') evaluate_definition(Image, 'Brenner', Value) * 切换Method看不同效果 dev_display(Image) Ret := [Ret, Value] endforIndex$'03d'是 Halcon 的格式化字符串语法,把数字补零到三位,拼出001到121的文件名。Ret初始化为空元组,每次循环用[Ret, Value]追加。这里Method传的是'Brenner',改成'Deviation'、'laplace'、'energy'或'Tenegrad'就能切换评价函数。实际跑的时候建议把五种都跑一遍,对比同一组图的得分曲线——不同方法对同一张图的敏感度不一样,有的曲线尖锐,有的平缓。
3.2 归一化与直方图可视化
拿到 121 个得分后,做 min-max 归一化映射到 0-100,再用gen_region_histo生成直方图区域。
* 归一化并生成直方图 VMax := max(Ret) VMin := min(Ret) GRet := 100 * (Ret - VMin) / (VMax - VMin) * 映射到0-100 gen_region_histo(Region, Ret, 255, 255, 1) * 生成直方图区域max和min是 Halcon 的元组算子,直接对数组操作。GRet算出来其实没在后面用到,但归一化这步在需要跨序列比较时有用——比如不同批次的图,原始得分范围可能差很多,归一化后才能用同一个阈值判断。gen_region_histo的255, 255, 1控制直方图的显示范围和缩放,生成的是一个区域对象,可以直接dev_display叠加在原图上。
3.3 峰值定位与最清晰图像提取
最后用find找到最大值的位置,读出对应的原图显示。
* 找峰值并显示最清晰图 qxd := find(Ret, max(Ret)) * 返回最大值索引 read_image(GoodImage, 'pcb_focus/pcb_focus_telecentric_' + qxd$'03d') dev_display(GoodImage) dev_display(Region)find返回的是元组中第一个匹配值的索引。注意 Halcon 元组索引从 0 开始,而文件名从 1 开始,这里qxd直接拼进文件名,实际对应的是第qxd+1张图。如果序列图是从 1 编号的,这个偏移会导致差一张——不过对于找最清晰帧来说,差一张通常不影响结论,但如果要做精确的焦距标定,这个索引偏移得修正。dev_display先显示原图再叠加直方图区域,直方图的峰值位置和图像清晰度峰值在视觉上能对应起来。
4. 避坑与排查:跑清晰度评价时容易翻车的几个点
4.1 现象:所有图的得分都很接近,曲线几乎是平的
原因通常是图像本身对比度太低,或者scale_image_max没起作用。如果原始图像灰度集中在很窄的范围内,方差法和梯度法的区分度都会下降。解决方法是先检查输入图像的灰度直方图,确认scale_image_max之后的灰度范围确实被拉开了。如果还是平,考虑换用对对比度更敏感的 Tenegrad 法,或者手动加一个emphasize增强边缘。
4.2 现象:Brenner 法报错,提示裁剪区域超出图像边界
Brenner 法的crop_part列偏移是 2,高度参数写的是Height-2,但如果图像本身高度很小(比如小于 3 像素),或者Width和Height获取时机不对,就会越界。检查get_image_size是否在scale_image_max之后调用——scale_image_max不改变尺寸,但如果在循环外获取尺寸而循环内图像被替换过,尺寸可能不一致。稳妥做法是在evaluate_definition内部每次重新获取尺寸。
4.3 现象:Tenegrad 法得分异常高或异常低
阈值 11.8 是针对特定 PCB 图像调的。如果换一组图,边缘梯度幅值的分布变了,这个固定阈值就不适用。得分异常高通常是因为阈值太低,噪声被当成边缘算进去了;异常低是因为阈值太高,真实边缘被滤掉了。解决方法是把threshold的阈值改成自适应——先用min_max_gray拿到梯度幅值的范围,再按比例设阈值,比如取最大值的 10%。
4.4 现象:切换 Method 后得分量级差很多,没法直接比较
五种方法的输出量纲不一样:方差法的值可能在几百,Brenner 法可能在几万。这是正常的,因为它们的数学定义不同。不要试图跨方法比较绝对数值,而是看同一方法下不同图像的相对趋势。如果确实需要跨方法比较,先做 min-max 归一化再比。另外,intensity返回的Deviation是标准差还是均方根,取决于输入图像的类型,real 类型下返回的是标准差,这点在文档里没写清楚,实测确认一下。
4.5 现象:峰值对应的图看起来并不是最清晰的
可能原因有两个:一是序列图的清晰度变化不是单调的,存在多个局部峰值,find只返回第一个;二是评价函数对某些类型的模糊不敏感,比如运动模糊和离焦模糊的梯度特征不一样。解决方法是把Ret数组和对应的图像索引一起打印出来,人工核对峰值附近几张图。如果确实有多个峰值,用sort排序后取前几个候选,再结合其他指标(比如图像熵)做二次筛选。
5. 二次开发:把评价函数接进自己的对焦流程
这个项目的评价函数是纯 Halcon 实现,没有外部依赖,移植到自己的项目里只需要把evaluate_definition整个复制过去,然后在需要的地方调用。但直接复制粘贴只是第一步,真正要接进自动对焦流程,还得解决几个工程问题。
第一个是实时性。121 张图离线跑没问题,但产线上相机每秒出几十帧,每帧都调一次评价函数,计算量得控制住。方差法和 Brenner 法计算量最小,适合做粗对焦;拉普拉斯和 Tenegrad 计算量大但精度高,适合在粗对焦找到大致范围后做精调。我一般会做一个两级策略:先用 Brenner 快速扫一遍找到峰值附近,再用 Tenegrad 在峰值附近的小范围内细评。
第二个是评价函数的参数自适应。Tenegrad 的阈值、拉普拉斯的邻域权重、Brenner 的差分步长,这些参数在不同场景下需要调整。我的习惯是把这些参数做成外部配置,而不是硬编码在函数里。比如用一个字典存{'Tenegrad': {'threshold': 11.8}, 'Brenner': {'step': 2}},调用时传进去。这样换产品换工位的时候不用改代码,改配置就行。
第三个是评价结果的稳定性。单帧评价容易受噪声影响,实际用的时候会对连续几帧的得分做滑动平均,或者用中值滤波去掉突变。如果相机支持硬件触发,最好在曝光稳定的时间段内采样,避免自动曝光调整过程中得分跳变。
下面是一个把评价函数封装成可配置调用的示例,用 Halcon 的字典和元组操作实现:
* 可配置的评价函数调用封装 * ConfigDict: 字典,键为Method名,值为参数字典 * 返回:归一化后的清晰度得分(0-100) evaluate_definition_config(Image : : Method, ConfigDict : Value) scale_image_max(Image, Image) get_image_size(Image, Width, Height) * 从配置字典读取参数,缺省用默认值 get_dict_param(ConfigDict, Method, 'threshold', Threshold) if (Threshold == []) Threshold := 11.8 endif * 根据Method分发到具体实现 if (Method = 'Tenegrad') sobel_amp(Image, EdgeAmplitude, 'sum_sqrt', 3) threshold(EdgeAmplitude, Region1, Threshold, 255) region_to_bin(Region1, BinImage, 1, 0, Width, Height) mult_image(EdgeAmplitude, BinImage, ImageResult4, 1, 0) mult_image(ImageResult4, ImageResult4, ImageResult, 1, 0) intensity(ImageResult, ImageResult, Value, Deviation) elseif (Method = 'Brenner') crop_part(Image, ImagePart00, 0, 0, Width, Height-2) convert_image_type(ImagePart00, ImagePart00, 'real') crop_part(Image, ImagePart20, 2, 0, Width, Height-2) convert_image_type(ImagePart20, ImagePart20, 'real') sub_image(ImagePart20, ImagePart00, ImageSub, 1, 0) mult_image(ImageSub, ImageSub, ImageResult, 1, 0) intensity(ImageResult, ImageResult, Value, Deviation) endif return ()这个封装的关键改动是把阈值从硬编码变成从字典读取,get_dict_param在键不存在时返回空元组,用if (Threshold == [])兜底。实际项目里我会把配置字典从 JSON 文件加载,这样调参不用重新编译 Halcon 程序。
验证改造是否成功的方法很简单:拿同一组序列图,用原始硬编码版本和配置化版本各跑一遍,对比Ret数组是否完全一致。如果一致,说明封装没引入逻辑变化;如果不一致,检查参数读取的默认值是否和原来相同。这个对比测试我每次改评价函数都会跑一遍,花不了几分钟,但能避免很多"改了参数忘了同步"的低级错误。
从那以后我每次接新的对焦项目,都会先把这五种评价函数在同一组图上跑一遍,看哪种方法的得分曲线最尖锐、峰值最明确,再决定用哪个做主力。没有哪种方法是万能的,方差法在低纹理表面会失效,Tenegrad 在噪声大的图上会误判,Brenner 对垂直边缘不敏感——选型这件事,实测比理论推导靠谱。希望帮到你。
本文还有配套的精品资源,点击获取