简介:图像去模糊是计算摄影和底层视觉中的经典难题,其中散焦模糊因物理成因复杂,传统方法常难以精确恢复。双像素传感器通过将每个像素分为左右两个子像素,可以同时获取同一场景的两个子孔径视图,为去模糊任务提供了额外的几何与深度线索。理解双像素成像原理,利用子孔径视差信息,已成为提升散焦去模糊效果的关键技术路径。DPDD数据集是当前业界公认的基准,它以6720x4480全分辨率图像配合左右子孔径视图,真实模拟了光学散焦过程,适合用于验证算法在真实大图上的表现。本文从数据集结构出发,详细介绍了双像素RAW到子孔径视图的预处理、数据组织方式,以及PyTorch下的全分辨率训练代码实现,并针对显存优化、输入通道设计、损失函数选取等工程问题给出了可落地的解决方案,能够帮助研究者和工程师高效开展散焦去模糊的算法开发与评测。 做图像去模糊这些年,我经手过不少数据集,但DPDD这个双像素脱焦去模糊数据集给我的印象一直很深。原因很简单:它把“模糊”这件事从单纯的低通滤波问题,变成了一个可以利用成像物理信息来求解的逆问题。名字里的DPDD,实际上就是双像素脱焦去模糊(Dual-Pixel Defocus Deblurring)的缩写,核心是6720x4480全分辨率图像搭配左右子孔径视图。对做计算摄影、底层视觉、以及手机影像算法的工程师来说,它几乎是目前研究散焦去模糊绕不开的基准数据。这篇文章不打算重复论文里的公式,而是想从数据集结构、子孔径处理、全分辨率训练代码这几个角度,把我实际跑通这套流程踩过的坑和解决思路完整记录下来。
如果你正准备拿DPDD做训练,或者想把手里的算法迁移到双像素输入上,这篇文章应该能帮你省掉好几天的排错时间。我会尽量把代码和操作细节写完整,也会解释每一步背后的原因。
1. 数据集的设计思路与双像素成像基础
1.1 为什么要用“双像素”来解脱焦模糊
散焦模糊和运动模糊不一样。运动模糊是一整张图或者局部区域像素沿轨迹拖影,信息丢失比较严重;散焦模糊则是因为物体不在焦平面上,光线在传感器上形成一个弥散圆。这个弥散圆的半径和方向,本质上和光的传播路径有关,而且不同深度的物体模糊程度不同。简单说,散焦模糊里其实还埋着场景深度和模糊核的信息,只是普通传感器把这些信息平均掉了。
双像素传感器的设计刚好能补回这部分信息。它把传感器上每一个像素拆成左右两个光电二极管,光线通过镜头左边和右边进入后,分别落在两个半像素上。于是,一张普通的RAW图里,实际上藏着两个视角略有差异的子孔径视图。你可以把它理解成用一只眼睛上看和一只眼下看同一个场景,虽然两只眼靠得很近,但产生的视差足以反推一些几何信息。在散焦去模糊里,这两个子孔径视图的差异,正好和弥散圆的方向、大小相关。
DPDD数据集的核心价值就在这里:它不只是给你一堆模糊图和清晰图,而是把左右子孔径视图也一起提供了。模型如果只吃一张模糊图,所有关于散焦的信息都被卷积层自己硬猜;但如果把左右子孔径视图喂进去,网络就有机会学到“这两个视角之间的差异,对应多大的散焦量”这样的显式线索。这也是DPDD在真实散焦去模糊任务上比普通合成模糊数据集更能落地的原因。
1.2 DPDD和普通模糊数据集的最大区别
我曾经用过不少公开去模糊数据集,大部分是运动模糊场景,比如用高速快门拍清晰帧,再和长曝光模糊帧配成训练对。这类数据集的问题在于,模糊是传感器上像素积分出来的结果,和真实光学散焦的物理过程并不完全一致。模型在训练集上学到的“去模糊”,很多时候只是学会了在类似纹理上做锐化,一遇到真实照片里的散焦边缘就露馅。
DPDD不一样。它的模糊来自真实镜头的光学特性,焦外区域自然形成了空间变化的散焦模糊。不是每一块模糊都一样,前景、背景、焦平面附近过渡区域的模糊程度都不同。这就逼着模型必须具备估计模糊强度和模糊方向的能力,而不是简单套一个全局卷积。其次是数据形式,DPDD保留了双像素RAW里的左右子孔径,这在别的数据集里几乎找不到。你可以在输入层直接把左右视图堆起来,也可以单独设计子孔径融合模块。这样的数据结构,决定了它很适合用来验证“是否利用成像先验能帮助去模糊”这个方向。
另外,DPDD还用到了全分辨率原始图像,分辨率高到6720x4480,差不多三千万像素。很多算法在256x256小图上刷点很漂亮,放到全分辨率上直接崩掉,细节要么糊成一团,要么产生大量伪影。用DPDD做评测,能真正看出一个算法有没有处理真实大图的能力,而不是在小分辨率上过拟合。
1.3 6720x4480全分辨率到底改变了什么
高分辨率首先带来的就是显存压力。训练时如果直接输入全图,哪怕是最轻量的U-Net,一张图也可能占掉十几G甚至更多。为了跑起来,常见的做法是随机裁剪成patch,但patch训练有个陷阱:如果patch大小太小,模型看不到大范围的模糊过渡区域,学出来还是局部的锐化器。我一般会尽量保持patch在512到768之间,再配合混合精度训练和梯度累积,才勉强能在单卡上跑一个像样的模型。
高分辨率带来的第二个变化是细节纹理更丰富。低分辨率小图里模糊和清晰之间的差异可能只有几个像素,网络随便学点边缘增强就能把PSNR刷高。但全分辨率图像里,真实纹理在模糊区域里被大量抹掉,去模糊后要恢复的是高频细节,这已经逼近图像生成问题。很多在公开小数据集上看起来不错的模型,在DPDD上效果就会明显下降,原因就在这里。
全分辨率还有一个实际工程影响:边界处理不能含糊。双像素子孔径视图在图像边缘往往存在视角遮挡,边缘像素的左右半像素信息不完全;如果不做边界裁剪或mask,训练出来的模型会在图像四周产生奇怪的颜色偏移。后面我会专门讲怎么处理这个边缘问题。
2. 子孔径视图的数据组织与预处理
2.1 双像素RAW到左右子孔径视图的重建
DPDD提供的数据通常是从双像素RAW转换出来的子孔径视图。我这里以我拿到的版本为例,文件里会包含左视角图(left)、右视角图(right)和对应的清晰参考图(sharp)。如果你手里的版本还是RAW格式,第一步就需要自己抽取左右半像素,这个坑非常值得多说几句。
双像素RAW的布局不像普通Bayer那样每个像素只有一个颜色值,而是每个Bayer位置存了两个半像素值,一个偏左一个偏右。直接调用常规的RAW解码库,大部分情况下只会把两个半像素相加,得到普通全像素图像,等于丢掉了子孔径信息。正确做法是用LibRaw这类库把双像素数据以“unpacked raw”形式读出来,再按像素索引把左半部分和右半部分拆成两个独立的类Bayer数组,然后分别做去马赛克。
拆的时候要注意顺序,不同传感器厂商的双像素方向不完全一样,有的左右,有的上下,甚至有的是四像素拜耳阵列。如果左右方向搞反,模型训练的收敛速度会明显变慢,甚至彻底失效。我的建议是在预处理阶段就做一次可视化验证:找一张有明显前景和背景的模糊图,把左右视图转成灰度,计算两张图的水平视差;如果方向正确,焦外物体的视差方向应该是稳定的,前景和背景刚好相反。用这个方式确认方向再进入批量处理流程,能避免很多后续问题。
2.2 数据集目录与元信息的组织方式
我比较推荐把预处理后的数据整理成下面这种目录结构,训练代码和推理脚本都通用:
DPDD/ ├── train/ │ ├── left/ │ │ ├── scene_001.png │ │ └── ... │ ├── right/ │ │ ├── scene_001.png │ │ └── ... │ └── sharp/ │ ├── scene_001.png │ └── ... └── val/ ├── left/ ├── right/ └── sharp/如果需要做散焦程度相关的控制实验,我还会在文件名里额外加一个标记,比如scene_001_f16.png表示光圈和焦距参数。不要小看这个meta信息,后续分析模型在某些光圈组合下表现差时,没有元信息会很难定位原因。另一个建议是把所有预处理产物统一转成16bit PNG,虽然占一点存储,但能保住动态范围。直接存8bit JPG的话,暗部区域在去模糊后容易出现色阶断层,训练时网络会误以为这些色带是要保留的结构。
在高分辨率数据集中,我还会顺手生成一份每个场景有效的“mask”,把边缘无效区域标记出来。由于双像素传感器在视场边缘存在遮挡和暗角,部分子孔径像素不可靠,如果直接用全图训练,模型会把边缘的伪影当成特征学进去。有效mask的生成方式可以参考左右视图的一致性:两张图在边缘区域差异显著,且这种差异不属于正常的子孔径视差,而是信息缺失。把这些区域缩小几像素后标记为0,其余为1,训练时只计算有效mask内的损失。
2.3 加载数据前的对齐、去马赛克与归一化
左右子孔径视图理论上来自同一个像素位置,但因为镜头畸变和微小的装配误差,直接读出来的两张图会有亚像素级别的错位。这个错位在训练时会被网络当成视差信号,导致去模糊结果出现重影。我实际处理时会在预处理阶段对左右视图做一次对齐,以左视图为基准,用OpenCV的findTransformECC或者基于特征点的仿射变换估计出一个全局偏移量,然后把右视图重采样到和左视图对齐。
要注意,这一步只能用轻微的重采样,不能做大幅度的透视变换,否则会破坏双像素里本来就脆弱的视差信息。另一个容易踩坑的点是白平衡。左右视图在RAW阶段虽然是同一块区域,但如果预处理脚本里分别做了自动白平衡,颜色就会产生不一致。正确的做法是先根据RAW的metadata算出统一的白平衡增益,再作用到左右两个视图上。最后归一化时,I通常是把每个通道除以白点值,再取平方根转换到近似线性空间,这样更符合双像素RAW的感光特性,网络学起来也更稳定。
3. 全分辨率去模糊代码实现:从Dataset到训练
3.1 PyTorch Dataset的完整实现
下面这段Dataset代码是我在DPDD上跑训练时的通用模板。它支持随机裁剪、堆叠左右视图、以及对mask的处理。需要注意,因为左图和右图必须严格同步裁剪,所以不要单独做随机crop,而是先取一个统一的左上角坐标。
import torch import numpy as np import cv2 from torch.utils.data import Dataset from pathlib import Path class DPDDDataset(Dataset): def __init__(self, root, split='train', patch_size=512, is_train=True, use_mask=True): self.root = Path(root) / split self.left_dir = self.root / 'left' self.right_dir = self.root / 'right' self.sharp_dir = self.root / 'sharp' self.images = [p.stem for p in self.left_dir.glob('*.png')] self.patch_size = patch_size self.is_train = is_train self.use_mask = use_mask def __len__(self): return len(self.images) def _load(self, path): img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if img.dtype == np.uint16: img = img.astype(np.float32) / 65535.0 else: img = img.astype(np.float32) / 255.0 return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) def __getitem__(self, idx): sid = self.images[idx] left = self._load(self.left_dir / f'{sid}.png') right = self._load(self.right_dir / f'{sid}.png') sharp = self._load(self.sharp_dir / f'{sid}.png') h, w, _ = left.shape if self.is_train: y = np.random.randint(0, h - self.patch_size + 1) x = np.random.randint(0, w - self.patch_size + 1) left = left[y:y + self.patch_size, x:x + self.patch_size] right = right[y:y + self.patch_size, x:x + self.patch_size] sharp = sharp[y:y + self.patch_size, x:x + self.patch_size] inp = np.concatenate([left, right], axis=2) # H, W, 6 inp = torch.from_numpy(inp.transpose(2, 0, 1)) sharp = torch.from_numpy(sharp.transpose(2, 0, 1)) return inp, sharp这段代码里有几个细节我想强调一下。use_mask在训练阶段通常没必要每次加载,因为训练时随机裁剪的patch大概率落在有效区域内;但在验证和测试时,我会单独写一个返回mask的版本,用于计算mask里的PSNR。另外,uint16转float后我不做额外裁剪,如果图像本身有超过1.0的高光,建议在预处理阶段统一裁剪,而不是在这里,否则不同场景之间的亮度尺度就不一致了。
3.2 子孔径特征的输入方式与网络改造
把左右视图堆叠成6通道输入是最简单的做法,但并不意味着效果最好。我在实验中发现,直接堆叠会让网络在前几层自己学习“左图减右图”这类特征,虽然也能学到,但效率和稳定性都不如直接把差分特征显式给出来。一个比较实用的做法是把输入设计成9通道:左视图3通道、右视图3通道、左右归一化差值3通道。这个差分项相当于给网络一个“子孔径视差的先验”,一开始就告诉它哪些区域存在明显的散焦偏移,训练速度快不少。
如果网络结构不允许输入通道数变化,也可以走双分支:左右视图各自过同一个共享编码器,然后在中间层把特征做差或者拼接。共享编码器的参数能被两边约束,保证左右特征在语义空间里是对齐的;如果让左右各自用独立的编码器,两个分支可能学到完全不同的表示,融合反而更困难。我在DPDD上试过,共享编码器在收敛速度和最终精度上都明显更稳。模型结构本身不需要特别复杂,基于U-Net加几个注意力模块就能在原始分辨率上有相当好的效果。重要的是在解码时要保留足够的高频skip connection,因为散焦去模糊和超分一样,高频细节恢复是核心难点。
3.3 损失函数与训练超参的选取
DPDD数据集上最常见的损失组合是L1损失加感知损失。L1损失能稳定地恢复整体结构,感知损失负责让输出在特征空间里贴近清晰图,避免过度平滑。具体到代码,L1损失可以直接用PyTorch的nn.L1Loss;感知损失则用VGG16的某个激活层输出特征算L2距离。需要特别注意的是,感知损失的特征层不要只选浅层,也不要只选深层,我一般取relu2_2和relu3_3,把浅层纹理和深层结构同时约束住。
训练超参方面,初始学习率1e-4是比较稳的起点,优化器用AdamW,配合余弦退火调度。patch大小设512,batch size在单卡上设4到6,配合混合精度训练。如果场景数量足够,随机裁剪的patch足够多,通常训练40到60个epoch就能看到一个可用的结果。不要一开始就用全分辨率,否则模型过早关注高频噪声,对模糊区域的大范围估计反而不利。我建议先用256x256的patch跑到损失基本稳定,然后再切到512继续微调,这种两阶段方案在DPDD上比一直用大patch更省时间。
4. 全分辨率训练与推理的性能优化细节
4.1 显存不够时的Patch训练方案
三千万像素的图直接在训练时送进网络,很多人第一步就卡在OOM上。我的办法是随机裁剪patch,但裁剪策略不能太粗暴。在DPDD中,散焦模糊的空间变化很大,有些patch可能完全清晰,有些patch可能只有背景虚化。如果完全随机裁剪,模型会频繁看到“清晰patch学习目标还是清晰patch”这样的样本,浪费容量。我一般会先用一个轻量级的散焦估计器,比如传统拉普拉斯方差,算出每个位置的模糊程度分布,然后按模糊程度加权采样,让模糊强的区域被抽到的概率更高。
推理时如果不希望把图缩小,就只能用重叠patch拼接。我是把全图划分成512x512的块,相邻块之间重叠64像素,再在重叠区域做线性融合。这个融合系数有很多种写法,我用的是一个简单的余弦权重,让patch中间的权重最高,边缘逐渐衰减到0。这样做的好处是不会在拼接处产生明显的接缝。如果用Hann窗口来做加权融合,效果更好,但计算量会大一些。另一个实用技巧是推理时关闭归一化层里的running stats更新,因为BN在单patch上统计不稳定,最好用全局统计或者直接就换成实例归一化。
4.2 增强与预处理中容易忽略的坑
普通的颜色增强和几何增强,在DPDD上不能直接套,因为左右子孔径视图之间的对应关系非常敏感。以水平翻转为例,翻转后原来的左视图会变成右视图,右视图变成左视图;如果训练代码没有把两个通道重新排列,网络会同时学到“翻转后左右互换”这个矛盾信号。我在代码里会统一规定:水平翻转后,把left和right交换位置,保持语义一致。垂直翻转则不需要交换,但要注意垂直方向上的子孔径视差本身很弱,不要做太强的垂直平移增强,否则会破坏左右视差的空间分布。
颜色增强相对安全,但必须保证左右视图和清晰图共享同一个增强参数。比如随机调整亮度、对比度、饱和度时,要对三个图像使用同样的增益值,否则左右颜色不一致。一个容易被忽视的点是噪声增强。DPDD图像本身是高质量raw输出,噪声水平很低,但实际拍摄场景里噪声不可避免。如果只在干净的DPDD上训练,模型对带噪输入的鲁棒性会很差。我会在训练时以一定概率对左右视图添加轻微的高斯噪声,这样模型能学到“模糊加噪声”联合去除,迁移到手机拍摄数据时表现更稳定。
4.3 评测指标的选择与结果可视化
PSNR和SSIM是去模糊任务最常用的指标,但它们在DPDD上有一个明显问题:很多区域天然就在焦外,地面真值本身是模糊的,模型再怎么学也不可能恢复出锐利纹理。如果全图统计算PSNR,会把“焦内恢复质量”和“焦外背景虚化”混在一起,结果看不出算法真正的优劣。我会用锐利度评估,把GT的拉普拉斯方差高的区域作为有效评估区,或者直接用左右视图的差分来估计散焦区域,只在这些区域计算PSNR和SSIM。这样评估出来的指标更接近人类对“清晰度”的感知。
除了数值指标,我强烈建议每次验证时都可视化几组典型结果:一张焦内物体居中的图、一张前后景深度变化大的图、一张包含细小纹理的图。不要只看指标选模型,很多模型在PSNR上差不多,但可视化里细节形态完全不同。比如有的模型会过度锐化边缘,产生白边;有的模型会把焦外背景里的纹理“脑补”出来,看起来清晰但其实是编造。这些差异在指标上可能只有零点几个dB,实际体验却天差地别。把输入、左右差值、预测输出、GT放在同一张图里对比,能帮你快速发现模型是否真的利用了子孔径信息。
5. 实操中常见的坑与排查记录
5.1 子孔径视图出现伪彩、错位怎么办
如果你在预处理后看到左右视图有明显的颜色偏差或者边缘重影,第一个要检查的是去马赛克顺序。双像素RAW拆出来的左右Bayer数组,如果按普通Bayer顺序做去马赛克,颜色通道会被打乱,结果就是整张图出现彩虹状伪彩。正确做法是先确定原始RAW里的CFA布局,再决定左右半像素的排列顺序。另一个常见原因是左右视图各自做了颜色校正,导致色温不一致。我遇到这个问题时,会把左右视图转换到Lab空间,用它们的L通道差做一次全局仿射对齐,再把颜色信息从原图映射回去。
错位问题则更多是由于镜头畸变和子孔径视角本身带来的微小透视差异。解决方法是先做全局ECC对齐,如果还有剩余残差,就做一个局部光流校正。但要注意,光流校正的尺度一定要很小,最多几个像素,否则会抹掉真实的视差信号。我一般会把最终对齐后的左右视图再做一次差分图可视化:如果差分图在焦外区域呈现连续平滑的渐变,而在焦内区域接近零,说明对齐和视差信号都正常;如果差分图像噪声一样杂乱,说明预处理还有问题。
5.2 全分辨率推理显存溢出怎么处理
推理阶段显存溢出,最简单的办法是缩小patch,但缩小后可能看不到大范围模糊结构。我更建议先把输入的分辨率减半跑一次,记录模型在低分辨率上的输出,再叠加一个高分辨率细节恢复模块。这个方案虽然没有直接全分辨率推理那么老实,但在实际工程中很实用:先用全局模型恢复整体结构和颜色,再用局部模型补充细节。如果你不想改架构,就用前面提到的重叠patch拼接,同时把torch.no_grad()和torch.cuda.amp.autocast()都加上,显存能省下将近一半。
还有一个容易忽略的点是,PyTorch默认会缓存显存分配器,即使释放了tensor,显存也不会立刻降下来。如果在推理时做全图裁剪循环,建议用torch.cuda.empty_cache()在每张图处理完后清理一次。同时把推理数据也按patch切成一个list,一次性组成batch,比循环单张patch效率高得多。我测试过,同样一张DPDD全分辨率图,用batch推理比for循环快了2到3倍,显存峰值涨幅却很有限。
5.3 子孔径信息没有被网络真正利用
这是我在实验里最容易踩的坑:模型加了left-right输入,但PSNR只比单输入高0.1dB,搞不懂子孔径信息到底有没有被用上。排查方法很简单,做个消融实验:把右视图替换成左视图的副本,其他条件不变重新训练,比较结果。如果两者几乎一样,说明你的网络结构或输入方式没有真正利用左右差异。原因通常是网络内部没有强制显式比较两路特征,默认的卷积堆叠把左右视图当成两个独立通道处理,没有产生有效的交互。
解决办法是在网络入口加一个显式的“差分卷积”模块,比如对左右特征做减法之后,再和原始特征拼接,让每一层的感受野都能看到左右差异。另一个办法是计算一个全局子孔径置信度图,让网络知道哪些像素的左右信息可靠,哪些不可靠。这个置信度图可以直接来自差分图的局部统计量,也可以作为额外监督训练一个小网络来预测。做完这一步再去看消融实验,通常能拉开0.2到0.5dB的差距,说明子孔径信息终于被网络理解了。
最后说一个最容易被忽略的细节:验证集上PSNR涨了0.2,不一定代表子孔径信息真的被用上了。我习惯在训练时加一个对照组,把左右子孔径输入替换成平均图再训练一遍,如果差距不大,说明问题大概率出在入口特征设计或者子孔径没有对齐上。这个对照实验成本很低,但对判断工作方向非常有用。DPDD这类数据集的真正价值,不在于把指标刷得多高,而在于提供了一个能逼着你去理解散焦模糊物理过程的窗口。希望这篇内容能帮你少踩几个坑。
本文还有配套的精品资源,点击获取