☰
从亮度预测色度:一种解构颜色恒常性问题的深度学习新思路
2026/10/14 11:20:14 网站建设 项目流程

第一次看到这个论文标题时,我愣了一下。“Color Constancy by Learning to Predict Chromaticity from Luminance”——颜色恒常性这个方向,这几年被深度学习翻来覆去地做,大家习惯性地把整张RGB图像扔给网络,让它直接回归光照颜色。而这篇文章偏偏绕了个弯:先把亮度(Luminance)单独拎出来,然后让网络从亮度图预测色度(Chromaticity)。这个操作初听有点反直觉,亮度里没有颜色信息,怎么能预测出颜色?但仔细想,光照变化在亮度通道里留下的痕迹,恰恰是颜色恒常性任务里最稳定的线索之一。

这篇文章讲的不是“又堆了一个新网络”,而是换了一个解构问题的角度:把“估计光照颜色”拆成“从亮度推断色度”这一步,用回归的思路把看似无解的耦合关系拉成可学习的映射。如果你做过白平衡算法,或者被自动白平衡在复杂光源下的偏色问题折磨过,这篇文章的思路会给你一个很不一样的参考。

下面我把整个思路从头拆开,边读边补实现细节,最后给出一套可以照着跑的复现方案和排坑记录。

1. 颜色恒常性到底在解决什么问题

1.1 人眼和相机在“看颜色”上的根本差异

先说个每个人都经历过的现象:在暖黄色的灯泡下看书,白纸看起来还是白的;走到室外阴天,同一张白纸看起来也还是白的。人眼会自动把光源的颜色“扣除”,让我们感知物体本身的颜色。这种能力叫颜色恒常性,是视觉系统的一种底层自动校正机制。

相机就没有这种机制。传感器记录的是场景反射光进入镜头后的物理数值,暖光下拍出来的白纸就是发黄的,阴天拍出来就会发蓝。所以相机需要额外的算法来做“白平衡”,也就是用软件去模拟人眼的自动校正能力。颜色恒常性(Color Constancy)这个任务,本质上就是给定一张图像,计算拍摄时的光照颜色,然后把偏色掰回来。

这里面有一个很经典的形式化表达。把图像上每个点的像素值看作物体表面反射率与光照光谱的乘积,去掉复杂的高阶反射,可以写成:

I_c(x) = R_c(x) * L_c

其中 I_c 是第 c 个颜色通道(R、G、B)的像素值,R_c(x) 是那个位置的表面反射率,L_c 是全局光照在三个通道上的强度。这个式子当然是简化过的,没有考虑互反射、镜面反射、空间变化光照,但它已经抓住了问题核心:我们只拿到 I,想要把 L 和 R 拆开。

只要 L 估计得够准,就能通过一个对角矩阵把图像校正到标准光照下,这一步就是白平衡里最常用的 Von Kries 对角校正。所以颜色恒常性问题,不管前面的网络怎么搭,最终几乎都会落成“估计全局光照颜色 L”这个回归任务。

1.2 一个看起来简单、做起来扎心的数学问题

为什么这个任务这么多年都没被彻底解决?因为从数学上看,它本质上是一个病态问题。给定单个像素点(R,G,B)的值,你根本无法判断这到底是“红色物体在白色光下”还是“白色物体在红色光下”。同一个像素值可以对应无穷多种“反射率-光照”组合,约束严重不足。

这也是经典方法全部依赖全局统计假设的原因。灰度世界假设(Gray World)认为场景里所有颜色的平均值是灰色,所以把图像RGB均值当作光照的反色;白斑算法(White Patch)假设场景里存在纯白色区域,于是取每个通道的最大值。这些假设在特定场景下有效,一旦场景里大面积是单一颜色,或者光源比较复杂,假设就崩了。

深度学习方法之所以能后来居上,是因为它通过学习大量数据中隐含的统计规律,隐式地给这个病态问题加上了“先验约束”。但哪怕是这样,深度学习也需要回答一个问题:输入图像里到底用哪些信息来做光照估计最有效?

大多数方法给出的答案是:直接用整张图的RGB像素。这在逻辑上没毛病,因为颜色信息全在RGB里。但问题也在这里——RGB图像里同时包含了物体颜色和光照颜色,网络要在一个耦合信号里把光照分量“拎”出来,学习难度很高。这篇文章选择把RGB拆成亮度+色度,只让网络从亮度出发,实际上是主动把“物体颜色信息”这个干扰源给屏蔽了一部分,逼迫网络去学习光照在亮度通道里留下的那种更本质的模式。

