AoZai

drift_restore:把 Drifting 场搬到图像修复

· 12 分钟阅读

序章

上一篇文章里,drifting 场在生成任务上给出了一个低成本但有限的结果:数字清晰,汉字发糊。写那篇文章的时候其实已经在想另一个问题——drifting 场的价值可能不在从噪声生成,而在修复。

理由是这样的:图像修复(去噪、去压缩)的标准做法是 L1/L2 回归,收敛稳定但输出偏糊——回归到条件均值是它的固有属性;想锐就得加 GAN 或感知损失,训练立刻变得娇气;扩散模型效果好但要跑多步。而 drifting 场恰好是冲着”把输出往数据流形上推”设计的——它不改变输出的均值属性(L1 保证下限),但在 L1 之上叠加一个往数据流形方向的推力(冲上限)。

于是写了 drift_restore.py,一个单文件的一步修复实验:输出 = 一次前向传播,没有扩散链,GAN 项可选。这篇文章记录它的设计和在笔记本上的 stage-2 训练结果。


第一章:问题设定

任务与退化

任务定义为同时去噪和去 JPEG 压缩伪影。退化在线合成:对干净图加高斯噪声(σ ∈ [5, 25])再过一遍 JPEG(质量 30-90 随机),所以不需要预生成噪声数据集,HR 图即数据源,退化强度每个 crop 独立采样——这比固定强度更接近真实使用场景,也天然做了数据增广。

数据走 DIV2K 式布局,支持两种模式:

  • 合成模式:只有 HR 目录,训练时在线退化
  • 配对模式:给 HR + LR 两个目录,同名对齐裁剪,另以 0.5 概率叠加轻噪声

stage-2 实验用的就是配对模式(350 对预载进内存,约几个 GB,笔记本 RAM 刚好扛住)。

评价

PSNR, degraded 输入 vs 修复输出同 crop 对比。验证集只取 8 张图的固定种子 crop——这个决定后面会带来一点麻烦,先记下。


第二章:模型——小 U-Net + 残差零初始化

Restorer

骨干是个小型条件 U-Net:三级下采样(width → 2w → 4w)+ mid + 两级转置卷积上采样 + 跳跃连接,width=24 约 10M 参数,width=48 约 40M。没有注意力,没有预训练——这个任务尺度下卷积 U-Net 够用,参数预算留给宽度。

一个关键设计是输出层零初始化 + 全局残差:

self.out = nn.Conv2d(w, 3, 3, 1, 1)
nn.init.zeros_(self.out.weight)
nn.init.zeros_(self.out.bias)

def forward(self, x):
    ...
    return x_in + self.out(x)  # output = degraded + correction

训练开始时模型就是恒等映射——输出等于输入。这对 drifting 实验比对普通回归更重要:drifting 场假设生成样本在数据流形附近,恒等起点保证初期的退化图至少”在附近”,场从第一步起就有合理的推力方向,而不是从纯噪声里硬拽。

EMA 与瓦片推理

权重 EMA(decay 0.999)照搬生成实验的习惯,best checkpoint 按 EMA/raw 中验证更优的那个存。推理时对大图做重叠瓦片切分(默认 overlap 32),加权平均接缝——修复任务的输入是任意尺寸的真实图片,不是 64×64 的玩具。


第三章:损失——L1 打底,drifting 冲上限

组合方式

总损失:

loss = L1(rec, clean) + λ · min(1, step / warmup) · drifting(rec, clean)

三个细节:

drifting 项线性预热(默认 warmup 10000 步,stage-2 用的 4000)。前期 L1 先把输出拉到数据附近,drifting 的推力逐步加上去。如果一上来就全权重,场在输出还离流形很远时给出的方向基本是噪声,两个目标打架。

温度自适应沿用 τ = 0.05 · 平均距离。修复输出的像素距离尺度和生成实验完全不同(这里输出几乎就是干净图),自适应温度避免了重新调参。

像素 or VGG 特征空间可切(--drift-space pixel|vgg)。VGG 特征版本把场算在 relu3_3 特征上,梯度穿过生成器(修复网络)回传,冲的是纹理空间的流形。上一篇文章的教训是纯特征空间会被纹理欺骗,所以这里保留了像素默认值,VGG 作为后续实验项。

drifting 项的实现和 mixdrift/drift.py 完全同源(同一个 Algorithm 2),只是搬进了单文件:

def drifting_pixel_loss(x, pos, temp_frac=0.05):
    flat_x, flat_p = x.flatten(1), pos.flatten(1)
    with torch.no_grad():
        v = compute_drift(flat_x.detach(), flat_p.detach(), temp=-1.0 * temp_frac)
        target = flat_x.detach() + v
    loss = (flat_x - target).pow(2).mean()
    return loss, v.norm(dim=1).mean()

