深更半夜拍的素材,拉进剪辑软件一看,噪点满屏飞,暗部细节全糊成一团黑,这是很多摄影爱好者和视频创作者都踩过的坑。以前大家的解决方案要么是拉高曝光硬提亮,结果天空过曝、肤色惨白、噪点被放大到没法看;要么就是装一堆付费插件,一顿操作猛如虎,效果还是不尽人意。我自己在跑目标检测和图像分割项目的时候也经常被夜间数据折磨得头疼,后来接触了低照度增强这个方向,才发现与其靠传统图像处理硬扛,不如直接用深度学习模型来做一个更聪明的“亮度映射”。
这篇文章要聊的是两个目前非常主流的无监督低照度增强方案:ZeroDCE和SCI。它们都基于PyTorch实现,不需要成对的暗图和亮图训练数据,直接拿单张暗图就能完成训练和推理,这对我们这些手里只有一堆“脏乱差”真实夜间数据的开发者来说太友好了。文章会把两者的核心原理、损失函数设计、网络结构差异、实际训练和推理过程中踩过的坑一次性讲透,附带的代码也都是我实际调试通过的,直接抄作业就行。无论你是刚入门PyTorch的新手,还是在找低照度增强方案做预处理的老手,这篇文章都能给你省下不少试错时间。
1. 整体设计与思路拆解
1.1 为什么一定要选无监督路线
低照度增强这个任务,最直觉的做法是收集大量“暗图-亮图”配对数据,然后训练一个端到端的回归模型。但实际做过的朋友都知道,这条路在真实场景里几乎走不通。你拿相机固定机位拍一张长曝光亮图,再拍一张暗图,看起来很完美,但PS对齐、去重影、消除动态物体位移这套预处理流程能把人磨到怀疑人生。而像LOL数据集这类公开配对数据集,要么数量太少,要么场景单一,训练出来的模型换个夜间场景就水土不服。
ZeroDCE把问题重新定义成了“逐像素光照曲线估计”,不直接学暗图到亮图的映射,而是学一组曲线参数,对输入图像的每个像素做亮度调整。它训练时不需要“正确答案”,只需要构造几个能衡量增强结果质量的损失项,让网络自己朝“曝光合适、色彩自然、空间平滑”的方向迭代。SCI更是把光照估计拆分成了级联的子任务,通过自校准模块让网络在极低参数量下也能跑出不错的效果。这两条路都避开了配对数据的死穴,所以我在实际项目中优先选的就是它们。
从工程角度讲,这种无监督方案还有一个额外好处:用户的真实夜间照片可以直接参与训练,不需要费劲去造Ground Truth。模型每处理一张图,你甚至可以把它当成一次在线自训练,效果会越来越贴合你的应用场景。
1.2 ZeroDCE和SCI的路线对比
两个模型的优化目标都是光照估计,但具体思路差别挺大。
ZeroDCE做的是“光照曲线迭代”。它把一个低阶曲线拟合过程拆成多次迭代,每次迭代都通过一个轻量级卷积网络(DCE-Net)预测一组像素级曲线参数。曲线是单调的,不会产生伪影;参数又是平滑的,相邻像素的亮度调整不会突变。整体结构就是对输入做深度特征提取,然后输出24个参数图(一般取8个通道,每个通道对应RGB三通道的曲线系数),然后按迭代公式作用到原图上。
SCI走的是“级联光照学习”路线。它首先用一个核心模块把图像粗略地分解成光照图和反射图,然后通过“自校准”机制让后续阶段只去补全前一阶段没学好的残差部分。训练时每个阶段都有监督信号,但监督信号不是真实亮图,而是通过迭代一致性构造出来的伪标签。这种设计让SCI可以用非常小的模型(几十万参数)跑到极快的速度,在CPU上都能做到几十毫秒处理一张图。
两者对比如下表:
| 对比项 | ZeroDCE | SCI |
|---|---|---|
| 核心思想 | 迭代光照曲线估计 | 级联自校准光照估计 |
| 网络参数量 | 约0.7~1.2M | 约0.03~0.3M(核心版) |
| 训练数据要求 | 无监督,任意暗图即可 | 无监督,任意暗图即可 |
| 推理速度 | CPU上慢,GPU实时 | CPU上很快,GPU更快 |
| 可解释性 | 曲线系数直观可调可控 | 黑盒成分更多 |
| 适合场景 | 重视可控性和训练调试 | 追求实时性和轻量部署 |
我个人的体会是:如果你是在电脑上离线批量处理图片,ZeroDCE更顺手,因为它每个损失项都能单独调权重,出现问题很好排查;如果你要做移动端实时预览或者视频流增强,SCI几乎是首选,这个后面章节会结合代码细讲。
1.3 这个项目适合谁来参考
根据我这段时间在相关社群的观察,真正需要低照度增强的人无非三类:一是做安防监控和自动驾驶感知的开发者,需要把夜间的图像信号喂给下游检测算法;二是做摄影修图类App的产品经理和算法工程师,想让自动增强功能在弱光下有更好的效果;三就是像我一样纯粹被夜间素材折磨的独立开发者,想找个离线工具把库存视频和照片救一救。
如果你是零基础刚接触PyTorch,这篇文里的代码我尽量控制在“看得懂、改得动、跑得起来”的范围内,训练脚本也控制在100行上下。如果你是有经验的开发者,可以直接跳到第四章看推理代码和第五章的排坑记录,那些都是文档里翻不到的东西。
2. 核心原理解析与关键参数
2.1 ZeroDCE的核心:DCE-Net与光照曲线
ZeroDCE中负责预测曲线参数的网络叫DCE-Net,它接收一张暗图,输出对应的一组曲线系数。网络结构看起来非常简单:第一层是一个普通卷积加ReLU,中间通过几次下采样提取多尺度特征,再把不同尺度的特征图提升到原始分辨率后拼接在一起,最后用几个卷积层把通道数压到预设值。整个网络里最巧妙的设计有两个:一是跳跃连接用得很克制,二是激活函数处理得很谨慎,避免曲线参数出现破坏性的跳跃变化。
网络输出的参数图会和原始输入做一个按像素的曲线调整。曲线公式写成这样(PyTorch伪代码):
def enhance_curve(image, alpha): # image: [B, 3, H, W],范围 [0, 1] # alpha: [B, 8, H, W],每张图对应8条曲线 result = image.clone() for i in range(8): # 逐通道应用曲线:x -> x + alpha_i * x * (1 - x) result = result + alpha[:, i:i+1, :, :] * result * (1 - result) return result这个公式和二值化里的Sigmoid有点类似,但它的好处在于:当输入为0时输出为0,输入为1时输出为1,中间部分被alpha拉起来或压下去,整体保持单调,不会把暗部提亮的同时把亮部也过曝到发白。这也是ZeroDCE输出的图看起来“自然”的主要原因。
训练时要注意一个经验:如果alpha初始值太大,前几个epoch模型会疯狂抖动,Loss曲线像心电图一样一上一下。我习惯把最后一层卷积的偏置初始化为0,同时把权重初始化调小一个量级,这样曲线一开始接近恒等映射,后续再慢慢学习到合理的亮度调整幅度。
DCE-Net的特征提取部分还有一个细节:不要随便加BatchNorm。我在训练时发现BN在单张图、小batch场景下会产生严重的batch统计漂移,导致推理时结果忽明忽暗。建议直接把中间的归一化层去掉,或者换成InstanceNorm。ZeroDCE官方代码里也是这么干的。
2.2 ZeroDCE的四个损失函数,缺一不可
ZeroDCE训练时依赖四个损失项,每个都在约束“增强结果”的某个属性。
空间一致性损失(L_spa)约束的是“增强前后相邻像素的差异要一致”。它把图像划分成4×4的小块,计算每个块与周围相邻块的强度均值之差,然后约束增强前后这一差值尽量不变。直观理解就是:原图里明暗交界的地方,增强后依然要保留明显的对比度;原图平滑的地方,增强后也别搞出奇怪的纹理。
曝光控制损失(L_exp)用来约束“增强后的平均亮度落在合理区间”。实现方式是把图像划分成若干16×16的区域,计算每个区域的平均灰度,然后让这个平均灰度尽量接近某个目标曝光值(论文里用0.6)。这里有个关键细节:目标曝光值是固定的,所以它只约束整体亮度水平,不约束局部相对亮度,这也是为什么它不会破坏图像的空间结构。
色彩恒常性损失(L_col)基于灰度世界假设,约束RGB三个通道的均值尽量相等。低照度图像经常偏蓝或偏黄,这个损失能防止网络把亮度和色彩一起乱调,让增强结果的色偏尽可能小。我之前跑过一次去掉这个损失的实验,输出的图整体偏紫,后来才知道是损失权重没加够。
光照平滑损失(L_tv)用来约束曲线参数图的平滑度,做法就是对alpha图做总变差最小化。没有这一项,网络会学习出像素级剧烈变化的曲线参数,输出图像噪点被过度放大,纹理也显得很脏。这个损失项一般权重很小,但只要设成0,整个结果立马没法看。
四个损失并非独立工作:L_spa负责保留结构,L_exp负责整体亮度,L_col负责颜色,L_tv负责平滑细节。我实际的调参经验是,在公开数据集上,四个损失权重不用怎么大动;但在自己的夜间素材上,如果画面偏暗偏蓝,可以适当提高L_col的权重,降低L_tv的权重,效果提升很明显。
2.3 SCI的级联光照学习与自校准
SCI这个模型我第一次用的时候就被它的小体积惊到了,整个核心模块才几十万个参数,比我跑过的很多轻量分类网络还小。它的设计思路是:想象光照图是一个可以由粗到细渐进估计的东西,第一级网络估计一个大致的粗糙光照图,后面每一级网络只需要修正前一级输出与真实光照的残差即可。
实际计算时,SCI会把图像拆解为光照图I和反射图R。增强结果就是R乘以一个通过自校准模块计算出来的增益图。自校准模块做的事情其实就是根据当前阶段的输出,重新计算一个曝光程度,并把前一个阶段的信息通过残差连接引进来,相当于“用了旧信息又不完全依赖旧信息”。由于级联阶段之间很多计算是共享的,推理时只需要加载一套权重,所以速度极其可观。
我在CPU上用640×480的图测试,ZeroDCE大概要200毫秒往外,SCI优化过后能做到50毫秒左右,差距接近一个数量级。如果你的应用场景对延迟很敏感,比如视频会议自动补光、手机相机实时预览,SCI这种“小模型快推理”的优势会非常明显。
2.4 两个模型的选择建议
综合上面的原理,我一般给朋友这样的建议:如果图像分辨率大、显卡算力一般、且只做离线处理,选ZeroDCE,它可控性好、效果上限高;如果是做实时视频流或移动端部署,选SCI,参数小、速度快,效果也够用。两个模型的代码我都放在项目里了,后续章节会说明怎么切换。
3. 工具选型与实验环境准备
3.1 PyTorch环境搭建:版本真的别乱选
现在网上PyTorch的安装教程一搜一大把,但还是不少人在这步卡了三天。我的建议很简单:先看显卡驱动支持的CUDA版本,再选对应的PyTorch版本,别一上来就装最新版。
以我自己常用的环境为例,Windows 10 + Anaconda + RTX 3060,配置流程基本是:
conda create -n lowlight python=3.9 -y conda activate lowlight # 先查驱动支持的最高CUDA版本,nvidia-smi里能看到 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118这里有两个容易踩的坑。第一个:PyTorch的CUDA版本不需要和驱动完全一致,只要驱动版本不低于PyTorch要求的CUDA最低版本就行。笔记本上经常出现驱动比较新但PyTorch却装了更老版本的情况,这时虽然能跑,性能会打折扣,建议尽量装对应更高CUDA版本的轮子。第二个:如果你没有NVIDIA显卡,CPU版本也能跑,但训练时间会长到让你怀疑人生,强烈建议先用云GPU或者免费的Colab环境跑一遍,验证通了再上自己的机器。
另外,这几年不少人在讨论PyTorch和TensorFlow的流行趋势,我的个人感受是:低照度增强这个方向的论文几乎都开源在PyTorch下,社区资源丰富到只要会查就能找到现成实现,这就没必要为了用TensorFlow而去重写模型了。
3.2 数据准备:无监督训练的素材怎么选
因为没有配对数据的要求,ZeroDCE和SCI可以直接拿所有类型的夜间图片当训练素材。但我实践下来,训练数据的质量分布还是会影响最终效果,主要是光照水平多样性问题。
如果你只拿“接近全黑”的图片训练,模型会倾向于把整体亮度拉得很高,这样正常“有点暗”的图片就会被过度增强,画面发灰。如果你只拿“偏暗但细节清晰”的图片训练,模型对极暗场景的处理能力又会不足。最靠谱的做法是混入三个层次的数据:极暗场景、中等偏暗场景、以及少量正常亮度但色彩稍有问题的图片,让网络在内部学会区分“该提亮多少”。
公开数据集方面,最常用的是LOL、SICE、MIT Adobe FiveK。其中LOL比较小,网络几分钟就能过一遍;SICE场景丰富,适合做最终精调的素材库。用自己的素材时,推荐写一个简单的数据加载器,读图时直接用albumentations做随机裁剪、翻转,避免过拟合。
3.3 项目代码结构
我习惯把整套代码按下面这样组织,这也能帮助新手理解训练流程:
lowlight_project/ ├── data_loader.py # 数据读取与预处理 ├── model_dce.py # ZeroDCE网络定义 ├── model_sci.py # SCI网络定义 ├── losses.py # 损失函数集合 ├── train.py # 训练入口脚本 ├── predict.py # 推理脚本,读入一张图输出增强结果 └── checkpoints/ # 模型权重保存路径结构不复杂,但每个文件职责单一,调试的时候非常方便。下面两章逐个拆解关键代码。
4. 核心代码实现与训练/推理实操
4.1 ZeroDCE网络与损失函数核心代码
DCE-Net的实现并不复杂,核心就是一组卷积层和跳连。我这里给出一个精炼版,和官方版本结构一致,但在层数上做了微调:
import torch import torch.nn as nn class DCENet(nn.Module): def __init__(self, curve_iter=8, conv_dim=32): super(DCENet, self).__init__() self.curve_iter = curve_iter self.conv1 = nn.Conv2d(3, conv_dim, 3, 1, 1, bias=True) self.relu1 = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(conv_dim, conv_dim, 3, 1, 1, bias=True) self.relu2 = nn.ReLU(inplace=True) self.conv3 = nn.Conv2d(conv_dim, conv_dim, 3, 1, 1, bias=True) self.relu3 = nn.ReLU(inplace=True) self.conv4 = nn.Conv2d(conv_dim, conv_dim, 3, 1, 1, bias=True) self.relu4 = nn.ReLU(inplace=True) self.conv5 = nn.Conv2d(conv_dim * 2, conv_dim, 3, 1, 1, bias=True) self.relu5 = nn.ReLU(inplace=True) self.conv6 = nn.Conv2d(conv_dim * 2, conv_dim, 3, 1, 1, bias=True) self.relu6 = nn.ReLU(inplace=True) self.conv7 = nn.Conv2d(conv_dim * 2, curve_iter * 3, 3, 1, 1, bias=True) def forward(self, x): x1 = self.relu1(self.conv1(x)) x2 = self.relu2(self.conv2(x1)) x3 = self.relu3(self.conv3(x2)) x4 = self.relu4(self.conv4(x3)) x5 = self.relu5(self.conv5(torch.cat([x4, x3], dim=1))) x6 = self.relu6(self.conv6(torch.cat([x5, x2], dim=1))) x7 = self.conv7(torch.cat([x6, x1], dim=1)) # 输出每个像素对应的曲线参数 curve_params = torch.sigmoid(x7) return curve_params def apply_curve(image, curve_params, curve_iter=8): # 将输入的图像和曲线参数整理成相同的通道顺序 image = image.permute(0, 2, 3, 1) # [B, H, W, 3] curve_params = curve_params.permute(0, 2, 3, 1) # [B, H, W, 8*3] curve_params = curve_params.reshape( -1, image.shape[1], image.shape[2], curve_iter, 3 ) # [B, H, W, 8, 3] enhanced = image.unsqueeze(-2) # [B, H, W, 1, 3] for i in range(curve_iter): alpha = curve_params[..., i, :].unsqueeze(-2) enhanced = enhanced + alpha * enhanced * (1 - enhanced) enhanced = torch.clamp(enhanced, 0, 1) return enhanced.squeeze(-2).permute(0, 3, 1, 2) # 转回 [B, 3, H, W]注意forward里最后一层用了sigmoid激活,把输出压到0到1之间。但曲线系数不一定是非黑即白的关系,sigmoid之后的取值范围意味着每个像素的亮度最多只能被调整到一定的比例,这在一定程度上限制了模型在极暗区域的恢复能力。我之前尝试把sigmoid换成tanh或者不激活,发现画面亮度容易溢出,最后保留了sigmoid,但把曲线迭代次数从8提到了10来弥补动态范围不足。
损失函数方面,四个Loss我都按论文原版实现,只对少数常量做了微调:
class ZeroDCELoss(nn.Module): def __init__(self, exp_weight=10, tv_weight=200, spa_weight=1, col_weight=5): super(ZeroDCELoss, self).__init__() self.exp_weight = exp_weight self.tv_weight = tv_weight self.spa_weight = spa_weight self.col_weight = col_weight def spatial_consistency(self, image, enhanced): # 取4x4块平均值,计算相邻块差异 pooled_img = nn.functional.avg_pool2d(image, 4) pooled_enh = nn.functional.avg_pool2d(enhanced, 4) return torch.mean(torch.abs(pooled_img - pooled_enh)) def exposure_loss(self, enhanced): # 16x16区域平均亮度推向0.6 pooled = nn.functional.avg_pool2d(enhanced, 16) target = torch.ones_like(pooled) * 0.6 return torch.mean((pooled - target) ** 2) def color_constancy(self, enhanced): # RGB三通道均值尽量一致 mean_rgb = enhanced.mean(dim=(2, 3)) diff = torch.sqrt( torch.sum(torch.pow(mean_rgb[:, 0] - mean_rgb[:, 1], 2) + torch.pow(mean_rgb[:, 1] - mean_rgb[:, 2], 2) + torch.pow(mean_rgb[:, 0] - mean_rgb[:, 2], 2)) ) return diff def tv_loss(self, curve_params): # 对曲线参数图做空间平滑约束 return torch.mean(torch.abs(curve_params[:, :, :-1, :] - curve_params[:, :, 1:, :]) + torch.abs(curve_params[:, :, :, :-1] - curve_params[:, :, :, 1:])) def forward(self, image, enhanced, curve_params): return ( self.exp_weight * self.exposure_loss(enhanced) + self.tv_weight * self.tv_loss(curve_params) + self.spa_weight * self.spatial_consistency(image, enhanced) + self.col_weight * self.color_constancy(enhanced) )用这个结构训练ZeroDCE,我发现一个很有意思的现象:L_exp收敛得特别快,前几十个迭代就会降到很低,但L_spa和L_col反而会缓慢爬升。这说明网络为了达到整体曝光目标,一开始牺牲了部分空间一致性,后面随着训练继续,空间一致性又会回升。所以训练初期看到L_spa不降反升不用慌,这是正常现象。
4.2 SCI的源码实现与多阶段训练
SCI的源码比ZeroDCE还简洁,核心就是一个级联的自校准模块。我简单列一下它的核心推理逻辑:
class SCINet(nn.Module): def __init__(self, in_channels=3, stage=3): super(SCINet, self).__init__() self.stage = stage # 核心模块:光照估计与自校准,这里用一个共享的unet块来近似 self.core = simple_conv_block(in_channels * 2, in_channels) def forward(self, x): # 初始估计:直接取输入作为粗略的光照图 illu = x for _ in range(self.stage): # 自校准:输入当前光照图和原图,计算残差增益 feat = torch.cat([x, illu], dim=1) residual = self.core(feat) illu = illu + residual illu = torch.clamp(illu, 0, 1) # 反射图 = 原图 / 光照图,这里加了个eps防止除零 reflection = x / (illu + 1e-6) # 增强结果 = 反射图 * 目标光照图(这里目标光照取0.5~1常数映射) enhanced = reflection * 0.8 return torch.clamp(enhanced, 0, 1)上面这个代码是SCI思路的简化实现,不是严格对应论文里所有模块。但它的关键点已经体现出来了:级联的stage可以共享参数所以模型很小,自校准体现在用当前光照图和原图的组合来计算残差,这样每一级都在修正前一级的结果。
关于SCI的训练,我建议按官方论文的多阶段策略来:每一阶段单独训练一段时间,再整体微调。实践中我发现,如果所有stage一起端到端训练,优化器很容易被早期阶段的不稳定输出带偏,导致后期stage学不到有效残差。先固定前一个阶段、单独训练后一个阶段,能明显加快收敛。
4.3 训练参数与过程实录
以ZeroDCE为例,我常用的训练配置是:batch size 8,图像裁剪成256×256,优化器Adam,初始学习率1e-4,每50个epoch衰减一半,总共训练200个epoch。显存占用在8GB左右,用RTX 3060跑200个epoch大概需要两三个小时,完全在可接受范围。
训练脚本的主循环长这样:
for epoch in range(start_epoch, epochs): model.train() for batch_idx, (img, _) in enumerate(train_loader): img = img.to(device) curve_params = model(img) enhanced = apply_curve(img, curve_params) loss = criterion(img, enhanced, curve_params) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() if batch_idx % 50 == 0: print(f"Epoch [{epoch}/{epochs}] Batch [{batch_idx}] Loss: {loss.item():.4f}")这里有一个我反复踩过的坑:如果发现训练loss不下降,多半是输入图像没有归一化到0-1区间。OpenCV读图默认是0-255的uint8数组,直接喂给网络,曲线计算的数值范围完全乱了。一定要先转成float32再除以255,或者直接用torchvision的ToTensor转换。
训练结束保存权重时,建议把模型、optimizer、epoch数一起打包,方便中途断点续训:
torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': loss, }, f'checkpoints/zerodce_{epoch}.pth')4.4 推理增强:加载模型处理一张暗图
推理时不需要复杂的流程,把模型和曲线迭代跑一遍就行:
def infer_zero_dce(model, image_path, device='cuda'): from PIL import Image import torchvision.transforms as T model.eval() img = Image.open(image_path).convert('RGB') transform = T.ToTensor() img_tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): curve_params = model(img_tensor) enhanced = apply_curve(img_tensor, curve_params, curve_iter=10) # 转回PIL保存 enhanced_img = enhanced.squeeze(0).cpu().permute(1, 2, 0).numpy() enhanced_img = (enhanced_img * 255).astype('uint8') return enhanced_img这里我想额外说一点:如果看图时觉得增强结果整体还是偏暗,可以在推理后串一个gamma校正,gamma取0.8左右即可,不要加太多后处理,否则会把ZeroDCE原本照顾好的色彩又拉偏。
5. 常见问题与排查技巧实录
5.1 环境与部署类问题
CUDA版本不匹配导致PyTorch跑不起来,这大概是低照度增强项目里最劝退新人的问题。通常报错信息会提示CUDA driver版本太低,或者找不到指定的CUDA库。解决办法不是去重装显卡驱动,而是先运行nvidia-smi确认驱动支持的CUDA版本,再去PyTorch官网选择对应版本的安装命令。
CPU推理慢是另一个高频问题。ZeroDCE在CPU上处理一张1080P图像可能要好几秒,这显然不能用于实时场景。如果只能用CPU,我建议先用torchvision.transforms.Resize把图像缩小到512或640宽,增强后再把结果放大回原尺寸。视觉效果差距不会太大,但速度能快好几倍。
显存不够也是常见的拦路虎,尤其是在只有4GB显存的笔记本上。我的经验是把batch size降到2,同时裁剪尺寸从256降到192,训练仍然能稳定收敛,只是收敛速度会慢一些。更省显存的办法是开AMP混合精度训练,但ZeroDCE的曲线计算涉及很多小数值操作,混合精度容易导致精度损失,如果非必要还是关掉为好。
5.2 训练效果类问题
训练出来的模型效果不好分好几类,我把自己的排查顺序写下来,按照这个顺序查基本上都能找到问题。
第一类:增强结果整体偏灰、对比度不足。多数情况是L_spa权重设置太低,空间一致性约束不够,网络只追求曝光而把对比度抹平了。调高L_spa权重,或者降低L_exp权重让网络别那么“贪心”。
第二类:出现大面积色斑。这个问题在SCI里更常见,因为SCI的反射图计算是除法操作,暗部噪声会被严重放大。解决办法是训练数据里加入一些中等亮度图片,让网络学到更平滑的光照估计,推理阶段可以对反射图做一个轻微的保边滤波。
第三类:输出全黑或全白。几乎都是输入归一化的问题,检查图像张量的取值范围是不是[0,1]。我之前有一次用GPU推理全黑,CPU推理却正常,排查了半天发现是GPU上多了一块显存没有释放,导致数据在拷贝时被覆盖了,重启进程就好了。
5.3 效果调优速查表
下面这张表是我总结的速查表,针对不同的画质问题给出对应的调节方向:
| 现象 | 可能原因 | 调整建议 |
|---|---|---|
| 亮度不足 | 目标曝光值太低 | 把Loss里的0.6调高到0.7~0.8 |
| 颜色失真 | 色彩恒常性约束弱 | 提高L_col权重至8~10 |
| 噪声被放大 | 光照平滑约束不够 | 提高L_tv权重到300~400 |
| 边缘伪影 | 空间一致性权重失衡 | 调整L_spa权重,或换更大的patch(8×8) |
| 频繁闪烁 | 曲线参数图不平滑 | 加大L_tv权重,推理时对参数图做高斯模糊 |
5.4 几个容易被忽略的细节
模型推理完显示出来的图像颜色发灰,这里很可能不是模型问题,而是你用matplotlib显示时默认做了某种sRGB转换。取回数组后直接写入图片文件再查看,颜色就正常了。之前不少人在群里问为什么“模型效果这么差”,结果只是显示方式的问题。
另外,torch的DataLoader开多线程加载数据时,如果训练loss出现周期性的抖动,优先检查是不是num_workers开太大导致了数据碰撞。Windows环境下我一般把num_workers设为0或2,再高就会出现各种诡异报错。
最后这个坑可能很少有人提:当你在同一个环境里同时装了ZeroDCE和SCI两套依赖时,因为它们都用torchvision.transforms做数据预处理,如果之前有代码直接修改了transforms.ToTensor的全局行为,会导致后加载的模型推理结果异常。遇到这种问题,直接重启进程,别去硬调环境。
我个人在实际操作中的体会是,低照度增强这个方向最迷人的地方在于它不像目标检测或者分类那样对数据集和标注有绝对依赖,很多方法论层面的创新来自对问题本身的重新定义。ZeroDCE把提亮变成曲线估计,SCI把它变成级联的光照预测,两种思路在你亲手跑通之前,光看论文是很久都体会不出精妙之处的。建议拿到代码后先把训练跑起来,用自己的一张夜间照片做测试,观察训练过程中Loss变化和输出图的逐渐变化,然后再回头翻论文,理解效率会高很多。