1.3 一个被低估的中间层能力

颜色恒常性不只是学术圈自嗨。手机相机拍夜景、室内氛围灯下的人像、工业视觉里的颜色检测、自动驾驶在日落时对交通灯和路面的判断,全都依赖这一步。算法偏色直接导致画面整体泛黄、泛蓝,后续无论做人脸检测还是语义分割,效果都会跟着崩。所以这个方向每年都有大量论文在做,工业界需求也一直没断过。

这篇文章真正有意思的地方,不在于它又刷高了多少个点的指标,而在于它提出来的“用亮度预测色度”这个中间层表示,让网络不再直接输出一个三通道光照向量,而是先学习把亮度和色度之间的映射关系建模出来。这个思路在工程上也有实用价值:很多ISP管线上已经有很好的亮度信号,可以直接复用,不用临时改硬件或重新拉一套RGB统计模块。

2. 核心思路拆解:为什么从亮度预测色度是行得通的

2.1 传统方法的基本盘:统计假设与物理先验

先回顾一下传统方法,因为不理解传统方法就很难理解这篇论文的动机。

灰度世界家族的方法,核心就是假设场景平均反射率是灰色。这个假设对色彩丰富的户外场景表现不错,但当你拍一面纯红色的墙,或者一个篮球,算法就会把红色误当成光照偏红,然后在校正阶段疯狂加青色。这是均值假设的硬伤。

灰边缘(Grey Edge)这一系引入了更高阶的信息,它假设场景中边缘处的颜色导数是灰色的,也就是说颜色突变处的通道差服从灰度分布。这个方法在纹理丰富的场景里表现更好,但在平滑表面或强噪声下也不稳定。

物理派方法会用光照几何模型做约束,比如考虑镜面高光的分布,或者利用双色反射模型来分离漫反射和镜面反射分量。这类方法对场景要求较高,在受控工业环境里效果不错,但拿到日常用户随手拍的图像上,鲁棒性很难保证。

这些方法的共同点是:用某种固定统计量直接计算光照,无法根据场景自适应调整。图片里出现一只橙色猫和一片橙色墙壁,对这些方法来说没有本质区别—它们都在做“统计→猜测”的推导。

2.2 深度学习方法的主流进路

到了深度学习阶段,大家的做法基本变成了:把图像缩放成固定尺寸(比如 224×224或512×512),直接输入卷积网络,输出一个三通道向量作为光照估计,然后计算这个向量与真实光照的角度误差作为损失函数。这种端到端的学习方式,靠大量成对数据把“图像→光照颜色”的映射学出来,效果确实比传统方法强不少,尤其是对复杂光源场景的适应能力更好了。

这个路线本身没什么问题,但有一个可以琢磨的细节:输入的是RGB三通道,输出是光照RGB,中间所有特征都在做同一件事——在颜色维度上做解耦。网络需要学的是“去掉物体颜色,留下光照颜色”,这个任务在RGB空间里表达比较绕,因为RGB三个通道高度相关,且对光照变化的响应是非线性的。

于是就有一些工作尝试改变输入表示,比如把图像转成色彩直方图,或者用色度分布作为输入特征。这篇文章选择了一种更极致的解构方式——直接把两个色度通道去掉,只留亮度,然后要求网络从亮度中预测出色度。这意味着网络必须从“空间结构和亮度分布的模式”里找到光照颜色的线索,而不是从“像素本身的颜色”里找。

2.3 “亮度预测色度”的三个直觉依据

为什么亮度里藏着光照色度的信息?这是全篇最核心的问题,结合成像原理和实验观察,我认为有三个层面的依据支撑。

第一,光照颜色会影响各个通道的响应比例,而这种影响会改变亮度的统计分布结构。假设场景里有多种反射率的表面,在不同色温光照下,虽然物体的可见颜色在变,但亮度值的变化模式会带有光照色度的痕迹。比如偏蓝光照会导致蓝色通道响应被整体放大,这对亮度的梯度分布和通道间比值会有可观测的影响。

第二,场景的几何结构其实包含了光源信息。高光、阴影、边缘方向这些特征在亮度图上保留得比较完整,而光照方向与颜色某种程度上是耦合的。网络完全可以从亮度图的边缘分布、高光位置、阴影软硬等线索中推断出光源的冷暖倾向。

