1. 低光照增强到底难在哪:先看清问题再动手当时我接手这个题目的时候,第一反应是:“低光照增强不就是把暗部提亮吗?”后来真正跑起来才发现,这事儿坑太深。直接提亮一张暗图,等于把里面的噪声一起放大,原本勉强能看的地方,提亮之后全是花花绿绿的噪点,而且暗部细节本来就模糊,硬拉亮度也拉不出来。原因很简单:暗光环境下传感器收到的光子数量少,信号本身被噪声淹没,信噪比极低。这已经不是对比度拉伸能解决的问题了,想要得到干净的亮图,必须把光照和噪声当作一个耦合问题来处理。深入一点拆解,低光图像其实承载了三层叠加的退化。第一层是亮度整体偏低,动态范围被压缩,很多区域直接贴到0附近。第二层是颜色失真,暗部容易出现偏绿、偏蓝甚至偏紫的色偏,这是传感器在低照度下彩色滤波阵列响应不一致导致的。第三层是噪声非均匀分布,极暗区域的噪声强度远高于中间调,而且是信号相关的——亮度越低,噪声越大。三层退化叠加在一起,任何一个线性映射都无法同时解决。你压暗部噪声,亮部也跟着变暗;你提亮中间调,暗部噪声又跟着放大了。传统方法大致分两类。一类是直方图均衡、伽马校正这种像素级调整,优点是快,几毫秒就能出结果;缺点是全局操作,暗部和亮部互相牵连,很容易把天空提得死白,或者把灯箱区域过曝成一团。另一类是Retinex系列的分解法,把图像拆成反射率和照度,再对着照度分量做调整。经典Retinex的问题在于分解质量不稳定,尤其在纹理密集的区域,照度分量估计不准就会出现光晕和伪影,暗部还会被压出奇怪的色块。到了深度学习时代,大家改成“学一个从暗图到亮图的映射”,让网络自己去拟合光照和噪声之间的复杂关系,效果确实比传统方法上了一个台阶。近几年的低光增强工作基本都走这条路。但同样是用深度学习,早期很多工作都是单一网络端到端直出,输入暗图、输出亮图。这种做法的瓶颈在于:让一个网络同时处理“提亮”和“去噪去伪影”两类任务,复杂度太高,网络容量和训练数据稍显不足,结果就会顾此失彼。后来我看到一些工作开始把任务拆开,用分阶段或分分支的思路来降低每个模块的学习负担。双分支残差结构就是这么来的——一个分支管全局的光照估计,一个分支管局部的纹理恢复,最后再用残差把信息融合起来。这个思路相当于把Retinex理论用现代网络结构重新实现了一遍,而且在实验里是真的能拉开效果差距。如果你正在考虑拿这个方向做毕业设计,它有几个非常实际的好处。任务本身清晰,效果可视化强,汇报答辩时放一张对比图,人人都能看懂你在做什么。技术栈完整,从数据处理、网络设计、训练调参到部署演示,一整套流程你都会走一遍,锻炼扎实。更关键的是可扩展性好,换一个骨干网络、加一个注意力模块、调整分支融合方式,都是顺理成章的改进点,中期检查和答辩都有东西可说。所以无论从课题难度还是产出角度看,这都属于性价比很高的题目。2. 双分支残差结构的设计逻辑:把“提亮”和“修复”拆开双分支结构不是拍脑袋定的,它的根子在Retinex理论。这个经典模型把一张图像建模成反射率R和照度L的乘积:I = R × L。人眼感知的“颜色”和“纹理”主要在R里面,照度L则是外部光源的分布——今天晴天还是阴天、哪里有阴影、哪里有路灯,这些都是L决定的。低光增强要做的事情,本质上就是把L提上来,同时保留R。这就引出一个关键判断:R和L的性格完全不同。L是低频主导的平滑场,变化平缓;R是高频主导的细节场,含有大量边缘和纹理。如果让一个网络同时学这两个映射,它需要兼顾两种截然不同的特征分布,结果往往是学会了提亮,却把细节磨没了,或者保住了细节但亮度分布不均匀。双分支就是顺势而为的解法。分支A做照度估计,输入原图,输出一个单通道的、低频主导的照度图。这个分支的网络倾向是“全局性”的,感受野要大,要能看到整张图的明暗分布,输出尽量平滑,不需要太多高频细节。分支B做细节恢复,输入原图,输出的是图像的高频细节和颜色修正信息。这个分支需要密集的局部特征提取能力,小卷积核堆叠、注意力机制都可以往这里放,目标是尽量把暗部的纹理从噪声里捞出来。两个分支的输出怎么融合?直接做像素相加不推荐,因为照度的量级和细节特征的量级完全不一样,硬加会导致强光区域过曝。我当时用了一个很常见的做法:分支A输出的照度图作为空间权重,分支B输出的细节图加到主干上,最后再过一个轻量的融合卷积层。简单来说就是一个乘、一个加,用照度控制整图的亮度基准,用细节分支修正局部纹理。这个融合方式在实现上非常轻量,但效果非常直观,收敛也快。然后是残差的部分。为什么一定要用残差?因为增强任务的输出和目标之间是高度相关的——亮图和暗图共享绝大部分结构信息,真正要学的是“差异”,而不是从零生成一张新图。残差学习相当于让网络只去学那个增量。你可以把网络想象成一个助手,它不需要重新画一幅画,只需要在草稿上添加笔触和高光。学习难度降了,收敛速度自然快了。而且残差连接给梯度提供了高速公路,深层网络训练起来不容易退化。我用的结构是:主干输入原图,经过几层卷积提取特征,在最后输出处把输入图像加回去,也就是“输出 = 特征回归结果 + 输入图像”。这个全局残差在低光任务里特别管用,网络相当于只负责预测增强量,亮度基线由原图兜着——训练初期输出不会发灰,也不容易出现灾难性遗忘。实际结构里还有个容易被忽略的细节:光照分支的输出要过softplus激活,确保输出恒为正数。如果照度图出现负值,乘到细节分支上会直接导致颜色反转——我见过复现代码跑出来颜色完全乱掉的,查到最后就是卡在这一步。所以我在光照分支的最后一层,先softplus,再做一个轻微clamp,保证数值稳定落在正区间。下面给一个我当时验证过的结构参数参考,简单但稳定:模块输入操作输出通道光照分支RGB原图先下采样几层获取全局感受野,再逐层上采样回原尺寸,最后softplus激活1细节分支RGB原图3x3卷积堆叠 + 跳跃连接,保持空间分辨率不降太多32主干融合原图 + 两支输出拼接后过3x3卷积,输出3通道增强结果3全局残差增强结果 + 原图逐像素相加3对应代码骨架大概是这个意思(PyTorch风格):class DualBranchEnhancer(nn.Module): def __init__(self): super().__init__() self.illum = IlluminationBranch() # 输出1通道照度 self.detail = DetailBranch() # 输出32通道细节 self.fuse = nn.Conv2d(3 + 1 + 32, 3, 3, padding=1) def forward(self, x): I = self.illum(x) # 照度估计 D = self.detail(x) # 细节恢复 fused = torch.cat([x, I, D], dim=1) out = self.fuse(fused) return out + x # 全局残差这里有一个我调试下来觉得特别重要的经验:两个分支千万不要用完全相同的结构。如果两个分支长得一模一样,它们学出来的特征会趋于一致,双分支就失去了意义。光照分支刻意走“低分辨率优先”的路径,获取全局感受野;细节分支保持“高分辨率优先”的路径,专注局部纹理。这种不对称的设计会强迫网络分化,训练出来的两个分支才真正各司其职。我试过把两个分支做成对称的,结果训练完发现它们各自学到了几乎相同的东西,融合之后效果还不如单个分支。3. 训练落地:数据、损失函数和调参顺序3.1 数据集怎么选低光增强和其他视觉任务有个明显不同:很多任务可以用无标签数据,但这个任务如果有配对数据会好做很多。配对指同一场景下同时有低光图和正常光图,让网络知道“眼前这张暗图应该变成什么样”。我优先推荐公开的成对数据集,例如LOL和Adobe FiveK这类。LOL是真实场景拍摄的短长曝光配对,图像尺寸不大,但优势在“真实”两个字——里面的噪声分布是传感器真实产生的,不是模拟出来的,训练出来的模型拿到真实暗光环境里不容易露怯。FiveK是摄影师手动后期后的结果,配对质量高,风格上偏“修图”,比较适合先做预训练,再拿LOL微调。如果手头数据量不够,还有一招是合成退化:拿正常光图做gamma变换压暗,再叠加高斯噪声或者泊松噪声,生成一批合成低光图。合成数据的优势是量大、配对精确,缺点是噪声类型和真实感光元件的行为差距较大,所以只能作为补充,不能全部依赖合成数据。我做数据增强的时候,除了常规的水平翻转、随机裁剪,还专门加了“随机gamma扰动”,让增强后的图亮度水平飘忽不定,逼着网络去适应不同的暗光程度,而不是死记某一档亮度。实际训练时用随机裁剪的小patch输入,一是显存友好,二是相当于制造了更多样的训练样本。3.2 损失函数怎么组合损失函数是低光增强里最玄学也最容易翻车的地方。只用一个L1损失,训练出来的图容易“均值回归”——整体颜色发暗,局部细节被平均掉。纯感知损失又容易让颜色跑偏,出现过饱和或者偏色的情况。我实践下来比较稳的组合是三部分:第一个是L1复原损失,管住整体亮度重建的准确性,相比MSE,L1在边缘处更宽容,不容易把边缘训糊。第二个是结构损失,用1减去SSIM来计算,这个分量专门约束局部结构,确保纹理和边缘不被磨平。第三个是感知损失,用预训练的图像分类骨干网络(比如VGG系列)提取中间层特征,再计算特征图之间的L1距离,这个分量管的是“看起来像不像”——约束两幅图在高频语义特征上的分布接近,对色偏也有明显的抑制作用。三个损失的权重需要反复试。我最终定的是1 : 0.2 : 0.5,也就是说L1主打、感知辅助、SSIM做微调。这样一个组合训出来的图,整体亮度准确、边缘干净、暗部有纹理,不会出现明显的色偏。还有一个容易被忽略的细节:把损失同时加在输出和光照分支上。也就是说,光照分支除了参与主损失的反向传播,还要单独接收一个“照度真值”的监督。照度真值怎么来?对低光图和高光图做逐像素比值,再做一个高斯模糊平滑,就能得到一个粗糙的照度估计。加上这个辅助监督之后,光照分支收敛明显更快,分解出来的照度图也更平滑,融合阶段不容易出现光晕。3.3 超参数和训练顺序训练细节直接决定最终效果,而且顺序很重要。我的经验是先把主结构跑通,再慢慢加辅助监督和损失项,不要一次性全部堆上去。优化器:AdamW,初始学习率2e-4,weight decay设为1e-4。学习率策略:余弦退火,把学习率在训练末期降到最低点。迭代轮数:300轮左右。前200轮主干结构基本稳定,后100轮主要在做细节打磨。Batch size:受显存限制,我用的是8,输入裁剪220x220。如果你的显卡显存小,Batch size降到4,学习率也要等比缩小,不然loss曲线会抖得很厉害。训练顺序:先只训“主干+全局残差”,等loss曲线的下降趋势稳定了,再加入光照分支的辅助监督;最后再把感知损失的权重逐步加进来。渐进式加损失,比一次性全开容易调通得多。提示:低光图像本身的动态范围差异非常大,输入像素值统一归一化到0到1之间,不要在原图上做任何对比度拉伸等预处理。网络需要自己去学习亮度分布,提前做归一化反而会破坏它的学习空间。我还有一个实战发现:如果开着BatchNorm训练,训练集上的效果和测试集上的效果差距会拉得比较大,这个问题在图像翻译类任务里尤其突出。因为每个batch的低光图像亮度风格差异很大,BatchNorm统计的均值方差会被拉偏。改成InstanceNorm之后,样本内部的亮度均值被单独归一化,验证集上的指标普遍提升了一个多dB,这个改动成本极低,收益却很直接。4. 实验评估:指标、对比实验与消融分析4.1 客观指标怎么算主流指标就三个:PSNR、SSIM、LPIPS。PSNR在意像素级重建误差,数值越高越好;SSIM在意结构相似度,区间在0到1之间,越高越好;LPIPS是深度学习时代的感知指标,用预训练网络的特征距离来衡量两张图“看起来像不像”,数值越低越好。毕设论文里这三个指标都要给,但它们的用途完全不同,答辩时如果讲不清楚很容易被问倒。需要注意的一个技术细节是:PSNR和SSIM要在哪个颜色空间算。很多经典方法是在YCbCr空间的Y通道上计算的,因为人眼对亮度最敏感,Y通道数值能直接反映亮度重建的好坏。也有不少工作是在RGB三个通道上分别算、再取平均,两种口径得到的数值差异不小。如果对比实验里各家用各家的口径,数据就会失真,写论文时一定要统一口径,并且在一开始就在实验章节写清楚计算方式。4.2 对比方法怎么选对比方法的选取要有梯度,不能只找比自己弱的方法比,那样说服力不够。我当时选了四个典型代表:一个传统Retinex类方法打底,一个早期端到端深度学习模型,一个分阶段处理的深度模型,再加一个近期效果比较好的工作。这样摆出来的对比逻辑很顺:传统方法在速度上有优势但质量不足,端到端网络在整体指标上更好但暗部细节仍有缺失,分阶段方法效果好一点但结构复杂、计算开销大。自己的双分支方法在这四者之间,无论在客观指标还是视觉结果上都处于一个清晰的位置。要提醒一点:复现别人的方法时,一定要用对方公开的检查点,或者严格按对方默认配置重新训练。不要拿一个没有收敛的模型来对比,那样赢了也没有意义。那些公开模型的运行环境最好也保持一致,不然生成的图像风格差异会影响判断。4.3 消融实验怎么做消融实验是证明“双分支和残差这些设计有效”的关键证据。我的消融方案按顺序做三组。第一组,去掉光照分支,只保留细节分支,相当于变回普通单网络端到端,效果差异最直观——暗部提亮明显不足,或者提亮了但颜色偏灰。第二组,去掉全局残差,让网络从零开始生成亮图,这一改动会导致训练难度上升,收敛变慢,而且最终结果容易发灰。第三组,把光照分支和细节分支的内部结构互换,让光照分支变深而细节分支变浅,这一组主要是验证“为什么细节分支要保持高分辨率路径”。每组消融都要单独训练到收敛,不能只跑几步就拿出来比。三组消融跑完,网络里每个组件的必要性都有了直接证据,答辩时被问“你这些结构凭什么有效”就不慌。4.4 指标高不等于效果好这是我做完整课题后最有价值的经验之一。实验结果里经常出现一个情况:某张图的PSNR很高、SSIM也不错,但人眼看着就是发灰、发闷,像是蒙了一层雾;而另一张图指标稍微低一点,视觉上却自然得多。原因在于PSNR和SSIM都是对整张图做统计平均,低光增强里最重要的暗部区域在全局统计中的权重天然就被低估了——暗部像素占图像面积就那么一点,它的恢复质量好坏,对全局指标的贡献远不如亮部区域。所以我实验阶段养成一个习惯:每次训完一个模型,不只看测试集均值,一定要挑几张有代表性的暗图,把增强结果和真值做局部裁切放大,专门看暗部纹理恢复得怎么样、色彩过渡自然不自然。这个主观审查甚至比指标数字还要关键。答辩时如果老师问“你的指标提升了多少”,先用数据回答,再补一句“我们更关注暗部区域的恢复质量,为此做了局部可视化对比”——这个回答比单报一个数字严密得多,而且展示出你真的理解这个任务的核心难点。5. 应用实现:从PyTorch模型到可演示的完整流程5.1 推理流程和性能瓶颈模型训练完之后,应用侧要解决的第一个问题就是推理性能。低光增强的部署目标很明确:单张照片的增强控制在两三秒以内都可以接受,但如果要给视频流或者连续处理多张图片,就必须考虑性能优化了。我的推理流程是:读图 -> 调整尺寸到模型输入 -> 前向推理 -> 后处理(反归一化、可选的颜色修正)-> 保存结果。整个过程的主要耗时集中在前向推理上,而前向推理的瓶颈主要在光照分支——因为它的感受野要求大,所以有几层下采样和上采样,特征图虽然不大,但计算量并不轻。细节分支反而因为结构轻量、计算量可控,不是主要矛盾。为了压推理延迟,我尝试过把光照分支的输出降到四分之一分辨率处理,再上采样回原尺寸,视觉差异极小,但计算量能省不少。5.2 模型导出与加速我当时把模型导出了ONNX格式,用ONNX Runtime来跑推理,速度提升非常明显。步骤很简单:先把PyTorch模型置为eval模式,构造一个固定尺寸的dummy输入,用torch.onnx.export导出,再加载到ONNX Runtime里运行。这里有一个很容易踩的坑:如果要支持不同尺寸的输入,导出时必须指定dynamic_axes参数,也就是声明哪些维度是动态的,否则模型只能跑导出的那个固定尺寸。我第一次就是没设动态轴,结果换个尺寸的图就报错,查了半天才找到问题。同样是在CPU上,PyTorch的Eager模式跑一张512x512的图大概要400毫秒,ONNX Runtime优化后基本能压到250毫秒上下。如果再开半精度推理还能更快一些,但精度会有轻微损失,而且在低光增强这种任务里,轻微损失可能恰好在暗部细节上体现出来。所以我的建议是:“训练用PyTorch + 推理用ONNX”是一套非常稳妥的组合,成本低、稳定、可控,适合毕设演示。至于要不要进一步量化到INT8,我建议先做对比再决定。量化大概率会让暗部细节损失更多,而低光增强最依赖的恰恰就是暗部细节,收益不划算。如果非要量化,就把量化模型和全精度模型各跑一遍,重点对比暗部区域的差异再拍板。5.3 一个简单可靠的演示界面毕设通常需要一个能演示的系统,用什么框架不重要,重要的是交互要直接、反馈要快。我做的是一个简单的图像增强演示工具:左侧放输入图片,右侧放增强后的输出图,中间一栏是增强强度滑块。滑块控制的其实不是模型本身,而是后处理阶段对输出图做一次幂变换的系数——相当于用户只能调节最终亮度强弱,但模型不需要重新推理。这个设计的实际演示效果很好。滑动滑块时画面实时变化,观众能看到“从暗到亮”的连续过渡,交互感很强,而且不卡顿。原因很简单:模型推理只需要做一次,后面滑块每次变化只需要做一次轻量的像素级幂运算,CPU完全扛得住。大图在界面里用缩略图展示,保存结果时仍然按原分辨率输出,照顾了体验,也保住了成果质量。6. 踩坑记录:训练中的真实问题与解决6.1 训练loss不降、输出图整体发灰这是我最早遇到的坑,前后折腾了大半天。现象是训练了很久,输出图跟输入图几乎一样,要么反过来变成灰蒙蒙的一片,细节全没了。后来定位到两个原因。一个是没有加全局残差,网络要从零生成亮图,初期完全找不到方向。另一个是L1损失的权重过大,模型学会了输出一个“温和的平均值”来最小化数值差距——直接结果就是发灰。解决方法是加上全局残差兜底,同时把SSIM和感知损失的权重稍微调高一点,让模型更关注结构性一致和视觉一致,而不是单纯追求逐像素数值接近。6.2 光照分支输出负值,颜色反转这个坑前面提到过,但值得再展开。现象非常诡异——部分区域的输出颜色直接反转,蓝色区域变橙色,完全不能看。我一开始怀疑数据归一化写错了,又怀疑图像通道顺序搞反了,排查了很久。后来我决定逐个模块打印中间张量的数值范围和均值,才发现问题出在光照分支的输出:它在这里变成了负值,乘到融合分支时相当于对颜色通道做了反向加权。解决方法很简单,把光照分支最后一个激活函数换成softplus,并加一次clamp,保证输出恒为正。这个排查过程给我最大的教训就是:遇到奇怪的输出,先别急着改网络结构、调超参数,逐层打印中间结果往往比瞎猜快得多。6.3 显存不足与训练不稳定我手头的显卡显存不大,一开始用448x448完整图训练,Batch size只能开到2到3,训练过程非常不稳,loss曲线一直在跳。后来改成随机裁剪224的patch输入,加上随机gamma扰动做数据增强,Batch size直接开到8,loss曲线立刻稳定下来。更意外的是,patch训出来的模型在完整尺寸的测试图上效果几乎没有下降,而且因为每个patch都来自不同图片的暗部区域,模型对局部暗部的恢复反而更敏感了。这也算一个经验:低光增强训练,优先用小patch加大批量,效果通常优于大图加小批量。训练时把DataLoader的num_workers设置到4以上,数据加载也不会成为训练提速的瓶颈。整个项目做下来,我对“双分支+残差”这套方案最大的感受是:它的有效不来自某个花哨模块,而是来自对任务本质的理解——低光增强不是一个纯粹的映射拟合问题,而是一个需要分别处理光照和细节的复合问题。看清这一点,结构设计其实水到渠成。后续想继续扩展的话,可以在融合阶段引入可学习的注意力权重,或者把光照分支换成更轻量的子网络来压缩推理耗时,这些都是性价比很高的方向。
1. 低光照增强到底难在哪:先看清问题再动手当时我接手这个题目的时候,第一反应是:“低光照增强不就是把暗部提亮吗?”后来真正跑起来才发现,这事儿坑太深。直接提亮一张暗图,等于把里面的噪声一起放大,原本勉强能看的地方,提亮之后全是花花绿绿的噪点,而且暗部细节本来就模糊,硬拉亮度也拉不出来。原因很简单:暗光环境下传感器收到的光子数量少,信号本身被噪声淹没,信噪比极低。这已经不是对比度拉伸能解决的问题了,想要得到干净的亮图,必须把光照和噪声当作一个耦合问题来处理。深入一点拆解,低光图像其实承载了三层叠加的退化。第一层是亮度整体偏低,动态范围被压缩,很多区域直接贴到0附近。第二层是颜色失真,暗部容易出现偏绿、偏蓝甚至偏紫的色偏,这是传感器在低照度下彩色滤波阵列响应不一致导致的。第三层是噪声非均匀分布,极暗区域的噪声强度远高于中间调,而且是信号相关的——亮度越低,噪声越大。三层退化叠加在一起,任何一个线性映射都无法同时解决。你压暗部噪声,亮部也跟着变暗;你提亮中间调,暗部噪声又跟着放大了。传统方法大致分两类。一类是直方图均衡、伽马校正这种像素级调整,优点是快,几毫秒就能出结果;缺点是全局操作,暗部和亮部互相牵连,很容易把天空提得死白,或者把灯箱区域过曝成一团。另一类是Retinex系列的分解法,把图像拆成反射率和照度,再对着照度分量做调整。经典Retinex的问题在于分解质量不稳定,尤其在纹理密集的区域,照度分量估计不准就会出现光晕和伪影,暗部还会被压出奇怪的色块。到了深度学习时代,大家改成“学一个从暗图到亮图的映射”,让网络自己去拟合光照和噪声之间的复杂关系,效果确实比传统方法上了一个台阶。近几年的低光增强工作基本都走这条路。但同样是用深度学习,早期很多工作都是单一网络端到端直出,输入暗图、输出亮图。这种做法的瓶颈在于:让一个网络同时处理“提亮”和“去噪去伪影”两类任务,复杂度太高,网络容量和训练数据稍显不足,结果就会顾此失彼。后来我看到一些工作开始把任务拆开,用分阶段或分分支的思路来降低每个模块的学习负担。双分支残差结构就是这么来的——一个分支管全局的光照估计,一个分支管局部的纹理恢复,最后再用残差把信息融合起来。这个思路相当于把Retinex理论用现代网络结构重新实现了一遍,而且在实验里是真的能拉开效果差距。如果你正在考虑拿这个方向做毕业设计,它有几个非常实际的好处。任务本身清晰,效果可视化强,汇报答辩时放一张对比图,人人都能看懂你在做什么。技术栈完整,从数据处理、网络设计、训练调参到部署演示,一整套流程你都会走一遍,锻炼扎实。更关键的是可扩展性好,换一个骨干网络、加一个注意力模块、调整分支融合方式,都是顺理成章的改进点,中期检查和答辩都有东西可说。所以无论从课题难度还是产出角度看,这都属于性价比很高的题目。2. 双分支残差结构的设计逻辑:把“提亮”和“修复”拆开双分支结构不是拍脑袋定的,它的根子在Retinex理论。这个经典模型把一张图像建模成反射率R和照度L的乘积:I = R × L。人眼感知的“颜色”和“纹理”主要在R里面,照度L则是外部光源的分布——今天晴天还是阴天、哪里有阴影、哪里有路灯,这些都是L决定的。低光增强要做的事情,本质上就是把L提上来,同时保留R。这就引出一个关键判断:R和L的性格完全不同。L是低频主导的平滑场,变化平缓;R是高频主导的细节场,含有大量边缘和纹理。如果让一个网络同时学这两个映射,它需要兼顾两种截然不同的特征分布,结果往往是学会了提亮,却把细节磨没了,或者保住了细节但亮度分布不均匀。双分支就是顺势而为的解法。分支A做照度估计,输入原图,输出一个单通道的、低频主导的照度图。这个分支的网络倾向是“全局性”的,感受野要大,要能看到整张图的明暗分布,输出尽量平滑,不需要太多高频细节。分支B做细节恢复,输入原图,输出的是图像的高频细节和颜色修正信息。这个分支需要密集的局部特征提取能力,小卷积核堆叠、注意力机制都可以往这里放,目标是尽量把暗部的纹理从噪声里捞出来。两个分支的输出怎么融合?直接做像素相加不推荐,因为照度的量级和细节特征的量级完全不一样,硬加会导致强光区域过曝。我当时用了一个很常见的做法:分支A输出的照度图作为空间权重,分支B输出的细节图加到主干上,最后再过一个轻量的融合卷积层。简单来说就是一个乘、一个加,用照度控制整图的亮度基准,用细节分支修正局部纹理。这个融合方式在实现上非常轻量,但效果非常直观,收敛也快。然后是残差的部分。为什么一定要用残差?因为增强任务的输出和目标之间是高度相关的——亮图和暗图共享绝大部分结构信息,真正要学的是“差异”,而不是从零生成一张新图。残差学习相当于让网络只去学那个增量。你可以把网络想象成一个助手,它不需要重新画一幅画,只需要在草稿上添加笔触和高光。学习难度降了,收敛速度自然快了。而且残差连接给梯度提供了高速公路,深层网络训练起来不容易退化。我用的结构是:主干输入原图,经过几层卷积提取特征,在最后输出处把输入图像加回去,也就是“输出 = 特征回归结果 + 输入图像”。这个全局残差在低光任务里特别管用,网络相当于只负责预测增强量,亮度基线由原图兜着——训练初期输出不会发灰,也不容易出现灾难性遗忘。实际结构里还有个容易被忽略的细节:光照分支的输出要过softplus激活,确保输出恒为正数。如果照度图出现负值,乘到细节分支上会直接导致颜色反转——我见过复现代码跑出来颜色完全乱掉的,查到最后就是卡在这一步。所以我在光照分支的最后一层,先softplus,再做一个轻微clamp,保证数值稳定落在正区间。下面给一个我当时验证过的结构参数参考,简单但稳定:模块输入操作输出通道光照分支RGB原图先下采样几层获取全局感受野,再逐层上采样回原尺寸,最后softplus激活1细节分支RGB原图3x3卷积堆叠 + 跳跃连接,保持空间分辨率不降太多32主干融合原图 + 两支输出拼接后过3x3卷积,输出3通道增强结果3全局残差增强结果 + 原图逐像素相加3对应代码骨架大概是这个意思(PyTorch风格):class DualBranchEnhancer(nn.Module): def __init__(self): super().__init__() self.illum = IlluminationBranch() # 输出1通道照度 self.detail = DetailBranch() # 输出32通道细节 self.fuse = nn.Conv2d(3 + 1 + 32, 3, 3, padding=1) def forward(self, x): I = self.illum(x) # 照度估计 D = self.detail(x) # 细节恢复 fused = torch.cat([x, I, D], dim=1) out = self.fuse(fused) return out + x # 全局残差这里有一个我调试下来觉得特别重要的经验:两个分支千万不要用完全相同的结构。如果两个分支长得一模一样,它们学出来的特征会趋于一致,双分支就失去了意义。光照分支刻意走“低分辨率优先”的路径,获取全局感受野;细节分支保持“高分辨率优先”的路径,专注局部纹理。这种不对称的设计会强迫网络分化,训练出来的两个分支才真正各司其职。我试过把两个分支做成对称的,结果训练完发现它们各自学到了几乎相同的东西,融合之后效果还不如单个分支。3. 训练落地:数据、损失函数和调参顺序3.1 数据集怎么选低光增强和其他视觉任务有个明显不同:很多任务可以用无标签数据,但这个任务如果有配对数据会好做很多。配对指同一场景下同时有低光图和正常光图,让网络知道“眼前这张暗图应该变成什么样”。我优先推荐公开的成对数据集,例如LOL和Adobe FiveK这类。LOL是真实场景拍摄的短长曝光配对,图像尺寸不大,但优势在“真实”两个字——里面的噪声分布是传感器真实产生的,不是模拟出来的,训练出来的模型拿到真实暗光环境里不容易露怯。FiveK是摄影师手动后期后的结果,配对质量高,风格上偏“修图”,比较适合先做预训练,再拿LOL微调。如果手头数据量不够,还有一招是合成退化:拿正常光图做gamma变换压暗,再叠加高斯噪声或者泊松噪声,生成一批合成低光图。合成数据的优势是量大、配对精确,缺点是噪声类型和真实感光元件的行为差距较大,所以只能作为补充,不能全部依赖合成数据。我做数据增强的时候,除了常规的水平翻转、随机裁剪,还专门加了“随机gamma扰动”,让增强后的图亮度水平飘忽不定,逼着网络去适应不同的暗光程度,而不是死记某一档亮度。实际训练时用随机裁剪的小patch输入,一是显存友好,二是相当于制造了更多样的训练样本。3.2 损失函数怎么组合损失函数是低光增强里最玄学也最容易翻车的地方。只用一个L1损失,训练出来的图容易“均值回归”——整体颜色发暗,局部细节被平均掉。纯感知损失又容易让颜色跑偏,出现过饱和或者偏色的情况。我实践下来比较稳的组合是三部分:第一个是L1复原损失,管住整体亮度重建的准确性,相比MSE,L1在边缘处更宽容,不容易把边缘训糊。第二个是结构损失,用1减去SSIM来计算,这个分量专门约束局部结构,确保纹理和边缘不被磨平。第三个是感知损失,用预训练的图像分类骨干网络(比如VGG系列)提取中间层特征,再计算特征图之间的L1距离,这个分量管的是“看起来像不像”——约束两幅图在高频语义特征上的分布接近,对色偏也有明显的抑制作用。三个损失的权重需要反复试。我最终定的是1 : 0.2 : 0.5,也就是说L1主打、感知辅助、SSIM做微调。这样一个组合训出来的图,整体亮度准确、边缘干净、暗部有纹理,不会出现明显的色偏。还有一个容易被忽略的细节:把损失同时加在输出和光照分支上。也就是说,光照分支除了参与主损失的反向传播,还要单独接收一个“照度真值”的监督。照度真值怎么来?对低光图和高光图做逐像素比值,再做一个高斯模糊平滑,就能得到一个粗糙的照度估计。加上这个辅助监督之后,光照分支收敛明显更快,分解出来的照度图也更平滑,融合阶段不容易出现光晕。3.3 超参数和训练顺序训练细节直接决定最终效果,而且顺序很重要。我的经验是先把主结构跑通,再慢慢加辅助监督和损失项,不要一次性全部堆上去。优化器:AdamW,初始学习率2e-4,weight decay设为1e-4。学习率策略:余弦退火,把学习率在训练末期降到最低点。迭代轮数:300轮左右。前200轮主干结构基本稳定,后100轮主要在做细节打磨。Batch size:受显存限制,我用的是8,输入裁剪220x220。如果你的显卡显存小,Batch size降到4,学习率也要等比缩小,不然loss曲线会抖得很厉害。训练顺序:先只训“主干+全局残差”,等loss曲线的下降趋势稳定了,再加入光照分支的辅助监督;最后再把感知损失的权重逐步加进来。渐进式加损失,比一次性全开容易调通得多。提示:低光图像本身的动态范围差异非常大,输入像素值统一归一化到0到1之间,不要在原图上做任何对比度拉伸等预处理。网络需要自己去学习亮度分布,提前做归一化反而会破坏它的学习空间。我还有一个实战发现:如果开着BatchNorm训练,训练集上的效果和测试集上的效果差距会拉得比较大,这个问题在图像翻译类任务里尤其突出。因为每个batch的低光图像亮度风格差异很大,BatchNorm统计的均值方差会被拉偏。改成InstanceNorm之后,样本内部的亮度均值被单独归一化,验证集上的指标普遍提升了一个多dB,这个改动成本极低,收益却很直接。4. 实验评估:指标、对比实验与消融分析4.1 客观指标怎么算主流指标就三个:PSNR、SSIM、LPIPS。PSNR在意像素级重建误差,数值越高越好;SSIM在意结构相似度,区间在0到1之间,越高越好;LPIPS是深度学习时代的感知指标,用预训练网络的特征距离来衡量两张图“看起来像不像”,数值越低越好。毕设论文里这三个指标都要给,但它们的用途完全不同,答辩时如果讲不清楚很容易被问倒。需要注意的一个技术细节是:PSNR和SSIM要在哪个颜色空间算。很多经典方法是在YCbCr空间的Y通道上计算的,因为人眼对亮度最敏感,Y通道数值能直接反映亮度重建的好坏。也有不少工作是在RGB三个通道上分别算、再取平均,两种口径得到的数值差异不小。如果对比实验里各家用各家的口径,数据就会失真,写论文时一定要统一口径,并且在一开始就在实验章节写清楚计算方式。4.2 对比方法怎么选对比方法的选取要有梯度,不能只找比自己弱的方法比,那样说服力不够。我当时选了四个典型代表:一个传统Retinex类方法打底,一个早期端到端深度学习模型,一个分阶段处理的深度模型,再加一个近期效果比较好的工作。这样摆出来的对比逻辑很顺:传统方法在速度上有优势但质量不足,端到端网络在整体指标上更好但暗部细节仍有缺失,分阶段方法效果好一点但结构复杂、计算开销大。自己的双分支方法在这四者之间,无论在客观指标还是视觉结果上都处于一个清晰的位置。要提醒一点:复现别人的方法时,一定要用对方公开的检查点,或者严格按对方默认配置重新训练。不要拿一个没有收敛的模型来对比,那样赢了也没有意义。那些公开模型的运行环境最好也保持一致,不然生成的图像风格差异会影响判断。4.3 消融实验怎么做消融实验是证明“双分支和残差这些设计有效”的关键证据。我的消融方案按顺序做三组。第一组,去掉光照分支,只保留细节分支,相当于变回普通单网络端到端,效果差异最直观——暗部提亮明显不足,或者提亮了但颜色偏灰。第二组,去掉全局残差,让网络从零开始生成亮图,这一改动会导致训练难度上升,收敛变慢,而且最终结果容易发灰。第三组,把光照分支和细节分支的内部结构互换,让光照分支变深而细节分支变浅,这一组主要是验证“为什么细节分支要保持高分辨率路径”。每组消融都要单独训练到收敛,不能只跑几步就拿出来比。三组消融跑完,网络里每个组件的必要性都有了直接证据,答辩时被问“你这些结构凭什么有效”就不慌。4.4 指标高不等于效果好这是我做完整课题后最有价值的经验之一。实验结果里经常出现一个情况:某张图的PSNR很高、SSIM也不错,但人眼看着就是发灰、发闷,像是蒙了一层雾;而另一张图指标稍微低一点,视觉上却自然得多。原因在于PSNR和SSIM都是对整张图做统计平均,低光增强里最重要的暗部区域在全局统计中的权重天然就被低估了——暗部像素占图像面积就那么一点,它的恢复质量好坏,对全局指标的贡献远不如亮部区域。所以我实验阶段养成一个习惯:每次训完一个模型,不只看测试集均值,一定要挑几张有代表性的暗图,把增强结果和真值做局部裁切放大,专门看暗部纹理恢复得怎么样、色彩过渡自然不自然。这个主观审查甚至比指标数字还要关键。答辩时如果老师问“你的指标提升了多少”,先用数据回答,再补一句“我们更关注暗部区域的恢复质量,为此做了局部可视化对比”——这个回答比单报一个数字严密得多,而且展示出你真的理解这个任务的核心难点。5. 应用实现:从PyTorch模型到可演示的完整流程5.1 推理流程和性能瓶颈模型训练完之后,应用侧要解决的第一个问题就是推理性能。低光增强的部署目标很明确:单张照片的增强控制在两三秒以内都可以接受,但如果要给视频流或者连续处理多张图片,就必须考虑性能优化了。我的推理流程是:读图 -> 调整尺寸到模型输入 -> 前向推理 -> 后处理(反归一化、可选的颜色修正)-> 保存结果。整个过程的主要耗时集中在前向推理上,而前向推理的瓶颈主要在光照分支——因为它的感受野要求大,所以有几层下采样和上采样,特征图虽然不大,但计算量并不轻。细节分支反而因为结构轻量、计算量可控,不是主要矛盾。为了压推理延迟,我尝试过把光照分支的输出降到四分之一分辨率处理,再上采样回原尺寸,视觉差异极小,但计算量能省不少。5.2 模型导出与加速我当时把模型导出了ONNX格式,用ONNX Runtime来跑推理,速度提升非常明显。步骤很简单:先把PyTorch模型置为eval模式,构造一个固定尺寸的dummy输入,用torch.onnx.export导出,再加载到ONNX Runtime里运行。这里有一个很容易踩的坑:如果要支持不同尺寸的输入,导出时必须指定dynamic_axes参数,也就是声明哪些维度是动态的,否则模型只能跑导出的那个固定尺寸。我第一次就是没设动态轴,结果换个尺寸的图就报错,查了半天才找到问题。同样是在CPU上,PyTorch的Eager模式跑一张512x512的图大概要400毫秒,ONNX Runtime优化后基本能压到250毫秒上下。如果再开半精度推理还能更快一些,但精度会有轻微损失,而且在低光增强这种任务里,轻微损失可能恰好在暗部细节上体现出来。所以我的建议是:“训练用PyTorch + 推理用ONNX”是一套非常稳妥的组合,成本低、稳定、可控,适合毕设演示。至于要不要进一步量化到INT8,我建议先做对比再决定。量化大概率会让暗部细节损失更多,而低光增强最依赖的恰恰就是暗部细节,收益不划算。如果非要量化,就把量化模型和全精度模型各跑一遍,重点对比暗部区域的差异再拍板。5.3 一个简单可靠的演示界面毕设通常需要一个能演示的系统,用什么框架不重要,重要的是交互要直接、反馈要快。我做的是一个简单的图像增强演示工具:左侧放输入图片,右侧放增强后的输出图,中间一栏是增强强度滑块。滑块控制的其实不是模型本身,而是后处理阶段对输出图做一次幂变换的系数——相当于用户只能调节最终亮度强弱,但模型不需要重新推理。这个设计的实际演示效果很好。滑动滑块时画面实时变化,观众能看到“从暗到亮”的连续过渡,交互感很强,而且不卡顿。原因很简单:模型推理只需要做一次,后面滑块每次变化只需要做一次轻量的像素级幂运算,CPU完全扛得住。大图在界面里用缩略图展示,保存结果时仍然按原分辨率输出,照顾了体验,也保住了成果质量。6. 踩坑记录:训练中的真实问题与解决6.1 训练loss不降、输出图整体发灰这是我最早遇到的坑,前后折腾了大半天。现象是训练了很久,输出图跟输入图几乎一样,要么反过来变成灰蒙蒙的一片,细节全没了。后来定位到两个原因。一个是没有加全局残差,网络要从零生成亮图,初期完全找不到方向。另一个是L1损失的权重过大,模型学会了输出一个“温和的平均值”来最小化数值差距——直接结果就是发灰。解决方法是加上全局残差兜底,同时把SSIM和感知损失的权重稍微调高一点,让模型更关注结构性一致和视觉一致,而不是单纯追求逐像素数值接近。6.2 光照分支输出负值,颜色反转这个坑前面提到过,但值得再展开。现象非常诡异——部分区域的输出颜色直接反转,蓝色区域变橙色,完全不能看。我一开始怀疑数据归一化写错了,又怀疑图像通道顺序搞反了,排查了很久。后来我决定逐个模块打印中间张量的数值范围和均值,才发现问题出在光照分支的输出:它在这里变成了负值,乘到融合分支时相当于对颜色通道做了反向加权。解决方法很简单,把光照分支最后一个激活函数换成softplus,并加一次clamp,保证输出恒为正。这个排查过程给我最大的教训就是:遇到奇怪的输出,先别急着改网络结构、调超参数,逐层打印中间结果往往比瞎猜快得多。6.3 显存不足与训练不稳定我手头的显卡显存不大,一开始用448x448完整图训练,Batch size只能开到2到3,训练过程非常不稳,loss曲线一直在跳。后来改成随机裁剪224的patch输入,加上随机gamma扰动做数据增强,Batch size直接开到8,loss曲线立刻稳定下来。更意外的是,patch训出来的模型在完整尺寸的测试图上效果几乎没有下降,而且因为每个patch都来自不同图片的暗部区域,模型对局部暗部的恢复反而更敏感了。这也算一个经验:低光增强训练,优先用小patch加大批量,效果通常优于大图加小批量。训练时把DataLoader的num_workers设置到4以上,数据加载也不会成为训练提速的瓶颈。整个项目做下来,我对“双分支+残差”这套方案最大的感受是:它的有效不来自某个花哨模块,而是来自对任务本质的理解——低光增强不是一个纯粹的映射拟合问题,而是一个需要分别处理光照和细节的复合问题。看清这一点,结构设计其实水到渠成。后续想继续扩展的话,可以在融合阶段引入可学习的注意力权重,或者把光照分支换成更轻量的子网络来压缩推理耗时,这些都是性价比很高的方向。