ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

无监督低照度增强实战:ZeroDCE与SCI模型原理及PyTorch实现

无监督低照度增强实战:ZeroDCE与SCI模型原理及PyTorch实现 深更半夜翻相册看到前几天拍的夜景照片噪点一片暗部细节完全糊死整张图就像泼了一瓶酱油。大多数人的第一反应是拉曲线、加曝光结果高光溢出天空变成一块死白。传统直方图均衡在低照度下不仅提亮有限还会把噪声放大成满屏彩点。这个场景我想搞计算机视觉的朋友都懂——低照度增强说难不难但要做到自然、无伪影、不偏色真不是套个OpenCV函数能解决的。最近我在两个无监督低照度增强模型上折腾了大概两周跑了大量实验一个是ZeroDCE全称Zero-Reference Deep Curve Estimation另一个是SCISelf-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次曲线每次使用不同的α。我实测N8效果已经足够再往上提升不显著计算量却线性增长。为了让曲线具备空间自适应性ZeroDCE让α不是单个数而是一个和输入图像同尺寸的多通道参数图实际是3通道对应RGB通道各一张或者说按通道逐像素调整。网络输出的α图规格为3 * N3个通道乘以迭代次数在训练中通过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和反射分量RI 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还增加曝光控制损失但核心还是重构加平滑。两个模型放到一起对比的话我的感受是对比维度ZeroDCESCI核心原理亮度曲线映射照明分量估计网络输出曲线参数α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_size256, color_jitterFalse): 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_curves8): super().__init__() self.n_curves n_curves self.relu nn.ReLU(inplaceTrue) # 前5层提取特征通道数固定32 self.conv1 nn.Conv2d(3, 32, 3, 1, 1, biasTrue) self.conv2 nn.Conv2d(32, 32, 3, 1, 1, biasTrue) self.conv3 nn.Conv2d(32, 32, 3, 1, 1, biasTrue) self.conv4 nn.Conv2d(32, 32, 3, 1, 1, biasTrue) self.conv5 nn.Conv2d(32, 32, 3, 1, 1, biasTrue) # 第6层输出3*n_curves个通道的原始预测 self.conv6 nn.Conv2d(32, 3 * n_curves, 3, 1, 1, biasTrue) 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, dim1) # 加权求和得到每个通道每个迭代步的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_curves8时3*824channel-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_curves8): super().__init__() self.n_curves n_curves self.relu nn.ReLU(inplaceTrue) 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, dim2) # 转回(B, 3*N, H, W)按通道切成N组alpha x x.reshape(B, -1, H, W) return x.chunk(self.n_curves, dim1)5.2 四个损失函数的PyTorch实现这是ZeroDCE的灵魂直接上代码class ZeroDCELoss(nn.Module): def __init__(self, exp_mean0.6, col_weight0.5, tv_weight200): 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_size4): 空间一致性损失对比输入图像和增强图像在局部块的平均强度 # 把图像下采样到固定块数取每个块的通道均值 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(dim1, keepdimTrue) # (B,1,H,W) B, _, H, W brightness.shape # 16x16块平均 blocks F.avg_pool2d(brightness, kernel_size16, stride16) 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], dim1) 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, epochs200, batch_size8, lr1e-4): model DCE_Net_Fast(n_curves8).cuda() optimizer optim.Adam(model.parameters(), lrlr) scheduler optim.lr_scheduler.StepLR(optimizer, step_size50, gamma0.5) criterion ZeroDCELoss() dataset LowLightDataset(train_dir, patch_size256) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue, num_workers4, pin_memoryTrue) for epoch in range(epochs): model.train() total_loss 0.0 pbar tqdm(loader, descfEpoch {epoch1}/{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(lossloss.item()) scheduler.step() if (epoch 1) % 20 0: torch.save(model.state_dict(), fzero_dce_epoch_{epoch1}.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(inplaceTrue) # 特征提取 照明估计 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(inplaceTrue) 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, iterations3): 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_weight0.1, smooth_weight0.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, target0.6): brightness enhanced.mean(dim1, keepdimTrue) 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_typezerodce, devicecuda): if model_type zerodce: model DCE_Net_Fast(n_curves8) state torch.load(model_path, map_locationdevice) model.load_state_dict(state) model.to(device).eval() elif model_type sci: model SCI_NET(iterations3) state torch.load(model_path, map_locationdevice) 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 两个模型的效果、速度与适用场景对比拿一组真实夜景照片实测后我的结论很明确实测项ZeroDCESCI1080P推理耗时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, gamma1.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的配置N4推理快约一倍增强效果偏保守对极暗场景提亮不够N8默认值最均衡N16暗部提亮更充分但容易过曝且训练时间明显增加。一个优化技巧是训练时用N8推理时如果觉得不够亮可以临时把N改成16继续迭代。因为alpha图已经训练好多迭代几次相当于重复加光。实测这种训练8推理16的组合往往比直接训N16更稳定因为N8训练出的曲线参数每个都经过了充分优化。8.3 训练数据多样性陷阱全用LOL会导致惨白这是一个非常重要的坑。我第一次训练时只用了LOL数据集的暗光子集跑出来的增强结果普遍惨白、发灰像蒙了一层雾。分析原因LOL数据集的特点是虽然暗但全局光照比较均匀它的暗图在统计上偏向低照度、低对比度。网络在这个分布上训练学到的曲线策略是无脑提亮反正输入都这么暗。一旦遇到真实夜景这种局部光影反差大的场景就失控了。解决办法是引入多样性数据。我最后采用的混合比例是LOL暗光子集负责基础场景ExDark覆盖各种弱光类型再加20%自己手机拍的真实夜景。混合训练之后增强效果立刻自然了很多对比度也正常了。8.4 大图推理的显存瓶颈与分块策略ZeroDCE和SCI都是全卷积网络理论上可以处理任意分辨率图像但显存是硬约束。我实测把4000x3000的原图直接喂进ZeroDCE3090直接OOM。解决思路参考语义分割的做法——分块推理加重叠融合def enhance_large_image(model, img_tensor, patch_size1024, overlap128): 把大图切成有重叠的块分别增强后融合 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, min1e-8)重叠区域取128像素就够了钱花在刀刃上。如果没有重叠拼接处会有明显的亮度跳变。后来我记得有个朋友做视频增强时也踩了类似的坑分块不能只分一次要配合重叠权重。8.5 视频增强的时间一致性最后说一个高级话题视频帧增强的时间稳定性。很多做视频处理的朋友可能以为逐帧跑增强模型再拼起来就行实测会出现严重的亮度闪烁因为相邻帧的增强参数会有微小差异但逐帧累积后看起来就变成忽明忽暗。如果项目对时间稳定性要求高建议优先选SCI或者类似的照明估计模型然后加一个时间平滑后处理对前后三帧的照明分量做指数移动平均。由于SCI天然输出照明分量这个操作几乎零成本。def temporal_smooth_illum(prev_illum, curr_illum, alpha0.6): # alpha越大时间平滑越强但运动拖影更明显 return alpha * prev_illum (1 - alpha) * curr_illum这个技巧在我做的夜间监控视频增强里效果非常明显闪烁几乎不可见代价只是轻微的运动拖影在监控场景完全可接受。最后再分享两个实战心得第一个心得关于模型选择如果任务核心是快速拿到好看的静态照片优先上ZeroDCE它训练省心、损失函数直观调参空间大如果要做视频流、移动端部署或者实时预览果断选SCI推理速度和小体积优势太明显了。第二个心得关于评估方式别只看PSNR和SSIM这两个指标在无监督增强里很骗人。我建议每次训练完固定挑十张不同场景的图目测对比三个维度——暗部细节是否恢复、高光是否过曝、色彩是否自然然后让身边不搞技术的人投票选哪张看起来最舒服。无监督增强的最终评价标准就是人的主观感受指标再漂亮不如人眼看着舒服这行当里的大部分真问题都出在指标过了但人看着不对上面。
返回列表