第三,亮度图的信噪比更高。RGB三个通道在弱光下噪声水平不同,单独使用某个通道容易放大噪声,而亮度通道是三个通道的加权融合,等效信噪比更高。用亮度作为输入,网络学到的特征更稳定,对传感器噪声和JPEG压缩伪影的鲁棒性也更好。

这三点不是论文里明确列出来的推导,而是我结合成像模型和复现经验补出来的解释。理解到这一层,后面的网络结构设计和训练技巧就有方向了。

3. 关键细节解析:网络结构设计背后的逻辑

3.1 输入表示与色度监督

先明确输入输出。输入是单通道亮度图,输出是两个通道的色度坐标(或加一个归一化操作后的二维向量)。色度的定义一般取:

r = R / (R + G + B) g = G / (R + G + B)

这样色度坐标天然满足 r + g + b = 1,只不过 b 可以由前两个推出来,所以回归两个通道就够了。

从RGB图算亮度,常用做法是加权求和:

L = 0.2126 * R + 0.7152 * G + 0.0722 * B

这组系数是标准亮度公式,符合人对亮度的感知权重。复现时可以直接用 OpenCV 的 cvtColor 转灰度,也可以自己用矩阵乘法做,效果差别不大。要注意的是图像必须先用线性化RGB,也就是去掉伽马校正,否则亮度关系不线性,后面学出来的映射会有偏差。

3.2 为什么输出色度而不是直接输出光照RGB

选择输出色度而非完整光照向量,是一个很实用的设计决策。原因有二:

一是色度天然与亮度尺度无关。两个光照颜色向量如果只是亮度大小不同,色度坐标是一样的。这等于把“光照有多亮”这个信息主动扔掉,网络只需要关心“光照是什么颜色”。也就是说,网络不需要同时回归亮度和颜色两个维度,回归目标更简单,收敛压力更小。

二是便于做归一化和度量。色度坐标都在[0,1]范围,输出层加一个 Sigmoid 直接限制范围,比回归三通道任意实数稳定得多。而最终评估用的角度误差本质上也只关心光照方向,不关心大小,所以色度监督与角度误差指标是自洽的。

实际实现时,可以再加一层归一化,例如把网络输出的二维向量看成二维角度,通过三角函数映射或直接归一化模长来约束方向,这比单纯回归两个独立数值更符合色度的几何意义。

3.3 损失函数的选择与坑

如果你直接对这个任务用 MSE 损失,训练过程会非常不稳定。原因在于色度坐标的数值范围虽然只有0到1之间,但角度误差对颜色方向非常敏感,一点微小的色度偏差在低色度区域会被放大成很大的角度差。

我更推荐的训练方案是组合损失:

L = L_angular + λ * L_mse

其中角度误差损失为:

L_angular = arccos( (gt · pred) / (|gt| * |pred|) )

MSE 损失用来稳定训练早期,避免梯度振荡;角度损失负责让预测方向精确对齐。λ 取0.1到0.5之间,先要跑几个小实验确定。

还有一个容易踩的坑:反三角函数的定义域。如果 gt · pred / (|gt| * |pred|) 因为数值问题跑到1.0000001,arccos 会返回NaN。一般做法是在计算前做一次 clip 操作,把值裁剪到[-1, 1]。我在第一次复现训练的时候就被这个NaN坑过,整整半天loss曲线都是空的,后来才发现是clip忘写了。

3.4 网络结构选型:不是越深越好

这类任务跟图像分类不一样,不需要从头到尾把语义信息层层抽象,而是要保留空间统计信息。我的建议是用一个中等深度的卷积网络,全连接层不要太多。

一种比较稳的基线结构是:

  • 输入:224×224×1 的亮度图
  • 卷积块1:64通道,3×3卷积,步长为2,后接BN和ReLU
  • 卷积块2:128通道,3×3卷积,步长为2
  • 卷积块3:256通道,3×3卷积,步长为2
  • 全局平均池化
  • 全连接1:512维,ReLU
  • 输出层:2维,Sigmoid

这个结构参数量不大,单卡就能训练,关键是全局平均池化之前特征图的空间信息保留得够好。如果你要刷高指标,可以换用现成的轻量分类网络去掉分类头,只取特征提取部分,前面加一个灰度输入适配层就行。

还有一个值得尝试的小技巧:在卷积特征提取阶段使用 DenseNet 风格的拼接结构,把浅层亮度边缘特征和深层统计特征连接起来。虽然文章标题没有明确指出这一点,但在复现这类“小输入信息量”任务时,密集连接能有效缓解梯度消失,也更利于网络同时利用局部边缘和全局统计两类线索。

