基于深度学习的图像修复与修补:U-Net与mask引导的老照片复原关键技术

📅 发布时间:2026/9/16 7:36:05
基于深度学习的图像修复与修补:U-Net与mask引导的老照片复原关键技术
简介这套基于深度学习的老旧照片修复与修补项目源码面向具备Python与机器学习基础的开发者与研究人员用于对破损、划痕、模糊的老照片进行自动检测与高质量重建可广泛应用于老照片收藏、历史影像数字化及数字媒体修复等场景。源码共95个文件压缩包大小约39.27MB以63个Python脚本为核心覆盖数据集加载、特征提取、模型训练、人脸检测对齐、图像映射及GUI交互等完整流程同时附带19张PNG示例图、Dockerfile与权重自动下载脚本便于在不同环境快速部署。已有455人学习浏览适合作为图像修复方向的实战参考。项目不仅支持老旧照片的划痕修复与局部补全还包含超分辨率、降噪等扩展能力并配有清晰的目录组织与readme说明方便读者从零运行、二次开发或迁移到其他图像处理任务。1. 基于深度学习的老旧照片修复与修补源码先认清损伤结构再谈模型基于深度学习的老旧照片修复与修补第一步不是选模型而是看明白照片上的损伤。扫描一张上世纪合影300dpi 下问题全暴露划痕横穿人脸边缘缺角胶片颗粒糊住暗部黄褐色偏色带偏所有肤色。用去噪、锐化插件逐个试每修好一项就在另一项上留下新痕迹——退化是叠加的单功能算法天然不匹配。这套设计的核心是把「修复」和「修补」放进同一个网络输入侧拼接二进制 mask 通道标注损伤与缺失输出侧用按 mask 加权的损失把优化资源倾斜给破损区。两个任务共享权重推理时不用在模型间切换。适合跑过基础分类、想往图像复原方向落的工程师也适合手里有批量扫描件的人。单张 11G 显存卡就能从零训练到出图下面按模型选型、数据合成、训练调参、大图推理展开。2. 修复与修补的模型骨架U-Net 为什么比复杂结构更可靠2.1 老照片的四类退化与退化建模老照片上的退化是复合的不是单一噪声或单一模糊。工程上常用的退化模型把观测图写成I_obs M ⊙ T(I_clean) (1 - M) ⊙ εM 是标注破损位置的二进制 maskT 是全局退化偏色、模糊、压缩痕迹ε 是加性噪声。这个式子精确划出了修复与修补的分界当 M 全为 0模型做的是恢复被全局变换和噪声破坏的像素属于图像修复restoration当 M 非 0被 mask 盖住的像素完全没有观测值模型必须依靠周围上下文生成内容这才是修补inpainting。标题里「修复与修补」并列本质就是要求一个模型同时处理这两种情况。落到真实扫描件上退化可以拆成四类各自的处理逻辑差异很大划痕是稀疏、线性、高对比的结构损伤。像素占比很小但视觉权重极高。模型需要先定位再抑制定位信息最好由 mask 通道显式提供而不是指望卷积自己从数据里发现规律。撕裂与虫蛀孔洞是大面积缺失。局部滤波完全无效必须靠下采样带来的全局语义、以及跳连带回的边缘结构共同决定内容。胶片颗粒接近高斯噪声但空域分布不均匀。CNN 的滑窗统计天然匹配这类退化不需要单独串联一个去噪模块。偏色是全局通道缩放等价于可学习的 1×1 颜色映射。它本身最简单但和去噪、去划痕耦合在一起端到端优化更合适。四类退化里前两类靠 mask 引导后两类靠全局统计。要把它们放进同一个网络关键设计就是让 mask 成为输入的一部分。2.2 U-Net 的 3 个选择理由与参数规模对比U-Net 是 2015 年提出的结构在老照片修复这类数据量不大、退化类型杂、还要批量上生产的任务里它仍然是性价比最高的骨架。第一个理由是多尺度感受野与高频信息兼顾。四次下采样把 256×256 的输入压到 16×16最深层每个位置都相当于看到了整个 patch 的上下文这是补大面积缺失的前提跳连又把下采样前的高频特征原样带回解码器保证了划痕边缘这类细节不会被池化抹掉。去掉跳连的编解码结构输出往往是「结构对了、细节糊了」正是修复任务最忌讳的结果。第二个理由是收敛行为可预期。只用 L1/L2 损失就能收敛不依赖 GAN 的对抗平衡。对比 SwinIR 需要小心调节窗口注意力的归一化参数MPRNet 要维护多阶段监督U-Net 在单卡上从零开始训练极少出现 loss 发散的状况。第三个理由是层数边界清晰参数容量好控制。下表是三种骨架在实际修复场景里的对比。骨架参数规模混合退化适应度收敛可靠性256×256 推理显存实现成本U-Netbase48约 5M好高约 1.5 GB低SwinIRlarge 变体数十 M 量级中中约 3 GB中MPRNet约 20M多阶段开销大好中低约 4 GB高一个容易忽略的点修复任务里 SwinIR 的瓶颈不在参数量而在窗口注意力的 padding 和 reshape 开销批量处理几千张照片时速度差距会被放大。2.3 把 mask 拼进输入通道修复与修补共用一份权重的代码实现常见错误做法是把缺损区域直接置黑。网络看到的输入是一个黑色窟窿它分不清这个窟窿是「需要补的内容」还是「本来就暗的背景」于是倾向于输出模糊的暗色斑块。正解是把 mask 作为额外通道和 RGB 拼接输入变成四通道 (B, 4, H, W)。网络在每一层都知道哪些位置没有观测值解码器在这些位置可以放开手做语义生成在 mask 为 0 的位置则按恢复任务处理。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch4, out_ch3, base48): super().__init__() self.pool nn.MaxPool2d(2) self.e1 DoubleConv(in_ch, base) self.e2 DoubleConv(base, base * 2) self.e3 DoubleConv(base * 2, base * 4) self.e4 DoubleConv(base * 4, base * 8) self.up1 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.d1 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.d2 DoubleConv(base * 4, base * 2) self.up3 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.d3 DoubleConv(base * 2, base) self.out_conv nn.Conv2d(base, out_ch, 1) def forward(self, x): # x: (B, 4, H, W) 3 通道受损图 1 通道 mask h1 self.e1(x) h2 self.e2(self.pool(h1)) h3 self.e3(self.pool(h2)) h4 self.e4(self.pool(h3)) d self.d1(torch.cat([self.up1(h4), h3], dim1)) d self.d2(torch.cat([self.up2(d), h2], dim1)) d self.d3(torch.cat([self.up3(d), h1], dim1)) return torch.sigmoid(self.out_conv(d))逻辑说明编码器每次池化把空间尺寸减半、通道数翻倍四次之后 256×256 变成 16×16底层特征在感受野层面已经覆盖整个输入。解码器每次用转置卷积上采样再用 concat 把对应层的跳连特征接回来因此 DoubleConv 的输入通道数是「上采样结果 跳连特征」两者之和。最后的sigmoid把输出约束到 (0,1)与训练数据的归一化范围一致保存 PNG 时再乘回 255。参数说明in_ch4对应 RGBmask训练和推理必须保持一致如果只做纯修复不做修补改成 3 即可。base48是容量旋钮代表第一层卷积的输出通道数out_ch3固定为 RGB。使用 mask 通道时注意值域必须严格是 0/1 的 float不能是 0/255 的 uint8否则拼接后 mask 的数值范围比 RGB 大两个量级第一层卷积的输出会被它主导。2.4 网络层数与通道数修复任务真正值得调的两个旋钮深度学习里的「网络层数」在图像复原中和分类不同分类可以堆几十层换准确率修复模型堆深了只会让底层特征图变小、梯度信号变弱。对 256×256 输入四层下采样是公认的平衡点。只有缺失块大到占据图像宽度一半时才值得把 patch 提到 384 并加到五层下采样但底层特征图只剩 12×12需要显著增加 epoch 数才能充分收敛。模型容量不够时优先加 base 通道数而不是加层。base 从 48 提到 64参数量大约从 5M 涨到 8M依然可控base 到 96 约为 18M显存不是问题问题是数据量喂不喂得饱。几百张干净图用 base48 就够数据量到几千张再上 base64否则过拟合的速度比 PSNR 涨得快。3. 数据合成与 Dataset照片修复源码里最影响效果的部分3.1 用 OpenCV 在线合成划痕、缺失、噪声和偏色老照片修复没有天然的成对数据不可能拿到同一张照片损坏前后的两份扫描件。标准做法是拿干净的高质量图像现代数码照片、DIV2K/BSD 这类公开数据集在训练时随机合成退化把合成前后的图像对当作监督信号。这一步对真实泛化能力的影响超过后续任何模型结构改动。如果你按《动手学深度学习》的方式跑过图像分类这里唯一的变化是目标从标量变成像素张量。import cv2 import numpy as np def random_damage(img, max_scratch8, prob_missing0.2): img: float32RGB值域 [0,1] 返回 (受损图, mask)mask 中 1 表示需要修补的区域 h, w, _ img.shape damaged img.copy() mask np.zeros((h, w), dtypenp.float32) # 1) 划痕亮暗随机厚度随机 for _ in range(np.random.randint(1, max_scratch 1)): x0, y0 np.random.randint(0, w), np.random.randint(0, h) angle np.random.uniform(0, 2 * np.pi) length np.random.randint(30, max(h, w) // 2) x1 int(np.clip(x0 length * np.cos(angle), 0, w - 1)) y1 int(np.clip(y0 length * np.sin(angle), 0, h - 1)) color 1.0 if np.random.rand() 0.5 else 0.0 thickness np.random.randint(1, 3) cv2.line(damaged, (x0, y0), (x1, y1), (color, color, color), thickness) cv2.line(mask, (x0, y0), (x1, y1), 1.0, thickness) # 2) 缺失块模拟撕裂/撕角置黑并写入 mask if np.random.rand() prob_missing: bx, by np.random.randint(0, w // 2), np.random.randint(0, h // 2) bw np.random.randint(w // 8, w // 3) bh np.random.randint(h // 8, h // 3) damaged[by:by bh, bx:bx bw] 0.0 mask[by:by bh, bx:bx bw] 1.0 # 3) 胶片颗粒加性高斯噪声 noise np.random.normal(0, 0.04, (h, w, 1)) damaged np.clip(damaged noise, 0, 1) # 4) 褪色/偏色随机通道增益与偏置 gain np.random.uniform(0.85, 1.15, (1, 1, 3)) bias np.random.uniform(-0.05, 0.05, (1, 1, 3)) damaged np.clip(damaged * gain bias, 0, 1) return damaged, mask逻辑说明函数按顺序叠加划痕、缺失、噪声、偏色。划痕的 mask 必须与画线的 thickness 完全一致否则网络会学到「mask 边缘有一圈对应不上的残影」缺失区域的 mask 宽度与图像置零区域严格对应这是保证后续加权损失位置准确的前提。胶片颗粒和偏色故意不进 mask因为它们影响整幅图把它们也标进去会让 mask 变成全 1失去区分意义。参数说明max_scratch8控制每条样本的划痕数量上限真实扫描件通常在 3~15 条之间设太小模型学不到多划痕叠加的效果prob_missing0.2表示约五分之一样本带缺失块太低会让修补分支欠训练太高会让网络过度依赖 mask 而忽视全局恢复噪声 sigma 0.04 对应中等颗粒感偏色增益 0.85~1.15 模拟胶片褪色的不均衡。想让模型更鲁棒可以把划痕从直线换成cv2.polylines画的折线缺失块从矩形换成交错的随机多边形。3.2 Dataset 类把受损图、干净图、mask 打包成训练样本from torch.utils.data import Dataset import torch class OldPhotoPairDataset(Dataset): def __init__(self, clean_paths, patch_size256, trainTrue): self.clean_paths clean_paths self.patch_size patch_size self.train train def __len__(self): return len(self.clean_paths) def __getitem__(self, idx): img cv2.imread(self.clean_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 h, w, _ img.shape if self.train: x0 np.random.randint(0, w - self.patch_size 1) y0 np.random.randint(0, h - self.patch_size 1) img img[y0:y0 self.patch_size, x0:x0 self.patch_size] if np.random.rand() 0.5: img img[:, ::-1] k np.random.randint(0, 4) if k: img np.rot90(img, k).copy() damaged, mask random_damage(img) damaged_t torch.from_numpy(damaged.transpose(2, 0, 1).copy()).float() mask_t torch.from_numpy(mask).unsqueeze(0).float() clean_t torch.from_numpy(img.transpose(2, 0, 1).copy()).float() x torch.cat([damaged_t, mask_t], dim0) return x, clean_t, mask_t逻辑说明__getitem__返回三个张量——四通道输入 x、三通道干净图 clean、单通道 mask。训练时做随机裁剪、水平翻转和 90° 旋转验证时不裁剪、不做增强。裁剪发生在退化合成之前确保退化内容与 mask 始终对齐如果先在整图上合成再裁剪patch 边缘的划痕会被截断mask 标注宽度和实际损伤位置会出现偏差。注意rot90和[:, ::-1]都会产生非连续内存视图torch.from_numpy之前必须.copy()否则后面.float()会隐式复制多一次白花内存。参数说明patch_size256是显存与感受野的折衷缺失块大的数据集建议升到 384trainFalse时仍要求原图不小于 patch_size数据准备阶段把不足 256×256 的老照片先等比放大否则randint会抛异常。翻转和旋转对老照片很必要很多扫描件在扫描时就已经歪了 90° 或者左右反了。训练前先遍历所有路径调用一次cv2.imread确认非空影像文件损坏是这类管线最常出现的静默故障。3.3 按 mask 加权的恢复损失把优化资源写给破损区普通 L1 损失对所有像素一视同仁。划痕和缺失区域通常只占整张照片的 5%~10%模型完全忽视它们也能把全局 loss 压得很低结果就是训练结束时修补区域仍然模糊。加权损失的思路是单独计算 mask 区域的 L1并按面积归一化让修补损失和全局损失处在同一个量级。def restore_loss(pred, clean, mask, mask_weight5.0): # 全图 L1负责整体色调和结构 l1 torch.abs(pred - clean) base_loss l1.mean() # mask 区域 L1按面积归一化避免小破损被全局平均淹没 masked_loss (l1 * mask).sum() / (mask.sum() 1e-6) return base_loss mask_weight * masked_loss逻辑说明l1 * mask中 mask 会自动广播到三个通道只保留破损位置的误差除以mask.sum()得到的是破损区域的平均绝对误差与破损面积无关所以面积很小的划痕也能获得和整图同等的梯度量级。mask_weight是两者之间的天平默认 5.0 表示破损区域的优化优先级是全局的 5 倍。如果只靠加权 L1 输出还是偏平滑可以再加上感知损失用预训练 VGG16 的relu1_2、relu2_2、relu3_3三个特征层分别提取 pred 与 clean 的特征后计算 L1。感知损失和对抗损失的取舍如下表损失组合输出观感训练成本适用场景仅 L1平滑、保守低管线验证L1 mask 加权破损区域更干净低默认推荐再加 VGG 感知纹理自然中干净图 2000 张以上再加 PatchGAN 判别器锐利、细节多高追求观感且能接受调参这个顺序不要跳先让加权 L1 把结构修对再加感知损失提纹理最后才考虑对抗损失。跳过前两步直接上 GAN训练过程会同时面临位置不准和纹理伪影两个问题排错难度翻倍。4. 训练循环与参数调优epoch、patch、batch 怎么设才可靠4.1 训练主循环与断点续训from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader device cuda if torch.cuda.is_available() else cpu torch.backends.cudnn.benchmark True model UNet(in_ch4, out_ch3, base48).to(device) dataset OldPhotoPairDataset(clean_paths, patch_size256, trainTrue) loader DataLoader(dataset, batch_size8, shuffleTrue, num_workers4, drop_lastTrue) optimizer Adam(model.parameters(), lr1e-4, betas(0.9, 0.99)) scheduler CosineAnnealingLR(optimizer, T_max150, eta_min1e-6) for epoch in range(1, 151): model.train() epoch_loss 0.0 for x, clean, mask in loader: x, clean, mask x.to(device), clean.to(device), mask.to(device) pred model(x) loss restore_loss(pred, clean, mask, mask_weight5.0) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() epoch_loss loss.item() scheduler.step() print(fepoch {epoch:3d}/150 loss {epoch_loss / len(loader):.4f}) if epoch % 25 0: torch.save(model.state_dict(), funet_epoch_{epoch:03d}.pth)逻辑说明每个 epoch 内是标准的 forward-backward 三步梯度裁剪放在backward()之后、step()之前防止早期个别样本产生超大梯度推毁参数。CosineAnnealingLR的学习率从 1e-4 按余弦曲线衰减到 1e-6配合 150 个 epoch让网络前 60 个 epoch 大步搜索、后 90 个 epoch 精细收敛。断点续训不需要额外框架保存时把模型、optimizer、scheduler 三份state_dict放进同一个字典恢复时按同样顺序加载。参数说明drop_lastTrue是因为最后一个 batch 不足 8 张时BatchNorm 的统计量会偏num_workers4对应机械硬盘要降到 2、SSD 可以提到 8否则数据读取会成为训练瓶颈。cudnn.benchmark True在输入尺寸固定时能带来明显提速但输入尺寸不固定时反而会因为反复探查算法增加开销必须关掉。4.2 关键参数表参数推荐初值调优方向patch_size256×256缺失块大就升 384显存不足先降 batch 而非 patchbatch_size811G 卡的上限报 OOM 降到 4learning_rate1e-4loss 震荡降为 3e-550 epoch 不下降升到 3e-4total_epochs150验证集 PSNR 连续 20 epoch 不涨就早停mask_weight5.0破损区域效果差调到 8超过 10 容易出现彩色噪点gradient clip1.0前几轮 loss 爆炸降到 0.5weight_decay1e-5数据量小于一千张时加防止过拟合合成退化环境配置按 pytorch 官网的 CUDA 匹配流程走即可这份源码只用了基础算子pytorch 1.13 之后到 2.x 都能跑常见的 cuDNN 兼容问题会在第一个 epoch 直接抛错属于最容易排查的一类。4.3 三个典型症状与排查顺序第一个症状是 loss 不降或前二十个 epoch 震荡。优先怀疑数据管线而不是模型检查 mask 与损伤是否对齐最常见的 bug 是划痕画在img.copy()上、mask 却画在别的数组上两处 thickness 不一致。打印每个 batch 的mask.sum()并随机抽一组可视化 x、clean、mask 叠加图十秒钟就能确认问题出在哪一侧。第二个症状是输出像水彩画边缘柔和、没有纹理。这是纯 L1 损失的典型结果。按 3.3 的顺序先加 mask 加权再加 VGG 感知损失不要跳过结构修复直接上对抗损失否则训练会同时面对位置偏移和纹理伪影两个问题。第三个症状是训练集 PSNR 高、真实老照片效果差。这是合成退化和真实分布之间的 gap。改进手段划痕加弯曲度用cv2.polylines画折线、缺失块用随机多边形代替矩形、噪声里混合少量盐椒噪声。工程上还有一个偷懒但有效的技巧在几张真实照片上手动标注 mask把标注样本按 1:9 混进训练集专门修正分布偏移。5. 大图分块推理与羽化拼接源码里最后一段工程代码5.1 分块推理与羽化拼接代码训练时模型看的是 256×256 的 patch实际扫描件动辄 6000×4000。把整图直接喂进去显存先爆就算塞进去下采样四次后的特征图也失去了细小划痕的空间分辨率。标准做法是滑动窗口分块推理窗口之间留 overlap拼接时用羽化权重融合重叠区消除接缝。def infer_large(model, img, mask, tile_size512, overlap64, devicecuda): img: (H, W, 3) float32 [0,1] mask: (H, W) float321 表示破损 返回与 img 同尺寸的修复结果 model.eval() h, w img.shape[:2] stride tile_size - overlap pad_h (tile_size - h % tile_size) % tile_size pad_w (tile_size - w % tile_size) % tile_size img np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)), modeedge) mask np.pad(mask, ((0, pad_h), (0, pad_w)), modeconstant) H, W img.shape[:2] y_steps list(range(0, H - tile_size, stride)) if not y_steps or y_steps[-1] ! H - tile_size: y_steps.append(H - tile_size) x_steps list(range(0, W - tile_size, stride)) if not x_steps or x_steps[-1] ! W - tile_size: x_steps.append(W - tile_size) out np.zeros_like(img) weight np.zeros((H, W, 1), dtypenp.float32) half max(overlap // 2, 1) ramp np.ones(tile_size, dtypenp.float32) for i in range(half): ramp[i] i / half ramp[tile_size - 1 - i] i / half alpha ramp[:, None, None] * ramp[None, :, None] with torch.no_grad(): for y0 in y_steps: for x0 in x_steps: t_img img[y0:y0 tile_size, x0:x0 tile_size] t_mask mask[y0:y0 tile_size, x0:x0 tile_size] x torch.from_numpy(t_img.transpose(2, 0, 1)).unsqueeze(0).float().to(device) m torch.from_numpy(t_mask).unsqueeze(0).unsqueeze(0).float().to(device) pred model(torch.cat([x, m], dim1)) pred pred.squeeze(0).cpu().numpy().transpose(1, 2, 0) out[y0:y0 tile_size, x0:x0 tile_size] pred * alpha weight[y0:y0 tile_size, x0:x0 tile_size] alpha out / np.clip(weight, 1e-6, None) return out[:h, :w]逻辑说明每个 tile 推理完按羽化权重累加到输出缓冲区同时累加权重图最后逐元素除法归一化。羽化权重由两个一维 ramp 做外积得到tile 边缘贡献趋近于 0、中心为 1重叠区里两侧权重之和恰好构成平滑过渡接缝因此消失。步长列表末尾显式补一个H - tile_size保证 stride 不整除时最右和最下的边缘也被遍历到不会缺角。参数说明tile_size512是精度与速度的折衷显存紧张就降到 256overlap64是 tile 的八分之一重叠越小越快、接缝越明显。modeedge的 padding 只影响图像边缘一圈像素最终裁剪回原尺寸时会丢弃。提示遇到 mask 全为 0 的 tile 时直接复制原始像素跳过一次前向推理批量处理几百张图能省下三分之一以上的耗时。5.2 交付前的三项验证第一验证 masked PSNR。在保留的合成测试集上只计算 mask 区域的均方误差公式与 3.3 的加权损失一致。masked PSNR 与全局 PSNR 差距超过 8dB 时说明模型把大部分能力花在全局恢复上修补分支还需加训练量。第二验证羽化拼接缝。选一张缺陷较多的图分别用 overlap32、64、128 推理放大 200% 对比重叠区正常情况三者看不出差异如果 overlap64 还看到交叉虚影说明羽化 ramp 或归一化除法有 bug不是 overlap 不够。第三验证显存峰值。分块推理的显存峰值由单个 tile 决定与整图尺寸无关6000×4000 的图配 tile_size512 通常落在 2~3GB。最后留一个工程细节真实老照片的 mask 很少来自全自动标注通常是阈值分割加人工修正。mask 精度会直接传导到修补区域边缘拿捏尺度比调参更影响最终观感。标注时把 mask 边缘比划痕实际宽度放宽 1~2 像素修复痕迹反而更不明显。本文还有配套的精品资源点击获取