做机器视觉这些年,白平衡(White Balance)是我被问得最多、也最容易被忽略的颜色处理问题之一。同一个工件,白天拍的样张和晚上拍的样张,色温一变,检测出来的颜色值就完全对不上;同一张彩色图像,在OpenCV里处理和在Halcon里处理,稍不注意通道顺序,结果就红蓝互换,看着像两张图。这篇文章我就以“OpenCV和Halcon分别实现彩色图像的白平衡效果”为主题,把灰度世界、完美反射这两种最常用的白平衡算法,在OpenCV和Halcon里的实现思路、完整代码、参数计算过程和工程里的坑一起讲清楚。适合刚接触图像处理、或者正在做工业视觉项目又需要统一图像色彩风格的朋友参考。
1. 白平衡的底层逻辑:为什么图像会偏色
1.1 色温、黑体辐射与人眼的白适应
要理解白平衡,先得理解色温。色温不是指画面“有多热”,而是描述光源光谱特性的物理量,单位是开尔文(K)。它源自一个思想实验:一个完全黑色的物体被加热时,会随着温度升高依次发出暗红、橙黄、白、蓝白的光。这个“黑体”发出的光的颜色,就对应了一个色温值。
- 低色温(约2800K~3500K),光源偏红偏黄,比如白炽灯、日出日落时刻;
- 中等色温(约5000K~6500K),光源接近中性白,比如正午阳光、闪光灯;
- 高色温(约7000K~10000K),光源偏蓝偏冷,比如阴天阴影处、蓝天反光。
物体本身没有颜色,它只是反射了光源中的一部分光谱。如果光源的色温不同,相机传感器上R、G、B三个通道收到的能量比例就会不一样。同样是白色纸张,在低色温光源下拍出来偏橙,在高色温光源下拍出来偏蓝。
人眼其实也有类似的问题,但大脑会自动“校正”,把白色知觉为白色,这就是白适应(Chromatic Adaptation)。然而CMOS/CCD传感器没有这个机制,所以必须在软件层面做一次白平衡,让图像中的“白色”重新变回白色。白平衡的本质,就是给R、G、B三个通道分别乘上不同的增益系数,让中性色的像素在三个通道上达到近似相等的响应。
1.2 灰度世界假设与完美反射假设
算法层面的白平衡,核心是“如何估计光源颜色”。最经典的两个假设就是:
灰度世界假设(Gray World):认为一幅色彩丰富的自然图像,其R、G、B三个通道的平均值应当趋于同一个中性灰度值。如果某个通道均值偏高,画面就偏这个通道的颜色,反过来把均值拉平即可。
完美反射假设(Perfect Reflector):认为图像中最亮的那些像素,对应的是场景中的白点或高光区域。这些像素的白平衡误差最明显,因此用最亮像素在三个通道上的响应值作为基准,将每个通道拉伸到最大值(比如255)。
这两个假设都不是绝对真理,都有失效场景。灰度世界假设在“整幅画面被一种颜色主导”(比如纯蓝色背景的零件)时会失效,完美反射假设在画面中没有真正的白色高光时会失灵。理解它们的适用边界,比会写代码更重要。
1.3 OpenCV的BGR与Halcon的RGB:第一个坑
动手之前必须先说通道顺序。OpenCV的彩色图像默认是BGR顺序存储的,也就是说使用cv2.split()得到的是b、g、r三个通道,如果用plt.imshow()直接显示OpenCV的BGR图像,人物会变成蓝脸关公。而Halcon内部图像模型的通道顺序是RGB,在HDevelop中读取彩色图后,decompose3出来的变量顺序就是R、G、B。
这两个库一旦同时出现在一个项目里(比如用Halcon调用工业相机采集,再转给OpenCV做算法),通道顺序就极容易写反。后面我会在踩坑章节专门讲这个,现在你只需要记住一句话:“OpenCV里先B后R,Halcon里先R后B。”
2. OpenCV实现白平衡:三种常用方案的完整代码与分析
2.1 代码环境与图像读取约定
本次OpenCV侧我用的是Python 3.9 + OpenCV 4.5.2,这也是目前最常用的组合。安装方面,直接使用pip安装即可。
pip install opencv-python==4.5.2如果你需要跑后面的完整示例,还需要numpy。
import cv2 import numpy as np src = cv2.imread('color_image.jpg') print("通道顺序:", cv2.split(src)[0].dtype, src.shape)所有演示代码都直接作用于BGR图像。为了统一结果可比性,我建议你把测试图缩放到统一尺寸,同时保存一张原图作为对照。
2.2 灰度世界算法:从公式到代码
灰度世界算法的数学表达很简单。假设图像I的三个通道均值分别为mean_b、mean_g、mean_r,目标灰度值为:
Gray = (mean_b + mean_g + mean_r) / 3那么每个通道的增益系数就是:
kb = Gray / mean_b kg = Gray / mean_g kr = Gray / mean_r最终校正图像为:
dst_b = clip(b * kb) dst_g = clip(g * kg) dst_r = clip(r * kr)这里的增益系数就是为了把三个通道的均值拉到同一个水平,相当于在整个通道上做了一次线性放大或缩小。需要特别注意的是,像素值必须做截断处理,防止结果超出0到255的范围。下面是完整实现:
def gray_world_balance(image): b, g, r = cv2.split(image) mean_b = np.mean(b) mean_g = np.mean(g) mean_r = np.mean(r) gray_level = (mean_b + mean_g + mean_r) / 3.0 kb = gray_level / (mean_b + 1e-6) kg = gray_level / (mean_g + 1e-6) kr = gray_level / (mean_r + 1e-6) b_bal = np.clip(b * kb, 0, 255).astype(np.uint8) g_bal = np.clip(g * kg, 0, 255).astype(np.uint8) r_bal = np.clip(r * kr, 0, 255).astype(np.uint8) return cv2.merge([b_bal, g_bal, r_bal])代码里的1e-6是为了防止某个通道均值恰好为0导致除零异常。实测下来,灰度世界算法对偏黄、偏蓝的图像校正效果非常明显,尤其是拍摄自然场景、多颜色工件时结果比较稳定。但如果你拍的是单一色块的物体,比如一块纯红色电路板,灰度世界会把红色通道大幅压低,结果反而偏青,这是灰度世界假设本身决定的,不是代码写错。
2.3 完美反射算法:最亮像素作为白点参考
完美反射算法的思路是:图像中最亮的点应该对应现实中的白色或高光区域,这些点的R、G、B值应当接近255。因此直接找出图像每个通道的最大值,或者更稳健地取一个高分位数,然后按比例拉伸。
低配版本的实现非常简单,直接除以通道最大值。但直接用max有个问题:只要有一个热噪点像素,整个通道就会被过度拉伸,所以我更推荐使用分位数方式,取99%分位值当作“最亮参考点”。这样可以避免单个异常像素的影响。
def perfect_reflector_balance(image, percentile_value=99): b, g, r = cv2.split(image) b_max = np.percentile(b, percentile_value) g_max = np.percentile(g, percentile_value) r_max = np.percentile(r, percentile_value) kb = 255.0 / (b_max + 1e-6) kg = 255.0 / (g_max + 1e-6) kr = 255.0 / (r_max + 1e-6) b_bal = np.clip(b * kb, 0, 255).astype(np.uint8) g_bal = np.clip(g * kg, 0, 255).astype(np.uint8) r_bal = np.clip(r * kr, 0, 255).astype(np.uint8) return cv2.merge([b_bal, g_bal, r_bal])关于分位数的选择,我的经验是:
- 99%适用于大部分自然图像,可以容忍1%的噪声点;
- 如果图像中本身有大面积高光,比如金属表面、白色外壳,可以降到95%,防止高光区域过度激活;
- 如果图像整体偏暗,可能需要提高到99.5%,否则参考点不够亮,校正力度不足。
完美反射算法在处理“偏暖但存在明显白色物体”的图像时效果极好。有一点需要提醒:取分位数时,务必先确认单通道的灰度值分布,不要把分位数小于50%的场景直接套用,否则拉伸完成后画面会整体过曝。
2.4 动态阈值法:灰度世界的稳健版
灰度世界算法有个常见改进版,常被叫做动态白平衡或“完美灰度世界”。它的思路是先将图像从BGR转换到YCrCb色彩空间,剔掉过亮和过暗的像素,再在剩余的“有效像素”上计算灰度世界增益。这样做的好处是,暗角和强高光区域不会干扰均值估计。
def dynamic_white_balance(image, low_threshold=5, high_threshold=250): ycrcb = cv2.cvtColor(image, cv2.COLOR_BGR2YCrCb) y, cr, cb = cv2.split(ycrcb) mask = (y >= low_threshold) & (y <= high_threshold) b, g, r = cv2.split(image) mean_b = np.mean(b[mask]) mean_g = np.mean(g[mask]) mean_r = np.mean(r[mask]) gray_level = (mean_b + mean_g + mean_r) / 3.0 kb = gray_level / (mean_b + 1e-6) kg = gray_level / (mean_g + 1e-6) kr = gray_level / (mean_r + 1e-6) b_bal = np.clip(b * kb, 0, 255).astype(np.uint8) g_bal = np.clip(g * kg, 0, 255).astype(np.uint8) r_bal = np.clip(r * kr, 0, 255).astype(np.uint8) return cv2.merge([b_bal, g_bal, r_bal])这里最关键的是mask的构建。YCrCb中的Y通道代表亮度,范围是0到255。low_threshold=5和high_threshold=250把纯黑和纯白的极端点排除在外,这些点要么是噪声,要么是过曝区域,对白平衡估计有害无益。该方法在OpenCV项目里实现简单,运行速度也不慢,是我个人在OpenCV侧的优先推荐方案。
2.5 三种算法选型对比
| 算法 | 原理假设 | 最佳适用场景 | 失效场景 | 处理速度 |
|---|---|---|---|---|
| 灰度世界 | 通道均值趋于灰度 | 自然场景、多色工件 | 单色主导画面 | 最快 |
| 完美反射 | 最亮像素应为白点 | 存在白色物体/高光 | 无高光或过曝严重 | 较快 |
| 动态阈值 | 灰度世界+剔除异常亮度 | 工业视觉、明暗不均 | 整体颜色单一且偏暗 | 中等 |
如果你是在项目早期阶段,不确定场景是否适合白平衡,我可以给你一个调试顺序:先跑动态阈值法看效果,再和灰度世界结果对比,如果两者差异不大,优先选动态阈值法,因为它的鲁棒性更好;如果差异很大,再检查图像中是否存在大面积单色物体,考虑是否需要针对特定ROI区域做白平衡,而不是全图处理。
3. Halcon实现白平衡:算子组合与灰度世界思路落地
3.1 Halcon图像处理的基本约定
Halcon不像OpenCV那样直接提供一个whiteBalance函数(至少在主流版本里没有内置的、开箱即用的“白平衡”算子,官方更倾向于让你通过基础算子组合实现),所以实现白平衡需要对Halcon的图像模型、通道分解、灰度统计和图像缩放类算子足够熟悉。
本节我以HDevelop 19.11/20.11版本为例编写代码。Halcon中:
- 彩色图像通道分解用decompose3,输出顺序是R、G、B;
- 通道组合用compose3,输入顺序同样是R、G、B;
- 灰度统计用intensity算子,可以得到区域内的灰度均值和偏差;
- 图像拉伸用scale_image算子,通过Mult和Add参数完成增益和偏移;
- 求最小最大值用min_max_gray或gray_histo。
3.2 基于灰度世界假设的Halcon实现
灰度世界在Halcon里的实现思路和OpenCV侧完全一致:分解通道、计算每个通道的灰度均值、求总平均值作为目标灰度、计算各通道增益、用scale_image缩放、最后合成彩色图像。
read_image (Image, 'color_image.jpg') get_image_size (Image, Width, Height) * 拆分成RGB三个通道 decompose3 (Image, R, G, B) * 获取各自通道的domain,避免histogram统计时的歧义 get_domain (R, DomainR) get_domain (G, DomainG) get_domain (B, DomainB) * 统计各通道灰度均值 intensity (R, DomainR, MeanR, DeviationR) intensity (G, DomainG, MeanG, DeviationG) intensity (B, DomainB, MeanB, DeviationB) * 目标灰度取三通道均值的中值 GrayLevel := (MeanR + MeanG + MeanB) / 3.0 * 计算增益系数 MultR := GrayLevel / MeanR MultG := GrayLevel / MeanG MultB := GrayLevel / MeanB * 对每个通道做增益缩放 scale_image (R, RBalanced, MultR, 0) scale_image (G, GBalanced, MultG, 0) scale_image (B, BBalanced, MultB, 0) * 重新合成彩色图 compose3 (RBalanced, GBalanced, BBalanced, ImageWB) write_image (ImageWB, 'jpg', 0, 'white_balanced.jpg')这里有几个Halcon特有的细节值得展开:
1. intensity算子的参数顺序。intensity(Image, Region, Mean, Deviation),第一个参数是灰度图像本身,第二个参数是统计区域。如果直接传同一张图作为Region,表示对全图domain统计,这在大部分情况下没问题,但为了明确表达意图,我用get_domain显式取了一次domain,这样在复杂ROI场景下更不易出错。
2. scale_image的Mult参数是增益倍数,不是目标灰度值。这是初学者最容易搞混的地方。scale_image的公式是dst = src * Mult + Add,所以MultR = GrayLevel / MeanR的含义是把这个通道的所有像素放大或缩小到“均值接近目标灰度”的水平。如果MeanR为200,GrayLevel为120,那么MultR就是0.6,像素值整体会被压暗。
3. 换行符问题。Halcon中如果表达式太长,可以使用\进行续行,比如:
GrayLevel := (MeanR + MeanG + MeanB) / 3.0这一行很短不需要续行,但如果你在写更复杂的表达式,记得在行尾加\,否则HDevelop会报语法错误。热词里有人搜索“halcon换行符号怎么设置”,大概率就是卡在这里。
4. 如果后续想对增益系数做进一步调试。可以将MultR、MultG、MultB转成元组并显示出来。用tuple_real可以直接确认系数类型是否为实数,避免后续在C#或者C++里传出时类型不一致。
tuple_real (MultR, MultR_real) tuple_real (MultG, MultG_real) tuple_real (MultB, MultB_real)3.3 基于完美反射假设的Halcon实现
完美反射在Halcon中的实现,核心是求每个通道的最大灰度值或高分位灰度值。Halcon里min_max_gray算子可以一次返回最小值、最大值和范围:
min_max_gray (R, DomainR, 0, 0, MinR, MaxR, RangeR) min_max_gray (G, DomainG, 0, 0, MinG, MaxG, RangeG) min_max_gray (B, DomainB, 0, 0, MinB, MaxB, RangeB)Percent参数设为0表示严格取最大最小值;如果担心噪点,可以设成1或2,表示忽略两端1%或2%的像素后再找极值。我这里为了兼顾通用性和稳定性,建议设成1:
min_max_gray (R, DomainR, 1, 0, MinR, MaxR, RangeR) min_max_gray (G, DomainG, 1, 0, MinG, MaxG, RangeG) min_max_gray (B, DomainB, 1, 0, MinB, MaxB, RangeB) MultR := 255.0 / MaxR MultG := 255.0 / MaxG MultB := 255.0 / MaxB scale_image (R, RBalanced, MultR, 0) scale_image (G, GBalanced, MultG, 0) scale_image (B, BBalanced, MultB, 0) compose3 (RBalanced, GBalanced, BBalanced, ImageWB)Perfect Reflector在Halcon中的实现比OpenCV更简洁,因为min_max_gray天然支持分位数设置。你如果希望参考点不取最高值,而是取某个分位数,直接调整第一个Percent参数即可。这个参数在中文资料里很少被讲透,我实际用过之后觉得这是Halcon在这类算法上比OpenCV更顺手的原因之一。
3.4 借助HSV色彩空间做色偏判断与扩展
Halcon在处理彩色图像时,除了RGB通道分解,还经常用HSV空间做颜色判断。HSV空间把色相、饱和度、亮度分开,对于判断“图像是否偏色”非常直观。如果只做白平衡,你可以先转换到HSV,把饱和度通道低于某个阈值的像素挑出来当作中性灰区域,再回到RGB去计算这些区域内的通道均值,这样做出的白平衡更贴近真实场景。
* 从RGB转换到HSV trans_from_rgb (R, G, B, H, S, V, 'hsv') * 根据饱和度阈值生成区域 threshold (S, LowSatRegion, 0, 30) * 在原图R/G/B通道上分别统计低饱和区域内的均值 intensity (R, LowSatRegion, MeanR, _) intensity (G, LowSatRegion, MeanG, _) intensity (B, LowSatRegion, MeanB, _) GrayLevel := (MeanR + MeanG + MeanB) / 3.0 MultR := GrayLevel / MeanR MultG := GrayLevel / MeanG MultB := GrayLevel / MeanB scale_image (R, RBalanced, MultR, 0) scale_image (G, GBalanced, MultG, 0) scale_image (B, BBalanced, MultB, 0) compose3 (RBalanced, GBalanced, BBalanced, ImageWB)注意,如果原图中饱和度偏低的像素分布在阴影、暗角里,此时直接统计反而会引入误差。建议在使用HSV方案前,先用灰度阈值或亮度阈值过滤掉太暗的像素,再和低饱和度区域求交集。这一招在工业场景里做“局部白平衡”特别管用,比如只针对某个表面上均匀材质做颜色校正,不影响其他区域。
4. OpenCV与Halcon白平衡的效果对比与工具选型
4.1 均匀光照下的效果对比
我拿同一张偏黄的室内测试图分别跑了两边的灰度世界算法。OpenCV侧最后输出的图像和Halcon侧输出的图像,单看视觉观感几乎一致:原本偏黄的地砖和白色桌面都回到中性色。但如果用Python读取两张图逐像素计算平均绝对误差(MAE),你会发现两种实现之间存在少量差异。
差异来源主要有两个。第一个是uint8截断时机不同:OpenCV的np.clip发生在乘法之后,Halcon的scale_image在内部处理截断,具体实现可能有细微差异。第二个是四舍五入规则不同,某些像素通道值会在0、255边界附近差1。这一点差异在肉眼层面完全可以忽略,但在做“算法一致性验证”时会被QA揪出来。
如果你想做到两边输出逐像素一致,我建议统一策略:先在浮点域完成所有增益计算,最后一次性转回uint8并截断。Halcon里可以用convert_image_type把图像转成'real'类型做浮点运算,计算完成后再用convert_image_type转回'byte'并做饱和截断。
* 先转浮点型再做增益,而不是直接对byte类型做scale_image convert_image_type (R, RReal, 'real') scale_image (RReal, RBalancedReal, MultR, 0) convert_image_type (RBalancedReal, RBalanced, 'byte')这样虽然多了两次类型转换,但结果更容易与OpenCV侧对齐。
4.2 偏色严重的场景对比
在严重偏色场景(比如白炽灯下的暖黄色画面)中,两边的灰度世界算法表现很接近,都能把色偏拉回来。但在严重偏蓝的阴影场景中,完美反射算法往往会放大蓝色通道的暗部噪声,OpenCV侧和Halcon侧在暗部区域都会出现彩色斑点。Halcon侧可以通过median_image(中值滤波)或bilateral滤波先做降噪,再做白平衡,效果会更好。
此外,Halcon的scale_image在浮点型图像上处理时不会自动饱和,因此如果增益系数过大,中间结果会超过1.0(或255.0),最终转换回byte时暗部细节可能会丢失。这一点在偏色严重时比OpenCV更需要注意,OpenCV的np.clip是强截断,反而更不容易出现风吹草动。
4.3 性能与开发效率对比
图像尺寸取1920x1080的彩色图:
- OpenCV的灰度世界算法,纯Python实现大约需要20-30ms,而用NumPy矩阵批量运算可以压到8ms以内;
- Halcon的灰度世界实现,由于算子经过高度优化,在同尺寸图像上大约能跑到5-10ms。
Halcon在底层算子上做了大量SIMD优化,同样的功能如果直接用C++重写OpenCV代码,性能会接近,但Halcon胜在开发效率高,不需要关心内存管理、多线程这些底层细节。OpenCV的优势在于生态丰富、社区资料多,尤其是和深度学习模型配合时,Python环境下调试白平衡参数非常灵活。
4.4 实际项目里的选型建议
我的项目经验给出的选型建议是这样的:
如果项目是纯软件、纯算法验证,用Python + OpenCV最舒服,白平衡参数可以随时调整,可视化方便。如果项目最终要部署到工业现场的Windows工控机,并且现场大量使用Halcon的相机采集、模板匹配、测量算子,那我建议白平衡用Halcon实现。理由不是性能,而是整个流程统一在Halcon里,后续交接给现场调试工程师时,维护成本最低。
如果项目必须两个库混用,比如Halcon负责读取相机图像和显示,OpenCV负责深度学习推理,那么白平衡放在Halcon侧做更好。因为相机采到的原始图像直接进Halcon,避免先从Halcon转到OpenCV、做完白平衡再转回Halcon的多次格式转换损耗。如果一定在OpenCV侧做,切记把BGR顺序问题处理好。
5. 实际踩坑记录与工程化经验
5.1 Halcon图像转到OpenCV时的通道顺序错乱
这是跨库协同里最经典的坑。Halcon的HObject转换为OpenCV的Mat,网上常能搜到C#或C++的转换代码。但转换之后很多人直接用imshow显示,发现红色蓝色颠倒。原因就是Halcon内存里存的是RGB,OpenCV Mat默认解释为BGR。解决方案有两个:
- 在Halcon侧先把通道顺序调整成BGR再转出;
- 在OpenCV侧拿到Mat后用cv2.cvtColor(..., cv2.COLOR_RGB2BGR)转一次。
我强烈建议在Halcon侧调整。因为如果图像管线中后续还有其他Halcon算子,保持Halcon内部的RGB一致更合理;只在交接给OpenCV的那一瞬间做通道换位。
* 在Halcon侧生成BGR顺序的临时图像,用于传给OpenCV compose3 (BlueChannel, GreenChannel, RedChannel, ImageBGR)5.2 白平衡参数在视频流里的闪烁问题
如果对视频流的每一帧独立计算灰度世界均值,你会发现一个非常恼人的现象:白平衡增益系数每一帧都在轻微抖动,画面高光区域会出现肉眼可见的闪烁。原因很简单,均值估计受噪声影响,帧与帧之间光源略有波动,增益系数也会跟着小幅波动。
解决办法是给增益系数加时间平滑。最常见的是指数滑动平均(EMA):
balance_kb_smooth = alpha * balance_kb_current + (1 - alpha) * balance_kb_smoothalpha取值在0.1到0.3之间。工业相机帧率通常15fps到60fps,alpha取0.2左右,画面既跟得上颜色变化,又不会剧烈闪烁。Halcon侧你也可以通过“在像素级上对目标灰度做平滑”的方法,或者干脆固定白平衡增益,只在换产线或切换光源时才重新标定,这样效果最稳定。
5.3 特定工业场景下白平衡并非越多越好
这是我最想强调的一点。白平衡的目标是让中性色还原为中性色,但工业检测里很多产品的颜色本身就是黄色的,比如PCB板、铜箔、某些塑料外壳。如果你用灰度世界算法对整个画面做白平衡,反而会把原本正常的黄色产品“校”成灰色或蓝色,导致后续色差判断全部跑偏。
有一个实用技巧:在ROI区域内做白平衡,而不是全图。比如产品固定出现在图像中心,就只在中心区域做白平衡估计,用中心区域算出的增益应用到整幅图上。再比如你可以选用固定色卡的白色块来做白平衡,采图时把标准色卡放在画面边缘,只统计白色块的三个通道均值作为参考基准,这样产品本身的颜色不会被误校正。
5.4 与深度学习结合的预处理心得
最近很多项目把白平衡放在深度学习检测之前,这个做法方向是对的。统一白平衡可以缩小训练集和测试集之间的颜色分布差异,模型收敛更快,泛化能力更强。但这里有个容易忽略的问题:训练阶段如果用了白平衡,推理阶段也必须用完全相同的白平衡算法,连参数都不能变。否则模型的输入分布一旦变化,精度会明显下降。
我的经验是尽量把白平衡做到“确定性”而不是“自适应”。固定增益模式比每帧动态估计模式对深度学习更友好。训练数据里可以做一些色温增强,模拟不同光源下的输入,提升模型的鲁棒性。这样即使现场光源波动,模型也不会因为颜色分布偏移而崩掉。
我个人在实际项目中的习惯是:先把Halcon侧的灰度世界版本写出来跑通,再用OpenCV的Python脚本做批量测试,用几十张不同色温的现场图对比效果。等确认算法没问题后,再把OpenCV侧的结果当作基准,回到Halcon侧做参数标定和性能优化,最后部署到产线。如果你现在正卡在“某个库到底怎么写白平衡”或者“为什么两边跑同一张图结果不一样”这个问题上,希望这篇文章能帮你少走几步弯路。