4. 复现实操:从数据准备到训练评估

4.1 数据集与预处理流程

公开可用的带真实光照标签的数据集就那么几个,复现时建议选一个规模适中的作为主实验集。数据格式一般是原始图像加一个光照颜色向量,基准测试时常用角度误差的中位数、平均值、最大值作为指标。

拿到数据后,要按顺序处理:

  1. 线性化:很多公开数据提供的是RAW线性图,但如果拿到的是sRGB图,先做反伽马变换。
  2. 黑电平扣除与归一化:把像素值归一化到 0~1。
  3. 下采样:分辨率降到 224×224,这个尺寸已经够用。
  4. 灰度变换:用标准亮度公式把RGB转成单通道亮度图。
  5. 标签变换:把光照RGB向量转成色度坐标(r,g),同时保留原始光照向量用于最终评估。

这里有一个细节很容易被忽略:训练时输入的是灰度亮度图,但评估时必须在原始RGB空间计算角度误差。也就是网络输出的色度要先还原回三维光照向量(b=1-r-g),再做角度误差计算。

数据增强方面,水平翻转是必须的,因为颜色恒常性任务中左右翻转不会改变光照颜色,但会翻转向量中某些空间线索的方向。随机裁剪也可以加,但要注意裁剪不能裁掉太多边界,本来全局统计信息就不多,再裁就没了。

4.2 模型实现与训练配置

下面给出一份可以直接跑的 PyTorch 代码骨架,结构是上面说的基线网络。

import torch import torch.nn as nn class LuminanceColorNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 64, 3, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 128, 3, stride=2, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 256, 3, stride=2, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), ) self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(256, 512), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(512, 2), nn.Sigmoid() ) def forward(self, x): x = self.features(x) x = self.pool(x).flatten(1) x = self.fc(x) return x

训练配置我实测下来比较稳定的一组参数:

  • 优化器:Adam,初始学习率 1e-4
  • 权重衰减:1e-5
  • Batch size:16
  • Epoch:80~120,早停阈值设10个epoch
  • 学习率策略:每30个epoch乘0.1

关键一点,在第一个epoch结束后打印一次验证集的角度误差中位数,如果明显低于随机猜测水平(大概55度到65度),说明管线没问题,可以继续往下跑。如果还是接近随机,不要盲目调学习率,先检查数据预处理和标签配准。

4.3 评估与对比实验

评估代码里最核心的部分是角度误差计算:

def angular_error(pred_light, gt_light): pred_light = pred_light / torch.norm(pred_light, dim=-1, keepdim=True) gt_light = gt_light / torch.norm(gt_light, dim=-1, keepdim=True) cos_theta = (pred_light * gt_light).sum(dim=-1).clamp(-1.0, 1.0) return torch.acos(cos_theta) * 180.0 / torch.pi

这里要再强调一次 clip 的必要性。矩阵乘完以后浮点误差很容易让 cos 值跑到 1.0000001,不 clamp 直接 acos 结果全是 NaN。

做对比实验时,至少要跑这几组:

  1. 输入用RGB三通道直接回归光照(常见基线,检验灰度输入的信息丢失程度)
  2. 输入用亮度单通道,输出用色度(本篇完整方法)
  3. 输入用亮度单通道,输出用三通道光照向量(验证输出色度的优势)
  4. 去掉角度损失,只用MSE(验证损失组合的必要性)

这样的消融设计基本能把文章核心贡献的每一项都拆出来验证。实测中第2组通常比第1组在泛化性上略好,尤其在跨数据集测试时,灰度输入对场景颜色变化的过拟合更少。

4.4 跨数据集泛化测试

颜色恒常性有一个很尴尬的现实:在同一个数据集上训练再测试,指标很好看,但换一个场景分布差异大的数据集,性能会明显下降。这是因为不同数据集使用的相机传感器光谱响应不同,而且场景内容偏好不同,比如室内场景和室外场景的统计特征差别很大。

我建议训练好模型后,一定要做一次跨数据集测试。如果发现性能掉很多,可以尝试一种很有效的补救方式:测试时做简单的输入适配,也就是把测试图的亮度直方图匹配到训练集亮度分布。这个操作不改变光照颜色信息,但能够减小传感器差异带来的亮度统计漂移,通常能把跨数据集角度误差降低10%~20%。

另一个更彻底的做法是训练时加入随机光照增强:在训练图像上随机乘一组光照扰动向量,让模型见过更多光照条件。这个增强对亮度预测色度的任务特别有效,因为扰动光照会直接改变亮度图,迫使网络学到对光照变化更鲁棒的特征。

