
先说我为什么做这个项目。上个月我在拍一组深夜街巷的素材路灯稀稀拉拉环境光勉强能看清人脸相机ISO拉到12800快门再快就一片死黑。回来打开RAW文件暗部一拉全是彩噪和带状噪声高光早就曝死的地方怎么都救不回来。我试过各种去噪插件、堆栈降噪、单帧RAW的神经网络效果都不理想。后来我把思路换了一下既然单帧RAW信息量就不够那就别死磕单帧用同一场景的双曝光RAW做输入再让扩散模型在RAW域里做恢复。这个方案跑起来之后效果比我预想中好很多我把它整理成了一个叫RawMetaDiff的项目。RawMetaDiff的核心说白了就是“双曝光RAW 元引导 扩散模型”。它解决的问题很明确极暗光环境下如何在保住高光细节的同时把暗部纹理和颜色一并恢复出来。相比传统去噪、HDR融合或者端到端的CNN恢复这套方案在暗部细节和色彩还原上有明显优势适合正在做底层图像恢复的算法工程师、对RAW域处理感兴趣的摄影师以及想在扩散模型方向上找落地场景的研究者。1. 双曝光RAW为什么它是暗光恢复的最佳“原料”1.1 单帧RAW在极暗光下为什么不够用先聊一个基础但关键的问题为什么一定要用RAWJPEG是经过Bayer插值、白平衡、去马赛克、gamma映射、压缩之后的成品信息损失很严重尤其在暗部一点点传感器噪声经过tone mapping之后会被放大成很难看的色块。RAW不一样它保存的是传感器每个像素的原始电荷量线性空间里的信噪比分布是真实的这给后期算法留了最大的操作空间。但单帧RAW在极暗光下依然有两个硬伤。第一是信噪比不够。暗光环境光子数少散粒噪声和读出噪声一起涌上来信噪比可能只有个位数dB。去噪模型再强面对这种“信号淹没在噪声里”的情况也只能猜。第二是动态范围不够。现在的手机传感器单帧12bit到14bit看起来好像不少了但真实场景里从阴影到高光的亮度跨度往往超过14bit更别提那些路灯光源直射的高光区域和旁边暗到接近噪声底的路面。一张RAW拍下来要么保证暗部曝光导致高光全死要么保住高光细节导致暗部欠曝严重。单帧RAW最理想的处理方式是“先提亮再去噪”比如很多暗光手机摄影就是这么干的压低曝光避免高光过曝然后靠强大的ISP和AI把暗部提起来。但这个过程的代价是暗部恢复出来的细节往往缺乏真实的纹理细节都是“脑补”出来的所以放大看会发虚、有色斑。1.2 双曝光RAW的互补逻辑双曝光不是个新概念HDR摄影早就这么玩了。但传统HDR是尽量让所有区域曝光正常而暗光双曝光的思路更极端一张短曝光RAW尽可能保住高光区域的真实细节一张长曝光RAW让暗部有足够的光子累积提供真实、高信噪比的暗部信息。这两张RAW在信息上是互补的。我做的具体拍摄方式是三脚架固定相机同一个场景拍两张短曝光1/120sISO 1600保留路灯、灯箱这类高光的细节但整体画面明显偏暗。长曝光1/8sISO 1600整体亮度明显提升暗部纹理出来了但所有高光区域全部过曝成白色色块。这里的ISO保持一致作用是为了让两张图的读出噪声水平在同一量级避免引入额外的噪声差异。曝光比是15倍在RAW域里长曝光的有效信号量就是短曝光的15倍信噪比理论上高出大约12dB相当可观。对比一下单帧方案单帧曝光时间1/8s高光全死单帧1/120s暗部噪声严重。双曝光的每一帧虽然都不完美但合在一起一个提供高光一个提供暗部正好互补。这比任何单帧的后期提亮都要扎实因为暗部纹理是真实曝光出来的不是算法编出来的。1.3 对齐与预处理从两张RAW到合格的训练样本如果你只用三脚架拍静态场景可以跳过对齐。但在手持拍摄或者轻微风吹动的场景里两张RAW之间会有几个像素的位移这个必须处理否则训练阶段模型会学到重影。我自己的对齐方案是分两步第一步图像级粗对齐用相位相关法估算全局平移和微小旋转。因为短曝和长曝的亮度差异很大直接在RAW域算相位相关容易受噪声干扰所以我习惯先把两张图转到Log空间再算抗噪能力好很多。第二步局部对齐。粗对齐之后用光流法我用的PWC-Net的预训练模型因为它在弱纹理区域表现比传统Lucas-Kanade好做逐像素配准然后把长曝光图warp到短曝光图的坐标系里。这一步需要一个很重要的细节光流要在大约1/4分辨率上算然后再上采样回原尺寸既快又稳。对齐做完之后还要做坏点修复。RAW传感器上总有几个固定坏点在暗部特别明显。我用的是最普通的中值滤波法统计每个像素在多帧我拍了三组不同场景里的响应如果始终比周围像素高好几个标准差就标记为坏点之后用周围9x9区域的中值替换。最后是归一化。所有处理都要在一个合理的数值范围内做我习惯把14bit RAW先减去黑电平不同相机不一样我用的相机黑电平是512然后除以(16383-512)缩放到[0,1]范围再做后续操作。如果不减黑电平暗部的噪声底会偏高扩散模型会把噪声底当成有效信号去恢复结果就是画面发雾。2. 扩散模型与元引导RawMetaDiff的技术底座2.1 为什么选扩散模型而不是GAN或端到端CNN我做这套方案之前已经用UNet类的CNN试过双曝光RAW融合。端到端CNN的学习逻辑是“输入到输出直接映射”在训练集覆盖的范围内效果很稳但一遇到没见过的场景尤其是极端暗光、罕见色温、奇怪噪声分布的图像结果就明显露怯要么糊要么出伪影。究其原因低光恢复本质上是高度病态的逆问题从一张甚至两张带噪欠曝图中恢复出干净的正常光照图像解空间非常大。CNN学到的往往是训练集的“平均答案”细节容易模糊。GAN能生成比较锐利的细节但它的问题大家也清楚训练不稳定容易模式坍塌颜色容易偏移生成出来的细节有时候是“幻想”出来的真实性不够。扩散模型解决问题的方式不太一样。它不是直接学输入到输出的一步映射而是把恢复过程拆成很多小步每一步只负责做一点去噪和细化。就像拆解一个复杂任务第一遍先把大体轮廓从噪声里拉出来后面几十步逐步补充细节。这是生成模型的路数但因为有“输入图像作为条件一直盯着”它不会像纯生成那样自由发挥而是把生成能力和条件约束结合起来。实测下来的直观感受是扩散模型恢复出来的暗部纹理更“真实”不是那种锐化过头的假纹理而是有颗粒质感的自然细节。尤其在极暗部这个优势比CNN明显太多。2.2 元引导是什么把相机元数据变成条件信息这是RawMetaDiff里最核心的一个设计。双曝光的输入是两张图但其实相关信息远不止两图像素。相机拍摄时记录了很多元数据曝光时间、ISO、黑电平、白平衡增益、镜头的暗角系数甚至传感器的温度。这些信息在传统方案里基本都被丢弃了但它们在暗光恢复里非常关键。举一个最直观的例子两张RAW的曝光比是15倍如果模型不知道这个比例它就得自己去估计“长曝光比短曝光亮多少”。这个估计在光滑区域还行但在纹理区域很容易出错。如果你把曝光比直接告诉模型它就能把信息用在正确的地方长曝光图里的高光区域虽然过曝但模型知道这里对应短曝光里的哪个亮度水平自然知道怎么去重建细节。这就叫元引导——用拍摄时的元数据作为额外的条件信号引导生成过程往正确的方向走。再比如黑电平。不同机型的黑电平不一样如果模型训练数据里混了好几种黑电平而你没有把黑电平信息告诉它它就只能自己“猜”猜错了暗部就会整体偏色或发灰。把黑电平作为一个标量条件输入相当于直接告诉模型“这个相机传感器的噪声底在这里”就省去了模型的大量猜测。元引导的具体构造方式我采用了类似DDIM和Classifier-Free Guidance结合的做法。在训练时我把以下元信息拼接成一个向量曝光比长曝光时间 / 短曝光时间取log短曝光ISO黑电平归一化值全局亮度统计量输入图减去黑电平后的均值曝光时间对数这个向量经过一个两层MLP映射成嵌入向量然后通过FiLM层调制U-Net的特征通道也就是在每一层对特征做仿射变换。扩散模型原本有一个时间步嵌入来告诉模型目前是第几步去噪元引导向量就和时间步嵌入加在一起再一起做特征调制。这样模型在每一步去噪时都知道当前的曝光、噪声水平、传感器特性可以动态调整处理策略。2.3 RawMetaDiff整体结构拆解整个模型的结构可以分成三个部分。我尽量用口语讲清楚不堆公式。第一个部分是条件编码器。输入是两张RAW短曝光和长曝光分别经过一个共享权重的卷积编码器提取特征然后在高分辨率层做一次特征拼接这样模型能看到“哪里高光过曝了哪里暗部有细节”。这个编码器是轻量的只有4层卷积输出是原图1/8分辨率的特征图。不直接拼接原图输入是因为两帧之间的曝光差异太大直接在像素域拼接会导致模型过拟合到亮度差上而在特征域融合更平滑。第二个部分是U-Net扩散主干。采用标准的DDPM U-Net结构带自注意力层在1/16分辨率的层上加入注意力模块因为RAW大图的全局结构在暗光下很重要比如路灯的位置、天空的渐变这些信息需要全局感受野才能处理好。我在每个分辨率的Block里都用FiLM层注入元引导向量和时间步嵌入。第三个部分是输出投影层。U-Net输出的是噪声预测这个预测会加回当前去噪的图像状态逐步得到最终恢复结果。这里我用的是经典的epsilon-prediction方式。训练阶段的完整流程是这样先把一张干净的长曝RAW或合成的干净图作为目标加噪到各个时间步然后让模型输入“被加噪后模糊的目标 短曝光RAW 长曝光RAW 元信息向量”预测添加的噪声。注意这里长曝光RAW在这里扮演的是一个引导参考的角色不是被恢复的主体。推理阶段就反过来了随机初始化一张纯噪声图然后逐步去噪。每一步都让模型基于“当前去噪状态 双曝光RAW 元引导参数”来预测噪声从纯噪声中一步步“还原”出干净图像。这个去噪过程我用的是DDIM采样步数设置为50步在质量和速度之间取了一个平衡。3. 训练与推理让模型真正“看见”暗部的细节3.1 数据构造与标注流程数据是这套方案效果好坏的最大决定因素。我没有用现成的公开数据集因为公开数据集大多是单帧暗光或普通HDR的格式没有配套的暗光双曝光RAW。所以我花了两周时间自己拍了一套。拍摄环境选在夜晚的停车场、街道、公园长椅这类场景三脚架固定拍摄流程是先拍短曝光再拍长曝光中间尽量在3秒内完成减少光线变化导致的差异。每一场景拍三组不同曝光组合极端暗光曝光比20倍、普通暗光曝光比10倍、微光曝光比5倍。同一场景再用最低ISO100、超长曝光20秒以上多拍8张每张之间间隔几秒最后叠加平均作为Ground Truth。这一步很关键因为长曝光单帧本身也有噪声不能直接当GT用。8帧平均能把随机噪声压制到很低水平得到接近真实的干净参考图。GT做好之后还有一个必须处理的问题长曝光RAW中过曝的高光区域怎么定义GT我的做法是把GT中像素值超过96%满阱也就是接近饱和的区域标记成mask训练时这部分像素不参与重建损失计算只靠扩散模型的生成能力去填补。原因很直接过曝区域本来就信息全丢了不存在“正确的GT”强制模型去拟合这些区域只会导致颜色发灰、纹理乱猜。总共采集了1200组场景裁成256x256的patch进行训练。因为RAW是14bit图很大单张6000x4000全图直接进模型显存根本扛不住。Patch-wise训练还有一个好处相当于天然做了数据扩增1000多张图裁出来差不多12万对patch够用了。3.2 损失函数与引导强度设置RawMetaDiff的训练损失是两部分加在一起扩散模型的噪声预测损失加上一个额外的感知损失。噪声预测损失就是标准DDPM的MSE这个没什么好说的。感知损失这一部分我是加了点私货。在纯MSE损失下扩散模型生成的暗部细节虽然干净但有时候纹理偏“橡皮化”——看起来很光滑缺少真实暗光照片的颗粒感。我在U-Net中间层接了一个VGG特征提取器把模型输出的中间状态和GT都映射到VGG特征空间里算L2距离强制模型保留更多感知层面的纹理结构。损失权重上噪声预测损失权重是1.0感知损失是0.2。这个比例我试了好几个0.2的感知损失刚好能在纹理细节和颜色准确性之间平衡太大会导致颜色轻微偏移太小就和没加差不多。Classifier-Free Guidance的引导强度我最终定在3.0。这个数值意味着在推理时模型同时做两次预测一次是有条件预测输入双曝光和元信息一次是无条件预测不输入任何条件信息然后用下面的公式融合两个结果[ \epsilon_{guided} \epsilon_{uncond} 3.0 \times (\epsilon_{cond} - \epsilon_{uncond}) ]引导强度越大生成结果越忠实于输入条件但超过4.0之后会出现细节过锐、颜色发脏的问题低于2.0则恢复出的图像偏模糊暗部细节不充分。3.0是我在测试集上试出来的甜点值。这个公式背后的直觉是通过推大条件预测和无条件预测之间的差异模型会更加“坚定”地按照输入条件去重建图像而不是依赖生成先验自由发挥。3.3 推理流程先粗恢复再用元引导细化推理阶段的完整流程我整理一下第一步读取两张RAW修复坏点减去黑电平归一化到[0,1]范围完成对齐。第二步计算元信息向量。曝光比直接用曝光时间的比值ISO直接从EXIF读黑电平已知全局亮度统计量就是输入图在减黑电平后的均值。第三步把两张图拼接起来送进条件编码器得到特征图。第四步初始化一个纯噪声图分辨率设为输入的1/4以节省显存这个下采样会导致细节轻微损失但换来的是能在单张12GB显卡上跑全图。然后开始DDIM 50步去噪迭代。每一步输入包括当前去噪状态、条件特征、元信息向量、时间步。第五步去噪完成后得到线性空间的恢复图像再做白平衡和简单的tone mapping输出最终的sRGB图像。可以直接参考的关键代码如下这就是整个推理循环的核心部分我用的是PyTorch风格伪代码根据自己的超参数调整即可# dims: B x C x H x W, cond_feat: 条件编码器输出, meta_vec: 元信息向量 noise torch.randn_like(init_noise) x noise.clone() for i in range(num_steps): t timesteps[i] t_emb get_timestep_embedding(t) meta_emb mlp(meta_vec) # 元信息映射 cond t_emb meta_emb # 合并条件 with torch.no_grad(): eps_uncond unet(x, t, cond_featNone, condcond_featNone) eps_cond unet(x, t, cond_featcond_feat, condcond) eps eps_uncond guidance_scale * (eps_cond - eps_uncond) x ddpm_step(x, eps, t, i)第一步到最后一步画面上能明显看到暗部的细节从模糊到清晰高光从灰白到恢复纹理这个过程跑在单张RTX 4090上50步大约需要8秒。对于静态照片处理来说这个耗时完全能接受。4. 踩坑实录我在这套方案里遇到的高频问题4.1 双曝光对齐不准导致鬼影这是我最先遇到也是最头疼的问题。前几版模型跑出来的结果在暗部经常出现双重边缘尤其在人行道栏杆、树枝这类细长结构上。排查下来的原因是纯标量对齐不够。短曝光图噪声大梯度信息弱单纯靠原始像素算相位相关容易被噪点带偏产生0.5到1个像素的偏移这点偏移在最终的扩散重建里会被放大成鬼影。最后的解决方案是“Log空间相位相关 光流粗到精”两级方案。第一步先把两张图分别做白平衡预处理转到Log亮度空间然后做带汉宁窗的相位相关抑制边缘效应。第二步用PWC-Net计算稠密光流再做一次粗到精的warp。这样处理之后绝大多数场景的对齐误差能控制在0.3个像素以内。4.2 元数据归一化不当导致色彩偏移这个坑比较隐蔽。我一开始直接把ISO、曝光时间这些原始数值拼成向量喂给MLP结果训练很快就发散。想了半天才意识到是数值量级差距太大曝光比是log值还好但ISO是1600、3200、12800这样的大数字黑电平是500左右全局亮度均值是0.01这种小数这些数值混在一起早期训练时梯度更新会被大数值量级的维度主导。解决方法是在拼接到meta_vec之前对每个维度做独立归一化。我用的归一化方法是对训练集中每一项统计均值和标准差然后做z-score归一化。把曝光比、ISO、黑电平、全局亮度全部转成近似标准正态分布之后再输入MLP训练立刻稳定了很多。如果你后续也要复现建议一定做这一步别偷懒。4.3 扩散模型训练不收敛与显存优化我踩的第三个大坑是关于训练稳定性的。扩散模型对损失曲线很敏感我刚开始用warmup 1000步、学习率5e-4结果跑到2万步时损失开始剧烈震荡怎么调都稳定不下来。后来参考了一些扩散模型优化经验做了三个改动warmup 增加到 5000 步学习率降到 1e-4。添加了EMA指数移动平均衰减系数0.9999。换用AdamW并在每个batch内对loss做grad clipmax norm设为1.0。这三个改动之后训练损失平稳下降效果肉眼可见地稳定。显存方面256x256的patch在12GB显卡上可以跑batch size为8如果显存更小就降到4。另外混合精度训练是必须开的效果几乎没有损失提速明显。我有测试过更大的patch384x384效果确实略好但显存压力陡增最后还是回到了256x256。我把遇到的高频问题整理成一个速查表供大家参考问题症状解决方案鬼影/重影暗部细节出现双重边缘Log空间相位相关 光流对齐色彩偏移恢复图整体偏绿/偏紫确保每个元数据维度独立归一化暗部发雾暗部不黑整体灰蒙蒙训练前必须减去黑电平过曝区域乱猜高光滑块颜色怪异GT做饱和mask屏蔽过曝像素训练震荡不收敛loss曲线大幅波动加warmup、降学习率、加EMA显存不足OOM报错Patch训练 混合精度 checkpointing最后再分享一个小技巧我自己在实际操作中摸索出来的一个很有用的技巧是推理时可以做一个“噪声回退”操作。如果用50步DDIM恢复出来的结果在某个局部区域有奇怪的伪影不要重新跑一遍而是在第30步时把已经去噪到一半的状态往回加一点噪声相当于让模型在那个区域重新想一遍再继续往下走。这个操作能明显改善细碎区域的纹理质量成本只是额外30步的计算时间比整体重跑便宜太多。RawMetaDiff这套方案目前还在迭代中。我最近在尝试把元引导扩展到自动白平衡和去马赛克的联合处理上因为RAW域的恢复本就不该割裂开做。如果你也在折腾暗光RAW恢复或者正在找扩散模型的落地场景这套方案是一个不错的参考起点。尤其是双曝光RAW加元引导这个组合思路把它换成视频帧、多帧连拍我觉得衍生空间还会更大。