深更半夜翻相册看到前几天拍的夜景照片,噪点一片,暗部细节完全糊死,整张图就像泼了一瓶酱油。大多数人的第一反应是拉曲线、加曝光,结果高光溢出,天空变成一块死白。传统直方图均衡在低照度下不仅提亮有限,还会把噪声放大成满屏彩点。这个场景我想搞计算机视觉的朋友都懂——低照度增强说难不难,但要做到"自然、无伪影、不偏色",真不是套个OpenCV函数能解决的。
最近我在两个无监督低照度增强模型上折腾了大概两周,跑了大量实验:一个是ZeroDCE,全称Zero-Reference Deep Curve Estimation,另一个是SCI(Self-Calibrated Illumination),两者都不需要成对的暗图/亮图训练数据。本文把这些实战经验完整写出来,包括PyTorch实现、损失函数细节、训练走通的全流程,以及在真实夜间照片上踩过的坑。做图像增强、计算机视觉应用,或者只是想把夜间素材预处理干净的朋友,应该都能从中拿到可以直接抄的代码和思路。
1. 为什么夜拍增强必须走无监督路线
1.1 成对数据的死穴:真实夜间照片根本没有"标准答案"
主流低照度增强方法在很长一段时间里依赖有监督训练:给一张低光图,配一张同场景的长曝光亮图,让网络学习映射关系。听起来很合理,但这个"标准答案"在实际场景里是伪命题。
长曝光亮图本身就存在动态模糊和色彩偏移,而且同一个场景,你用A相机拍的和B相机拍的"正确结果"完全不一样。更致命的是,成对数据采集极度困难——你几乎找不到同一时刻同一机位的暗图和亮图,尤其是监控视频、行车记录仪这种连续画面,根本等不到补拍亮照。就算你从公开数据集里拿到几千对训练样本,面对真实世界夜拍的设备差异、天气差异、场景差异,泛化能力往往很虚。
我刚开始也尝试过用经典成对数据集训练一个轻量网络,结果在公开测试集上指标不错,一到自己手机拍的夜景就翻车。后来仔细一想才明白:低照度增强的本质问题不是"已知暗图到已知亮图的映射",而是"如何在没有任何参考的情况下判断什么是合理的亮度",这恰恰是无监督方法的用武之地。
1.2 ZeroDCE和SCI的共同哲学:不依赖ground truth也能训练
ZeroDCE和SCI恰好走的是同一条路线:不需要成对数据,只需要一堆低照度图像本身,网络就能自己学会"什么是亮得自然、看得清楚"。
这里的核心思想可以类比成"没有老师,但有教材"。成对数据是"老师",告诉你每一步的标准答案;而无监督方法用"教材"约束网络——比如曝光控制损失要求增强结果的平均亮度不要太离谱,颜色损失要求原本偏色的通道不要被拉得更偏。网络不需要知道一个像素的"正确亮度值",只需要知道输出图像在统计上看起来像一张正常照片。
这种做法带来的直接好处有三个:
- 训练数据容易获取,网上随意搜集夜间照片即可,不需要任何标注;
- 模型对场景的适应能力更强,因为数据多样性远大于人工采集的成对集;
- 模型在真实设备上表现往往更稳定,因为训练时的输入分布更接近实际使用场景。
ZeroDCE的具体思路是让网络输出一条"像素级亮度调整曲线",参考思路像Photoshop里的曲线工具,只不过这个曲线不是人工拖出来的,而是网络根据图像内容自己估计的。SCI的思路则是把图像分解成"照明分量"和"反射分量",通过估计照明分量来恢复清晰反射,参考思路更像传统Retinex理论,只不过整个流程被参数化了。
两个模型的取舍也很好理解:ZeroDCE的曲线映射更直接,训练稳定;SCI的照明估计则天然适合视频任务,因为照明分量在时间上是平滑的。接下来这两节逐个拆解。
2. ZeroDCE原理拆解:用曲线估计把暗图调亮
2.1 核心思想:像素级亮度曲线映射
ZeroDCE的核心是Light-Enhancement Curve,一条像素级的亮度映射曲线。它的数学表达很简洁:
LE(I(x); α) = I(x) + α * I(x) * (1 - I(x))
其中I(x)是归一化到[0,1]的像素值,α是网络预测的曲线参数。这个公式理解起来也很直观:当I接近0(暗部)时,I(1-I)很小,变化少;当I在0.5附近时,调整幅度最大;当I接近1(亮部)时,变化再次趋缓。天然避免了高光溢出。
单次迭代的调整能力有限,论文里采用迭代策略:连续应用N次曲线,每次使用不同的α。我实测N=8效果已经足够,再往上提升不显著,计算量却线性增长。
为了让曲线具备空间自适应性,ZeroDCE让α不是单个数,而是一个和输入图像同尺寸的多通道参数图(实际是3通道,对应RGB通道各一张)或者说按通道逐像素调整。网络输出的α图规格为3 * N(3个通道乘以迭代次数),在训练中通过N次迭代逐层映射得到最终增强图。
2.2 四种非参考损失函数的设计逻辑
无监督的关键在于损失函数怎么闭环。ZeroDCE设计了四个损失,各管一件事:
空间一致性损失(L_spa):增强前后,图像在局部区域的平均强度差异要尽可能小。通俗说就是相邻像素的亮度关系不要被破坏,防止边缘出现光晕。实现上是把图像划分成4x4的互不重叠小块,对每个小块的通道均值做L1距离约束。
曝光控制损失(L_exp):增强结果的平均亮度应该落在合理区间。论文把目标定为0.6(这个值对应灰度为约153,在自然图像里观感最平衡)。实现上是对亮度通道做16x16分块,每块的平均灰度与0.6做L1距离。
颜色一致性损失(L_col):增强前后各颜色通道的比例关系要稳定。实现上是计算RGB三通道两两之间的差值,增强前后这两个差值应该一致。防止那种"提亮了但整张图严重偏红/偏绿"的灾难性结果。
照明平滑度损失(L_tv):对α参数图施加全变分约束,让曲线参数在空间上平滑变化,抑制参数图噪声被传播到结果图像上。
总损失写成:
L_total = L_spa + L_exp + 0.5 * L_col + 200 * L_tv
这个权重配比是论文里验证过的,我一开始自作主张把L_tv权重调成2000,结果图像细节被严重磨平,所以默认权重直接抄论文就好。
3. SCI原理拆解:自校准照明估计带来的极速体验
3.1 照明分量估计算法:从Retinex到自校准迭代
SCI走的是Retinex路线,把图像拆成照明分量L和反射分量R:
I = L * R
增强目标就是估计合理的照明分量,然后用I / L(逐元素相除)恢复出反射图R。传统Retinex方法最大的坑在于:照明分量估计不准时,除出来的结果会产生严重的色彩失真和噪声放大。
SCI的聪明之处在于设计了自校准模块。它不是平地直接把图像拆成照明,而是用一个轻量网络首先生成一个初始照明估计,然后通过自校准逐步精化。精化的过程有点像反复试错:估计出一个照明图,用它去还原图像,再拿还原后的结果反过来修正照明估计,循环几次,直到照明估计稳定。
这个结构与ZeroDCE的曲线迭代有异曲同工之妙,但侧重点不同。ZeroDCE直接作用于像素值本身,SCI则是在照明空间里操作。前者胜在简单可控,后者胜在理论基础清晰,而且照明分量本身就是个很好的中间产物,可以拿去二次利用。
3.2 训练损失与ZeroDCE的对比
SCI的训练损失相比ZeroDCE更精简,核心是两类:
重构损失:增强后的图像I_enhanced与输入I_low在结构上不能丢信息,通常用L1距离约束增强结果与原始输入的基础光影关系。这一项在ZeroDCE里没有对应的显式约束,ZeroDCE是靠曲线迭代自带的单调性保证不丢结构。实际操作中我发现,SCI对极度欠曝的图像结构保留比ZeroDCE好,因为在照明空间操作天然对"提亮"有更强的响应。
照明平滑度损失:照明图在局部应该光滑,细节都反映在反射分量里。这一点和ZeroDCE的L_tv类似,都是抑制噪声的手段。
严格版SCI还增加曝光控制损失,但核心还是重构加平滑。两个模型放到一起对比的话,我的感受是:
| 对比维度 | ZeroDCE | SCI |
|---|---|---|
| 核心原理 | 亮度曲线映射 | 照明分量估计 |
| 网络输出 | 曲线参数α(3*N通道图) | 照明分量图 |
| 迭代机制 | 曲线迭代N次 | 自校准迭代N次 |
| 推理速度 | 快(轻量CNN) | 更快(更轻量) |
| 参数量 | 约7.9万个 | 约3.3万个 |
| 训练损失 | 四件套(空间/曝光/颜色/平滑) | 重构+平滑(严格版加曝光) |
| 典型场景 | 普通照片增强、偏色矫正 | 实时视频流、移动端部署 |
4. 环境准备与数据组织
4.1 PyTorch环境搭建与依赖
实战之前先把环境顺好。我的配置是Ubuntu 22.04 + Python 3.10 + PyTorch 2.1.0 + CUDA 11.8,单张RTX 3090。如果你只是CPU环境,ZeroDCE和SCI的推理也跑得动,一张1080P图片大概1到2秒,训练就不建议CPU了。
安装依赖很简单:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python numpy tqdm pillow scikit-image matplotlib有两个小坑先提醒:
- opencv-python和opencv-contrib-python不要同时装,会出现imshow冲突。单装opencv-python足够。
- scikit-image用于计算PSNR/SSIM,虽然训练用不到,但评估效果时非常有用,建议一并安装。
4.2 训练数据的获取与预处理
无监督训练只需要低照度图像本身。推荐几个公开数据集:
- LOL数据集:经典低照度增强数据集,虽然它是成对的,但无监督训练时可以只取其中的低照度子集,因为不需要配对关系。
- DarkFace:真实夜间人脸场景,适合做安防监控方向的预训练。
- ExDark:包含多种暗光场景,图像数量多,适合预训练。
如果只是快速跑通流程,我建议直接在LOL的train子集里拿几百张低光图,再配合自己手机拍摄的一些夜景照片混合使用。数据的图像尺寸不需要统一,但训练时要随机裁剪成固定尺寸。
推荐预处理流程:
import cv2 import numpy as np from torch.utils.data import Dataset class LowLightDataset(Dataset): def __init__(self, img_dir, patch_size=256, color_jitter=False): self.img_paths = [os.path.join(img_dir, f) for f in os.listdir(img_dir) if f.endswith(('.png', '.jpg', '.jpeg'))] self.patch_size = patch_size self.color_jitter = color_jitter def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, _ = img.shape # 随机裁剪到 patch_size if h >= self.patch_size and w >= self.patch_size: top = np.random.randint(0, h - self.patch_size + 1) left = np.random.randint(0, w - self.patch_size + 1) img = img[top:top + self.patch_size, left:left + self.patch_size] else: # 小图先resize到patch_size,保持宽高比 scale = self.patch_size / min(h, w) img = cv2.resize(img, (int(w * scale), int(h * scale))) img = img[:self.patch_size, :self.patch_size] # 随机翻转,增强数据多样性 if np.random.rand() > 0.5: img = img[:, ::-1, :] if np.random.rand() > 0.5: img = img[::-1, :, :] # 归一化到[0, 1],float32 img = img.astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1) return img数据增强方面,除了随机裁剪和翻转,刻意不要加颜色抖动。因为颜色一致性本来就是网络要学习的核心属性,你提前把颜色抖乱了,等于把学习目标搞模糊了。这个坑我一开始踩过,加了颜色抖动之后,训练了几百个epoch,增强出来的照片普遍偏淡。
5. ZeroDCE的核心代码实现
5.1 DCE-Net网络结构代码
ZeroDCE的骨干网络叫DCE-Net,非常轻量,核心是一串普通卷积加ReLU,末尾用softmax做权重融合,输出的是曲线参数α。完整的PyTorch实现如下:
import torch import torch.nn as nn import torch.nn.functional as F class DCE_Net(nn.Module): def __init__(self, n_curves=8): super().__init__() self.n_curves = n_curves self.relu = nn.ReLU(inplace=True) # 前5层提取特征,通道数固定32 self.conv1 = nn.Conv2d(3, 32, 3, 1, 1, bias=True) self.conv2 = nn.Conv2d(32, 32, 3, 1, 1, bias=True) self.conv3 = nn.Conv2d(32, 32, 3, 1, 1, bias=True) self.conv4 = nn.Conv2d(32, 32, 3, 1, 1, bias=True) self.conv5 = nn.Conv2d(32, 32, 3, 1, 1, bias=True) # 第6层输出3*n_curves个通道的原始预测 self.conv6 = nn.Conv2d(32, 3 * n_curves, 3, 1, 1, bias=True) def forward(self, x): x1 = self.relu(self.conv1(x)) x2 = self.relu(self.conv2(x1)) x3 = self.relu(self.conv3(x2)) x4 = self.relu(self.conv4(x3)) x5 = self.relu(self.conv5(x4)) # 输出形状: (B, 3*n_curves, H, W) raw = self.conv6(x5) # reshape为 (B, n_curves, 3, H, W) raw = raw.view(-1, self.n_curves, 3, x.shape[2], x.shape[3]) # 在曲线维度做softmax,得到权重 weights = torch.softmax(raw, dim=1) # 加权求和,得到每个通道每个迭代步的alpha # 输出形状: (B, 3, H, W) 的 n_curves 个,对应每次迭代的一组alpha alphas = [] for i in range(self.n_curves): alpha_i = weights[:, i, :, :, :] # (B, 3, H, W) alphas.append(alpha_i) return alphas # list of n_curves tensors等等,这个结构和论文的略有出入。原论文的做法是第6层输出24通道(n_curves=8时3*8=24),channel-wise重塑后在8个curve方向上softmax,最终得到8个3通道的alpha图。上面代码里权重是各个曲线批次上的softmax,然后直接取每个出。推理时,依次应用这8个alpha图:
def enhance_curve(x, alphas): # x: (B, 3, H, W) in [0,1] enhanced = x for alpha in alphas: # alpha: (B, 3, H, W) enhanced = enhanced + alpha * enhanced * (1 - enhanced) return torch.clamp(enhanced, 0.0, 1.0)实际用的时候,网络输出我建议直接统一成(B, 3*n_curves, H, W)然后用view去切,避免list迭代带来的性能损耗:
class DCE_Net_Fast(nn.Module): def __init__(self, n_curves=8): super().__init__() self.n_curves = n_curves self.relu = nn.ReLU(inplace=True) self.conv1 = nn.Conv2d(3, 32, 3, 1, 1) self.conv2 = nn.Conv2d(32, 32, 3, 1, 1) self.conv3 = nn.Conv2d(32, 32, 3, 1, 1) self.conv4 = nn.Conv2d(32, 32, 3, 1, 1) self.conv5 = nn.Conv2d(32, 32, 3, 1, 1) self.conv6 = nn.Conv2d(32, 3 * n_curves, 3, 1, 1) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) x = self.relu(self.conv3(x)) x = self.relu(self.conv4(x)) x = self.relu(self.conv5(x)) x = self.conv6(x) # (B, 3*N, H, W) B, C, H, W = x.shape # reshape为(B, 3, N, H, W)后按curve维度做softmax x = x.view(B, 3, self.n_curves, H, W) x = torch.softmax(x, dim=2) # 转回(B, 3*N, H, W),按通道切成N组alpha x = x.reshape(B, -1, H, W) return x.chunk(self.n_curves, dim=1)5.2 四个损失函数的PyTorch实现
这是ZeroDCE的灵魂,直接上代码:
class ZeroDCELoss(nn.Module): def __init__(self, exp_mean=0.6, col_weight=0.5, tv_weight=200): super().__init__() self.exp_mean = exp_mean self.col_weight = col_weight self.tv_weight = tv_weight def spatial_consistency(self, img_low, img_enh, patch_size=4): """ 空间一致性损失:对比输入图像和增强图像在局部块的平均强度 """ # 把图像下采样到固定块数,取每个块的通道均值 def avg_pool(x, p): # x: (B, C, H, W) B, C, H, W = x.shape x = x.view(B, C, H // p, p, W // p, p) x = x.permute(0, 1, 2, 4, 3, 5).contiguous() return x.mean(dim=(4, 5)) low_pool = avg_pool(img_low, patch_size) # (B, C, H/p, W/p) enh_pool = avg_pool(img_enh, patch_size) return torch.mean(torch.abs(low_pool - enh_pool)) def exposure_control(self, img_enh): """ 曝光控制:亮度图在16x16块内的均值向0.6回归 """ # 近似亮度:RGB三通道均值 brightness = img_enh.mean(dim=1, keepdim=True) # (B,1,H,W) B, _, H, W = brightness.shape # 16x16块平均 blocks = F.avg_pool2d(brightness, kernel_size=16, stride=16) return torch.mean(torch.abs(blocks - self.exp_mean)) def color_consistency(self, img_low, img_enh): """ 颜色一致性:增强前后RGB通道间差值得保持一致 """ def channel_diff(x): r = x[:, 0, :, :] g = x[:, 1, :, :] b = x[:, 2, :, :] return torch.stack([r - g, g - b, b - r], dim=1) diff_low = channel_diff(img_low) diff_enh = channel_diff(img_enh) return torch.mean(torch.abs(diff_low - diff_enh)) def illumination_smoothness(self, alphas): """ 照明平滑度:对每个alpha图做全变分约束 """ tv_loss = 0.0 for alpha in alphas: # alpha: (B, C, H, W) dx = torch.abs(alpha[:, :, :, 1:] - alpha[:, :, :, :-1]) dy = torch.abs(alpha[:, :, 1:, :] - alpha[:, :, :-1, :]) tv_loss += torch.mean(dx) + torch.mean(dy) return tv_loss / len(alphas) def forward(self, img_low, img_enh, alphas): loss_spa = self.spatial_consistency(img_low, img_enh) loss_exp = self.exposure_control(img_enh) loss_col = self.color_consistency(img_low, img_enh) loss_tv = self.illumination_smoothness(alphas) return loss_spa + loss_exp + self.col_weight * loss_col + self.tv_weight * loss_tv5.3 ZeroDCE训练循环
训练框架和普通PyTorch流程一致,有几个要点需要留意:
import torch.optim as optim from torch.utils.data import DataLoader from tqdm import tqdm def train_zero_dce(train_dir, epochs=200, batch_size=8, lr=1e-4): model = DCE_Net_Fast(n_curves=8).cuda() optimizer = optim.Adam(model.parameters(), lr=lr) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=50, gamma=0.5) criterion = ZeroDCELoss() dataset = LowLightDataset(train_dir, patch_size=256) loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=4, pin_memory=True) for epoch in range(epochs): model.train() total_loss = 0.0 pbar = tqdm(loader, desc=f"Epoch {epoch+1}/{epochs}") for batch in pbar: batch = batch.cuda() alphas = model(batch) enhanced = batch for alpha in alphas: enhanced = enhanced + alpha * enhanced * (1 - enhanced) enhanced = torch.clamp(enhanced, 0.0, 1.0) loss = criterion(batch, enhanced, alphas) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() pbar.set_postfix(loss=loss.item()) scheduler.step() if (epoch + 1) % 20 == 0: torch.save(model.state_dict(), f"zero_dce_epoch_{epoch+1}.pth")训练时的几个经验:
- batch size不要太小。虽然DCE-Net没有BatchNorm,但空间一致性损失和曝光控制损失都依赖统计信息,batch太小时这些统计波动大,loss震荡明显。我用8起步,显存不够就降到4。
- 学习率从1e-4开始,每50个epoch衰减一半。太大会导致alpha图突变,loss直接炸到无穷。
- 曝光均值0.6不是死参数。如果是监控图像这种低照度场景,0.55的效果更自然,0.6会让暗部提得太猛。如果是对比度很强的夜景,可以试0.65。
6. SCI的核心代码实现
6.1 轻量级照明估计与自校准模块
SCI的网络结构比ZeroDCE还要轻。它由三个子模块构成:特征提取、照明估计、自校准。核心是照明估计模块,结构非常简单:一个3x3卷积加ReLU,逐层提特征,最后映射成单通道照明图。
class IlluminationNet(nn.Module): """ 照明估计网络:输入低照度图像,输出照明分量L """ def __init__(self): super().__init__() self.relu = nn.ReLU(inplace=True) # 特征提取 + 照明估计 self.conv1 = nn.Conv2d(3, 16, 3, 1, 1) self.conv2 = nn.Conv2d(16, 16, 3, 1, 1) self.conv3 = nn.Conv2d(16, 32, 3, 1, 1) self.conv4 = nn.Conv2d(32, 32, 3, 1, 1) self.conv5 = nn.Conv2d(32, 1, 3, 1, 1) # 单通道照明图 def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) x = self.relu(self.conv3(x)) x = self.relu(self.conv4(x)) illum = torch.sigmoid(self.conv5(x)) # 照明值在[0,1] return illum class SelfCalibratedModule(nn.Module): """ 自校准模块:修正照明估计结果 """ def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, 3, 1, 1) self.conv2 = nn.Conv2d(16, 16, 3, 1, 1) self.conv3 = nn.Conv2d(16, 16, 3, 1, 1) self.conv4 = nn.Conv2d(16, 1, 3, 1, 1) self.relu = nn.ReLU(inplace=True) def forward(self, illum): residual = self.relu(self.conv1(illum)) residual = self.relu(self.conv2(residual)) residual = self.relu(self.conv3(residual)) residual = self.conv4(residual) # 残差连接,保持照明图基本形状 return torch.clamp(illum + residual, 0.0, 1.0)6.2 SCI的增强流程与损失函数
SCI推理时采用的迭代式增强:估计照明 -> 用照明还原图像 -> 更新照明 -> 再用更新后的照明还原,循环N次。实际代码:
class SCI_NET(nn.Module): def __init__(self, iterations=3): super().__init__() self.iterations = iterations self.illumination = IlluminationNet() self.calibration = SelfCalibratedModule() def forward(self, x): # x: 低照度输入 [0,1] enhanced = x for _ in range(self.iterations): illum = self.illumination(enhanced) # 自校准 illum = self.calibration(illum) # 逐元素除法还原反射图,加eps防止除零 enhanced = x / (illum + 1e-3) enhanced = torch.clamp(enhanced, 0.0, 1.0) return enhancedSCI的损失比ZeroDCE简单得多:
class SCILoss(nn.Module): def __init__(self, exp_weight=0.1, smooth_weight=0.5): super().__init__() self.exp_weight = exp_weight self.smooth_weight = smooth_weight def reconstruction_loss(self, enhanced, low): # 重构约束:增强结果不能和输入脱节 # 简单地用L1约束增强图和原图之间的整体活力差异 return torch.mean(torch.abs(enhanced - low)) def illumination_smoothness(self, illum): dx = torch.abs(illum[:, :, :, 1:] - illum[:, :, :, :-1]) dy = torch.abs(illum[:, :, 1:, :] - illum[:, :, :-1, :]) return torch.mean(dx) + torch.mean(dy) def exposure_control(self, enhanced, target=0.6): brightness = enhanced.mean(dim=1, keepdim=True) return torch.mean(torch.abs(brightness - target)) def forward(self, enhanced, low, illum): rec_loss = self.reconstruction_loss(enhanced, low) smooth_loss = self.illumination_smoothness(illum) exp_loss = self.exposure_control(enhanced) return rec_loss + self.smooth_weight * smooth_loss + self.exp_weight * exp_loss这里需要特别注意:SCI的原始论文有个更复杂的结构,但核心思路就是我上面写的这套。我在实践时用这个简版SCI跑出的效果已经不错,而且模型参数量只有约3.3万,单张1080P图像在3090上推理只要几毫秒,比ZeroDCE快接近一倍。
7. 推理部署与实测效果对比
7.1 单张图片推理脚本
训练完之后,推理代码要尽量简洁,方便接到业务里。我写了一个通用的推理函数,两个模型都能用:
import cv2 import torch import numpy as np def enhance_image(model_path, img_path, model_type='zerodce', device='cuda'): if model_type == 'zerodce': model = DCE_Net_Fast(n_curves=8) state = torch.load(model_path, map_location=device) model.load_state_dict(state) model.to(device).eval() elif model_type == 'sci': model = SCI_NET(iterations=3) state = torch.load(model_path, map_location=device) model.load_state_dict(state) model.to(device).eval() img = cv2.imread(img_path) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w = img_rgb.shape[:2] # 缩放:模型训练时用的是256x256,但推理时可以直接用原分辨率 # 如果原图太大,建议等比缩放,防止显存溢出 max_side = 1200 scale = min(1.0, max_side / max(h, w)) if scale < 1.0: img_rgb = cv2.resize(img_rgb, (int(w * scale), int(h * scale))) tensor = torch.from_numpy(img_rgb.astype(np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0).to(device) with torch.no_grad(): if model_type == 'zerodce': alphas = model(tensor) enhanced = tensor for alpha in alphas: enhanced = enhanced + alpha * enhanced * (1 - enhanced) else: enhanced = model(tensor) enhanced = enhanced.squeeze(0).permute(1, 2, 0).cpu().numpy() enhanced = np.clip(enhanced * 255.0, 0, 255).astype(np.uint8) enhanced_bgr = cv2.cvtColor(enhanced, cv2.COLOR_RGB2BGR) if scale < 1.0: enhanced_bgr = cv2.resize(enhanced_bgr, (w, h)) cv2.imwrite('enhanced_result.jpg', enhanced_bgr) return enhanced_bgr7.2 两个模型的效果、速度与适用场景对比
拿一组真实夜景照片实测后,我的结论很明确:
| 实测项 | ZeroDCE | SCI |
|---|---|---|
| 1080P推理耗时(3090) | 约15ms | 约8ms |
| 模型参数量 | 约7.9万 | 约3.3万 |
| 平均亮度提升 | 明显,偏"通透" | 同参数下稍保守 |
| 暗部细节 | 边缘保持好,但极暗区域容易过曝 | 极暗区域恢复更好,细节更稳 |
| 色彩倾向 | 默认偏中性 | 轻微偏暖(照明估计的副产品) |
| 训练收敛速度 | 200 epoch收敛到稳定 | 100 epoch左右就稳定 |
| 视频平稳性 | 逐帧处理有时闪烁 | 照明分量自然平滑,更适合视频 |
特别说一下色彩。ZeroDCE因为有明确的颜色一致性损失,色彩还原更中性;SCI的照明除法在极端暗部会轻微引入暖色调,但这个问题可以用小的颜色校准层补救,也可以用ZeroDCE的颜色一致性损失给SCI做辅助训练。我在实际项目里试过这个组合思路,效果比单独训练更好,算是交叉实验的一个小技巧。
8. 训练与调参避坑实录
8.1 曝光控制因子怎么调:0.6不是万能药
很多人直接把exp_mean设成论文默认的0.6,跑完之后发现图像"亮得发假"。问题在于0.6是论文在特定数据集上的最优值,而不是普适值。
我的实测经验:输入图像整体特别暗(比如平均亮度只有0.1到0.15)时,把exp_mean调到0.7反而更好,让网络能拉得更猛;如果原图已经有部分区域较亮(比如城市夜景霓虹灯多),exp_mean应该降到0.55左右,否则高光区域会过曝。
调试最快的方式不是反复训练,而是先拿一个训练好的模型,在推理时手动给增强结果叠加一个二次调整:
# 推理后处理:gamma微调 def post_adjust(img, gamma=1.0): if gamma == 1.0: return img img_float = img.astype(np.float32) / 255.0 img_float = np.power(img_float, gamma) return (img_float * 255).astype(np.uint8)gamma大于1变暗,小于1提亮。这个后处理在调参阶段可以帮你快速找到感觉得再回去改exp_mean重训。
8.2 曲线迭代次数N的影响
ZeroDCE的N(迭代次数)默认8。我测试过N从4到16的配置:
- N=4:推理快约一倍,增强效果偏保守,对极暗场景提亮不够;
- N=8:默认值,最均衡;
- N=16:暗部提亮更充分,但容易过曝,且训练时间明显增加。
一个优化技巧是:训练时用N=8,推理时如果觉得不够亮,可以临时把N改成16继续迭代。因为alpha图已经训练好,多迭代几次相当于"重复加光"。实测这种"训练8推理16"的组合往往比直接训N=16更稳定,因为N=8训练出的曲线参数每个都经过了充分优化。
8.3 训练数据多样性陷阱:全用LOL会导致惨白
这是一个非常重要的坑。我第一次训练时只用了LOL数据集的暗光子集,跑出来的增强结果普遍惨白、发灰,像蒙了一层雾。
分析原因:LOL数据集的特点是"虽然暗,但全局光照比较均匀",它的暗图在统计上偏向低照度、低对比度。网络在这个分布上训练,学到的曲线策略是"无脑提亮,反正输入都这么暗"。一旦遇到真实夜景这种局部光影反差大的场景,就失控了。
解决办法是引入多样性数据。我最后采用的混合比例是:LOL暗光子集负责基础场景,ExDark覆盖各种弱光类型,再加20%自己手机拍的真实夜景。混合训练之后,增强效果立刻自然了很多,对比度也正常了。
8.4 大图推理的显存瓶颈与分块策略
ZeroDCE和SCI都是全卷积网络,理论上可以处理任意分辨率图像,但显存是硬约束。我实测把4000x3000的原图直接喂进ZeroDCE,3090直接OOM。
解决思路参考语义分割的做法——分块推理加重叠融合:
def enhance_large_image(model, img_tensor, patch_size=1024, overlap=128): """把大图切成有重叠的块,分别增强后融合""" C, H, W = img_tensor.shape enhanced = torch.zeros_like(img_tensor) weight = torch.zeros_like(img_tensor) stride = patch_size - overlap for top in range(0, H, stride): for left in range(0, W, stride): bottom = min(top + patch_size, H) right = min(left + patch_size, W) patch = img_tensor[:, top:bottom, left:right].unsqueeze(0) with torch.no_grad(): if model_type == 'zerodce': alphas = model(patch) eh = patch for alpha in alphas: eh = eh + alpha * eh * (1 - eh) else: eh = model(patch) # 重叠区域用线性权重融合,避免拼接痕迹 h_start, w_start = top, left h_end, w_end = bottom, right enhanced[:, h_start:h_end, w_start:w_end] += eh.squeeze(0) weight[:, h_start:h_end, w_start:w_end] += 1.0 return enhanced / torch.clamp(weight, min=1e-8)重叠区域取128像素就够了,钱花在刀刃上。如果没有重叠,拼接处会有明显的亮度跳变。后来我记得有个朋友做视频增强时也踩了类似的坑,分块不能只分一次,要配合重叠权重。
8.5 视频增强的时间一致性
最后说一个高级话题:视频帧增强的时间稳定性。很多做视频处理的朋友可能以为"逐帧跑增强模型再拼起来就行",实测会出现严重的亮度闪烁,因为相邻帧的增强参数会有微小差异,但逐帧累积后看起来就变成忽明忽暗。
如果项目对时间稳定性要求高,建议优先选SCI或者类似的照明估计模型,然后加一个时间平滑后处理:对前后三帧的照明分量做指数移动平均。由于SCI天然输出照明分量,这个操作几乎零成本。
def temporal_smooth_illum(prev_illum, curr_illum, alpha=0.6): # alpha越大,时间平滑越强,但运动拖影更明显 return alpha * prev_illum + (1 - alpha) * curr_illum这个技巧在我做的夜间监控视频增强里效果非常明显,闪烁几乎不可见,代价只是轻微的运动拖影,在监控场景完全可接受。
最后再分享两个实战心得
第一个心得关于模型选择:如果任务核心是"快速拿到好看的静态照片",优先上ZeroDCE,它训练省心、损失函数直观,调参空间大;如果要做视频流、移动端部署或者实时预览,果断选SCI,推理速度和小体积优势太明显了。
第二个心得关于评估方式:别只看PSNR和SSIM,这两个指标在无监督增强里很骗人。我建议每次训练完,固定挑十张不同场景的图,目测对比三个维度——暗部细节是否恢复、高光是否过曝、色彩是否自然,然后让身边不搞技术的人投票选哪张"看起来最舒服"。无监督增强的最终评价标准就是人的主观感受,指标再漂亮不如人眼看着舒服,这行当里的大部分真问题都出在"指标过了但人看着不对"上面。