5. 复现中常见问题与排查技巧实录

5.1 灰度输入导致的信息瓶颈与对策

第一次跑这个方案,我遇到的最大困惑是:训练loss不断下降,但验证集指标就是不动。仔细分析后发现,灰度图丢失了颜色对比信息,网络很容易陷入一种“偷懒”的解——不管输入是什么,都预测一个接近数据集平均色度的值。

应对策略是强化空间信息的作用。具体有两招很管用:

第一,输入上叠加位置编码或梯度图。在亮度图上叠加一个由Sobel算子计算得到的边缘强度通道,或者叠加一个坐标网格通道,都能给网络提供额外的结构线索,让它更容易利用空间模式而不是简单求均值。

第二,在损失函数中加入场景级约束。比如强制预测结果与场景中高光区域的色度建立一个弱一致性。这个不一定要做得很复杂,可以用一个辅助分支,在全局平均池化前对高光区域特征做一个加权池化,并把加权特征直接接到最后的色度输出上。这个方法有点“开小灶”的意思,但确实能打破僵局。

5.2 数据划分的坑

颜色恒常性数据集普遍存在一个特殊问题:同一个场景可能在不同光源下被拍摄多次。如果训练集和测试集划分时没有检查场景重叠,那么测试集里会出现与训练集高度相似的场景,评估结果会虚高很多。

正确做法是按场景ID划分数据集,保证同一个场景的图片全部落在同一侧。我在复现时因为这个吃了大亏,一开始按文件名随机划分,验证集中位数误差只有3度左右,感觉模型效果非常好;后来改成按场景划分,指标立刻跳到7度多。这个差别足以让一篇论文的结论完全反转,所以评估协议一定要严格。

5.3 训练震荡与角度损失数值问题

另一个高频问题是训练早期loss出现剧烈震荡,甚至某个batch之后直接变为NaN。排查顺序依次是:

  • 输入数据里有没有NaN或Inf,个别数据集的标注文件偶尔会有缺失值。
  • 标签有没有归一化,如果真实光照向量里混入了一个零向量,归一化会除零。
  • 角度损失有没有clip,前面已经说过。
  • 学习率是否过大,Adam在早期容易因为温启动参数导致大梯度,可以把学习率先降到1e-5跑10个epoch再调回去。

如果以上都排除了,还有一个容易被忽视的点:Sigmoid输出在两端饱和区梯度非常小,如果标签恰好接近0或1,刚开始几个epoch网络几乎学不动。解决办法是在Sigmoid之前把logits乘以一个系数(比如5),迫使输出在训练早期不进入饱和区。

5.4 问题排查速查表

现象可能原因解决方法
Loss持续下降但验证集不动模型退化为预测均值叠加边缘/位置通道,引入高光约束
训练早期Loss变成NaN角度计算未clip在acos前用clamp限制余弦值到[-1,1]
跨数据集测试掉点严重传感器差异、场景分布漂移直方图匹配测试图亮度,训练时加光照增强
验证集指标虚高同场景图像泄漏到测试集按场景ID严格划分数据
输出色度趋于0或1Sigmoid饱和导致梯度消失在Sigmoid前放大logits,或改用atan-style输出
灯光偏暖的测试集上表现异常训练数据冷暖不平衡对训练集做光照扰动增强,均衡冷暖分布

5.5 从论文方法到工程落地的一点点心得

复现到后期,我越来越觉得这篇文章最大的价值不在于指标本身,而在于它提供了一个很轻量的改造思路:如果你的ISP管线里已经有稳定的亮度信号,白平衡模块完全可以利用这个亮度通道做一次快速的色度估计,不需要依赖专门的RGB统计硬件模块。这一点对手机端和嵌入式端非常友好,因为亮度图的计算成本远低于RGB直方图统计,也更方便硬件加速。

另外,这种方法的一个扩展方向是视频序列。视频里相邻帧的亮度变化具有时间连续性,用亮度序列预测色度会更稳定。我在实验中发现,把视频连续帧的亮度图拼成一个多通道输入,预测出的光照色度比单帧预测噪声小很多。这个方向目前论文里没有展开,但工程价值很实在。

如果你正打算复现这篇论文,我的建议是:先把评估协议定清楚,再开始调网络结构。这个方向最核心的问题永远是“评估是否公正”,其次是“输入表示是否合理”,结构反而是最不敏感的部分。记住这点,你能省掉大量无效调参的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询