可选 GAN 项

留了一个很小的 patch 判别器(约 1M 参数,least-squares GAN),--gan-weight > 0 时启用,且默认推迟到 2000 步之后才加入——让 L1/drift 先稳定。stage-2 这次没开(gan_weight=0),它是留给”drifting 不够锐”时的备胎。


第四章:级联 stage-2——站在 M1 的肩膀上

为什么要级联

修复任务有个和生成不同的结构:一步很难同时把噪声、压缩伪影和细节全部修好。所以做了两阶段设计——--m1-run 指定一个已训练的 M1,推理/训练时输入先过冻结的 M1,M2 学习修正 M1 的残余误差:

输入 → M1(冻结) → M2(训练中) → 输出

训练时 M1 的输出作为 M2 的输入(clamp 后),监督目标仍然是干净 HR。这等于把”修干净”拆成”粗修 + 精修”两步,每步都是一次前向。

stage-2 配置

runs_laptop_v6_stage2/ 的完整配置:

项值
M1(冻结)runs_laptop_v4
网络宽度32(约 18M 参数)
crop / batch160 / 16
lr2e-4(AdamW)
drift 空间 / 权重pixel / 2.0
drift 预热4000 步
计划步数20000(实际跑到 11200 中断)

全程笔记本(无外接 GPU),batch 16 + crop 160 是显存和内存共同约束下的上限。

结果

50 次验证日志(每 200 步一次)的统计:

指标PSNR
退化输入(基线)平均 20.97 dB
级联修复输出平均 24.22 dB
最佳(step 2000)26.06 dB

平均增益 +3.25 dB,训练曲线:

stage-2 训练曲线

但曲线不好看

必须诚实记录:最佳 PSNR 出现在 step 2000(26.06),之后一路震荡在 23-26 dB 之间,没有再稳定超越。 这不是收敛后小幅波动——23.0 和 26.0 的差距在 PSNR 上是感知差异明显的。

两个可能的原因,权重不同:

  1. 验证集太小。 8 张图的固定 crop,单次验证的方差极大(看曲线毛刺就知道),26.06 这个”最佳”本身可能就有运气成分。真实均值可能没有掉那么多,但无法排除。
  2. drifting 项在持续扰动。 drift 范数全程在 2-11 之间跳(没有像生成实验那样收敛到接近 0),说明场认为输出始终离流形”有距离”。修复和生成不同——输入是真实退化图,输出永远不可能完美落在干净流形上,场就会一直推。预热结束后权重 2.0 的推力可能过强,把 EMA 权重拽出 L1 已经找到的好的位置。

事后看,drift_weight 2.0 对级联场景偏大——M1 已经修掉大部分退化,M2 面对的残余误差很小,L1 的梯度信号本来就弱,drifting 项相对就显得过强。下一步该做的是权重扫描(0.5 / 1.0 / 2.0)而不是继续堆步数。


第五章:复盘

和生成实验的对照

把两篇文章的结论放在一起看很有意思:

  • 生成任务(从噪声出发):drifting 场是唯一的训练信号,输出的质量完全取决于场的几何性质,类内方差大的数据直接暴露短板
  • 修复任务(从近似数据出发):L1 打底 + drifting 提推力,场的角色从”主引擎”降级为”增压器”,稳定性立刻好了一个量级

也就是说 drifting 场更适合当配角——在输出已经接近数据流形的场景里(修复、后处理、细化),它提供 L1 给不了的流形推力;在从纯噪声出发的场景里,它独自承担全部几何,就要求网络能跟随一条混沌的指派路径,这正好是小网络做不到的。

stage-2 的教训

级联设计本身工作正常——M2 在 M1 的输出上稳定地再赚 3 dB 量级。但两个工程决定需要修正:验证集 8 张图太小, best checkpoint 的选择可信度存疑;drift_weight 2.0 对级联场景偏大,步数堆到 20000 之前应该先做权重扫描。

若要继续

按性价比排序:

  1. drift_weight 扫描(0.5 / 1.0 / 2.0),并加大验证集到 32 张以上
  2. --drift-space vgg:特征空间的场 + 像素 L1 双项——上一篇文章里这个组合在生成任务发散,但修复场景的输出离流形近得多,发散风险完全不同
  3. 开 GAN 项对比:同样的锐度目标,least-squares GAN 和 drifting 场谁更稳
  4. stage-3 级联(M2 再冻结,M3 精修),观察增益是否递减

本项目借助了 AI 工具辅助代码实现和思路梳理。

Share: