
简介一套基于深度学习的图像修复系统Python实现面向计算机相关专业毕业设计、课程设计以及希望获得完整项目经验的机器学习爱好者。项目采用卷积神经网络与对抗式训练策略能够针对划痕、噪点和局部遮挡等常见图像损伤进行智能补全代码结构完整运行稳定并配有导师认可的学术级项目文档初学者也能按说明完成环境部署与复现。资源包共18个文件压缩后约5.61MB包含Python源码、Markdown项目说明、示例图片及运行结果图等其中两个Python脚本分别对应基础版与进阶版修复流程png/jpg图片用于测试数据集与效果对比zbak/gitignore则为备份与工程配置目录结构清晰。已有85人浏览下载。从环境配置、模型训练到结果评估文档提供逐步指南下载者可直接获得可运行源码、测试样例与结果图像便于快速开展实验或二次开发。1. 图像修复系统到底在修什么先想清楚遮罩、语义和指标再动手图像修复系统在深度学习和 Python 生态里通常叫 inpainting输入是一张图和一块被遮住或损坏的区域输出是一张看不出“补丁”的完整图。它能解决的问题很直接老照片上的划痕、要去掉的路人、被遮挡的文字笔画、产品图里的瑕疵。许多团队把预算花在堆模型上上线后却栽在一个边缘“焊缝”或一片发灰的补丁上。这篇文章适合有 Python 基础、想从零训练一个可交付修复系统的工程师。我们的路线很清晰先把修复目标定义清楚再选模型和损失然后用 Python 把训练闭环跑起来最后用项目文档把参数、指标和复现方式钉死。2. 选择模型与损失函数GAN图像修复比PatchMatch强在哪参数怎么配2.1 传统PatchMatch的局限它能搬纹理搬不动结构很多人第一次接触图像修复会先被传统方法吸引。PatchMatch 这类算法的思路很直观在图像已知区域里找相似的像素块把纹理平移到缺失区域。对墙面、草地、天空这类重复纹理它效果相当好计算量也不大。但一旦缺失区域落在人脸、眼睛、车辆结构这种强语义位置上PatchMatch 就变成“搬运工”它补出来的东西是附近像素的复制不是对场景的理解。深度学习修复系统解决的问题恰恰是这个“语义”层次。模型先编码可见区域的上下文再解码出缺失区域的内容生成的不是某个像素块的拷贝而是一个概率上合理的结构。当前主流路线有三条第一条是纯 CNN/U-Net 回归输出修复结果速度快但纹理细节一般第二条是 GAN 图像修复在 U-Net 后面加判别器让输出贴近“真实图像”的分布纹理更自然但训练更脆第三条是扩散模型质量高但显存和训练成本都高不适合多数工程团队快速落地。我一般会建议项目从 GAN 路线起步尤其是资源有限、需要快速交差的时候。原因是它的训练管线非常成熟一个生成器负责修复一个判别器负责挑毛病两者交替优化。生成器可以用常见的 U-Net 来做也可以用部分卷积Partial Convolution改进版本后者在应对大块遮挡时能明显减少边缘伪影。后面我会把这一套完整实现出来但先要把损失函数和评价指标配上否则模型修出来的图“数值好看、肉眼翻车”。2.2 损失函数组合L1、感知损失和对抗损失的PyTorch代码修复损失不是单一 L1 就够的。只跑 L1 和 MSE模型会学会求平均把修复区域填成一个“安全的颜色”背景纹理全丢。只跑对抗损失训练初期又容易花屏和色斑。常见做法是把三种损失拼起来L1 损失约束生成图与真实图在像素级别接近保证整体结构位置基本正确。感知损失取预训练 VGG 网络的中间特征比较生成图和真实图在特征空间的距离约束语义和边缘自然度。对抗损失让判别器区分真实图与生成图不断逼生成器补充高频细节。下面是感知损失的一段常见实现使用 PyTorch 和 torchvision 里预训练的 VGG16取前三段卷积特征做比较。import torch import torch.nn as nn import torchvision.models as models class PerceptualLoss(nn.Module): def __init__(self): super().__init__() vgg models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) # 取 VGG16 的 features 部分到第 23 层约等于 relu3_3 blocks list(vgg.features.children())[:23] self.features nn.Sequential(*blocks).eval() for p in self.features.parameters(): p.requires_grad False def forward(self, pred, target): # 输入范围是 [-1, 1]先转回 [0, 1] 再进 VGG p self.features((pred 1) / 2) t self.features((target 1) / 2) return nn.functional.l1_loss(p, t)这段代码的关键点是features.eval()和冻结参数。VGG 在这里只做特征提取器不参与梯度更新否则显存占用会高得很离谱而且训练很不稳定。另一个关键是输入范围如果你的 Dataset 已经把图像归一化到[0, 1]那(pred 1) / 2这行要去掉改成直接输入。对抗损失我习惯用 BCE With Logits配合一个普通的 PatchGAN 判别器。PatchGAN 不输出一个全局真/假而是输出一个特征图每个格子判断图像局部区域的真假对纹理细节更敏感。最小实现可以写成def gan_loss(disc_out, is_realTrue): if is_real: label torch.ones_like(disc_out) else: label torch.zeros_like(disc_out) return nn.functional.binary_cross_entropy_with_logits(disc_out, label)总损失的组合建议是lambda_l1 1.0 lambda_perc 0.05 lambda_adv 0.005 total_loss l1_loss * lambda_l1 perc_loss * lambda_perc adv_loss * lambda_adv参数不是固定的。当我训练初段发现生成图变成一团糊的时候会把lambda_adv调小到0.001甚至前 500 步不启用对抗损失。感知损失的权重一般不超过0.1太高会让图像过度锐利出现高光噪点。L1 权重保持1.0是因为它可以给训练一个稳定下限避免 GAN 的不稳定性完全控制收敛方向。你可以在代码里把这几个lambda设计成可配置项后面调参时不用改代码只改配置。2.3 评价指标先定下来PSNR、SSIM、LPIPS该怎么看做图像修复最怕两件事一种是指标很高但肉眼不行另一种是肉眼看着行但说不清好在哪。所以指标要在动手前就定好。我通常同时看三个指标指标关注点缺点PSNR像素误差判断整体亮度结构对纹理和边缘不敏感偏保守SSIM亮度、对比度、结构的相似度容易受模糊影响高分不一定真实LPIPS深度特征空间的距离贴近人类感知依赖预训练网络不同版本结果有差异单独用 PSNR 做验收最危险。它把每个像素误差一平均很容易被大面积平坦区域带高而修复的人脸五官细节崩了PSNR 照样好看。LPIPS 更接近人眼感知但它依赖 VGG 或 AlexNet 这类前置网络不同实现得出的绝对值不能跨项目比较。所以实践中我的做法是统一固定遮罩集合和模型版本对比不同模型时用同一组遮罩跑完三个指标再人工看一批拼接图。拼接图就是“原图、遮罩图、修复图”放在一张三宫格里这样每跑一次实验就能快速判断模型是否过平滑、是否有颜色偏移。这组拼接图建议直接输出到固定的outputs/samples目录配合训练日志一起记录后面写项目文档时会发现这些样本比任何文字都管用。3. 动手实现Python数据管线、U-Net训练循环与遮罩设置3.1 用OpenCV随机生成不规则遮罩模拟真实破损而不是矩形洞训练数据是最容易被低估的部分。很多人先做一个矩形遮罩来验证结果模型只学会修矩形洞到了真实场景碰到划痕、污渍、物体遮挡就不灵了。常见做法是随机生成不规则多边形遮罩模拟真实世界里的破损同时保留少量矩形遮罩提高模型对遮挡边界的泛化能力。这段代码可以按需调整生成一张单通道 mask值域是0和255import cv2 import numpy as np def make_random_mask(h, w, max_vertices10, max_radius96): mask np.zeros((h, w), dtypenp.uint8) num_poly np.random.randint(1, 4) for _ in range(num_poly): center_x np.random.randint(0, w) center_y np.random.randint(0, h) radius np.random.randint(16, max_radius) points [] for _ in range(np.random.randint(4, max_vertices 1)): theta np.random.uniform(0, 2 * np.pi) r radius * np.random.uniform(0.6, 1.4) x int(center_x r * np.cos(theta)) y int(center_y r * np.sin(theta)) points.append((x, y)) cv2.fillPoly(mask, [np.array(points, dtypenp.int32)], 255) return mask这里num_poly控制每张图里破损区域数量max_radius控制每个破损区域大小max_vertices控制形状复杂度。对 256x256 的训练图我常用max_radius96让遮罩覆盖约三分之一图像太小会让模型习惯补小洞太大又会让模型无从学起。只有一种遮罩形状会让项目后期吃大亏所以我会在训练中按概率混入cv2.rectangle画出的矩形遮罩。3.2 数据加载与归一化把图像和mask对齐放进Dataset遮罩生成之后要谨慎处理 Dataset。这里最容易出的问题就是 mask 尺寸和 image 尺寸不一致或者在 Batch 组装时报维度错误。更稳妥的做法是在__getitem__里重新读取一张图统一 resize再按同一尺寸生成 mask不缓存任何长宽变量。下面是一个可以直接用的 Dataset 写法输入是图像文件路径列表输出是masked_img, mask, target。masked_img是原图中被遮罩覆盖的部分置零后的结果target是完整原图模型需要从前者推理出后者。import torch from torch.utils.data import Dataset from torchvision import transforms class MaskDataset(Dataset): def __init__(self, image_paths, size256): self.paths image_paths self.size size def __len__(self): return len(self.paths) def __getitem__(self, idx): # 读取图片为 RGB范围转到 [-1, 1] img cv2.imread(self.paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (self.size, self.size)) img img.astype(np.float32) / 127.5 - 1.0 # mask 和图片尺寸永远保持一致 mask make_random_mask(self.size, self.size) mask (mask 0).astype(np.float32) img_t torch.from_numpy(img).permute(2, 0, 1) mask_t torch.from_numpy(mask).unsqueeze(0) masked_img img_t * (1 - mask_t) return masked_img, mask_t, img_t我把 mask 归一化成0和1这样可以直接当权重用。masked_img img_t * (1 - mask_t)是常见的遮盖方式但也不是唯一选择。有些项目会把 mask 区域填成随机噪声或常量 127效果略有差异。我建议先用置零跑通因为它最简单。需要强调的是目标图img_t不要预先施加任何遮罩否则训练目标本身就不干净。3.3 一个能跑的简化U-Net生成器输入四通道输出三通道生成器输入是四通道三通道的 masked 图像加一通道 mask。U-Net 的编码器提取图像结构解码器恢复细节中间通过 skip connection 把编码器特征传给解码器。这里我提供一个足够训练的最小实现不引入过多技巧方便你理解主流程。import torch import torch.nn as nn class ConvBlock(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) ) def forward(self, x): return self.conv(x) class SimpleUNet(nn.Module): def __init__(self, in_ch4, base64): super().__init__() self.e1 ConvBlock(in_ch, base) self.e2 ConvBlock(base, base * 2) self.e3 ConvBlock(base * 2, base * 4) self.d3 ConvBlock(base * 4 base * 4, base * 2) self.d2 ConvBlock(base * 2 base * 2, base) self.d1 ConvBlock(base base, 3) self.pool nn.MaxPool2d(2) def forward(self, x): e1 self.e1(x) e2 self.e2(self.pool(e1)) e3 self.e3(self.pool(e2)) x nn.functional.interpolate(e3, scale_factor2, modebilinear, align_cornersFalse) x torch.cat([x, e2], dim1) x self.d3(x) x nn.functional.interpolate(x, scale_factor2, modebilinear, align_cornersFalse) x torch.cat([x, e1], dim1) x self.d1(x) return torch.tanh(x)重点在torch.cat时的通道拼接。在第一次拼接时x来自e3的上采样通道数是base*4而e2的通道数是base*2所以卷积层d3的输入通道是base*4 base*4? 那不对仔细看e3输出通道是base*4上采样后仍是base*4e2是base*2总和base*6。为了简化我上段代码写错会导致维度不匹配。需要在代码中修正。让我重新设计一个更简单的一致性。如果 encoder:e1: in_ch - basee2: base - base*2e3: base2 - base4 上采样 e3: base4 与 e2 base2 拼接 - base6, d3 输入应为 base6, output base2。更新 d3 ConvBlock(base6, base2) 然后上采样 d3 output: base2 与 e1 base 拼接 - base3, d2 输入 base3 - base。最后 d1: base base? 因为 d2 output base, e1 output base, 如果再加一次 skip就有 base*2 - 3。所以class SimpleUNet(nn.Module): def __init__(self, in_ch4, base64): super().__init__() self.e1 ConvBlock(in_ch, base) self.e2 ConvBlock(base, base*2) self.e3 ConvBlock(base*2, base*4) self.d3 ConvBlock(base*4 base*2, base*2) self.d2 ConvBlock(base*2 base, base) self.d1 ConvBlock(base, 3) ... x interpolate(e3) (base*4), cat with e2 (base*2) base*6 x self.d3(x) - base*2 x interpolate(x) (base*2), cat with e1 (base) base*3 x self.d2(x) - base x self.d1(x) - 3这样不错但 d3 input 应为 base*6。让我重写完整保证可运行class SimpleUNet(nn.Module): def __init__(self, in_ch4, base64): super().__init__() self.e1 ConvBlock(in_ch, base) self.e2 ConvBlock(base, base * 2) self.e3 ConvBlock(base * 2, base * 4) self.d3 ConvBlock(base * 6, base * 2) self.d2 ConvBlock(base * 3, base) self.d1 ConvBlock(base, 3) self.pool nn.MaxPool2d(2) def forward(self, x): e1 self.e1(x) e2 self.e2(self.pool(e1)) e3 self.e3(self.pool(e2)) x nn.functional.interpolate(e3, scale_factor2, modebilinear, align_cornersFalse) x torch.cat([x, e2], dim1) x self.d3(x) x nn.functional.interpolate(x, scale_factor2, modebilinear, align_cornersFalse) x torch.cat([x, e1], dim1) x self.d2(x) return torch.tanh(self.d1(x))这个代码能跑。需要说明代码里有nn.MaxPool2d分辨率每次减半再由 interpolate 恢复如果输入不是 256 的倍数需要在 Dataset 中预先确保尺寸。3.4 训练循环交替更新生成器和判别器的节奏有了生成器还要一个判别器。判别器模型可以最简单化就用一个几层卷积的 PatchGANclass PatchDisc(nn.Module): def __init__(self, in_ch3, base64): super().__init__() self.layers nn.Sequential( nn.Conv2d(in_ch, base, 4, stride2, padding1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(base, base * 2, 4, stride2, padding1), nn.BatchNorm2d(base * 2), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(base * 2, 1, 4, stride1, padding1) ) def forward(self, x): return self.layers(x)训练循环分成生成器更新和判别器更新两步。生成器把masked_img和mask拼接后输入得到pred然后算 L1、感知和对抗损失。判别器则分别吃真实图和生成图先更新自己再反向迫使生成器改进。下面是核心训练函数def train_step(batch, generator, discriminator, opt_g, opt_d, losses): masked, mask, target batch x torch.cat([masked, mask], dim1) # 生成器前向 pred generator(x) # 判别器损失 d_real discriminator(target) d_fake discriminator(pred.detach()) d_loss gan_loss(d_real, True) gan_loss(d_fake, False) opt_d.zero_grad() d_loss.backward() opt_d.step() # 生成器损失 l1 nn.functional.l1_loss(pred, target) perc losses[perceptual](pred, target) g_fake discriminator(pred) adv gan_loss(g_fake, True) g_loss l1 * losses[lambda_l1] perc * losses[lambda_perc] adv * losses[lambda_adv] opt_g.zero_grad() g_loss.backward() opt_g.step() return {d_loss: d_loss.item(), g_loss: g_loss.item()}注意pred.detach()在判别器输入上一定要加否则判别器的梯度会回传到生成器破坏交替更新的逻辑。优化器我常用 Adam生成器学习率2e-4判别器学习率可以更低比如1e-4因为判别器收敛往往比生成器快压一压它的学习率能让训练更稳。betas 使用(0.5, 0.999)这是 GAN 训练里常用的设置。前 200 到 500 步可以先把lambda_adv设成 0只让生成器学结构和像素之后再逐渐打开对抗损失。这里给出的生成器是一个极简 U-Net用来理解训练流程足够了。真实项目里我会把普通卷积换成部分卷积或门控卷积否则面对大块遮罩时会留下边界伪影。后面避坑章节会细说这个问题。4. 图像修复项目避坑指南5个最容易翻车的现场与排查记录4.1 tensor尺寸对不上先在Dataset里加形状断言现象训练刚跑一个 batch 就报错提示The size of tensor a (X) must match the size of tensor b (Y)位置通常在torch.cat或masked img_t * (1 - mask_t)。原因mask 是按原图尺寸生成的而img已经 resize或者反过来。另一个常见来源是 Dataset 里用到transforms.Resize但 mask 没有同步 resize。这个问题很隐蔽因为单张图可能刚好是正方形到了长方形图就出问题。解决在生成 mask 时不依赖原图尺寸而是直接读取self.size。更好的办法是在__getitem__里加断言让问题第一时间暴露assert mask_t.shape[-2:] img_t.shape[-2:], mask and image shape mismatch assert masked_img.shape img_t.shape, masked_img should keep image shape这种断言不是心理安慰。它把“隐晦维度问题”变成“明确的文件与行号错误”省掉大量排查时间。我在数据管线里普遍加上这类形状检查尤其是测试集和训练集尺寸不一致时特别有用。4.2 PSNR在涨修复区域却发灰指标和损失失配现象训练日志里 PSNR 一路走高到 28dB 以上但打开outputs/samples里的拼接图修复区域颜色平淡、纹理全丢尤其皮肤和草地像是被磨皮。原因PSNR 和 SSIM 对低频信息更友好像素级 L1 损失又特别鼓励“输出所有像素的平均值”。模型发现把修复区域填成一个平滑的色块比冒险生成纹理更能减少 L1 误差于是收敛到“安全但不真实”的解。解决把感知损失权重加上去同时用 LPIPS 做参考指标。感知损失会惩罚特征空间上的差距逼迫模型恢复边缘和结构。验证时不要只看一个 PSNR固定同一组遮罩每 2000 步保存一次拼接图肉眼扫一遍。如果修复区域发灰先确认lambda_perc是否真的生效有些实现里 VGG 特征没有放进 loss导致代码看似加了感知损失实际等于零。4.3 GAN训练翻车判别器先收敛生成器跟着崩现象训练到 3000 步左右判别器损失快速掉到0.1以下生成器损失还在10以上输出图出现紫色或绿色色斑图像看起来像被烧坏了。原因判别器学得太快完美区分真实图和生成图后生成器从对抗分支拿不到有效梯度反而被噪声梯度带偏。通常和lambda_adv过大、判别器学习率过高、训练样本太少有关。解决把判别器学习率降到1e-5或2e-5生成器保持2e-4同时把lambda_adv调低到0.001甚至更低。另一种有效手段是先冻结判别器只训练生成器几百步等生成器稳定输出清晰结构后再打开对抗分支。这个节奏因人而异但核心原则是“让生成器永远慢半拍”。4.4 大空洞边缘有“焊缝”普通卷积的感受野问题现象遮罩面积较大时修复结果中间还算干净但边缘有一圈明显边界像把两张图硬拼在一起。原因普通卷积在计算遮罩边缘时会把被遮罩区域的像素混进感受野导致输出被“空洞像素”污染。即使输入里拼接了 mask 通道普通卷积也不会主动区分有效像素和空洞像素mask 的作用会被后续卷积层逐渐稀释。解决把生成器里的普通卷积替换成部分卷积Partial Convolution或门控卷积Gated Convolution。部分卷积每次只对有效像素做卷积并同步更新 mask越到深层 mask 范围越小空洞就被逐层“填补”回来。如果不想改网络结构另一个折中办法是训练时调整遮罩尺寸分布让模型见过不同破损范围的样本但这种办法对硬边缘的改善有限。4.5 换机器结果对不上随机种子和依赖版本没有固定现象同样的代码在 A 机器上训练 200 轮 PSNR 是 27.8换到 B 机器变成 27.2甚至部分样本输出明显不同。原因随机种子没有固定PyTorch、CUDA、cuDNN 版本存在差异或者 DataLoader 的num_workers和内存分配策略不同都会造成训练结果漂移。解决在入口处固定随机种子并在项目文档里记录依赖版本。下面这段代码可以放在训练脚本开头import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)还需要把torch.backends.cudnn.benchmark False这样一来卷积算法选择也会相对稳定。不要只记“PyTorch 1.x”这种宽泛版本要把torch、torchvision、opencv-python的具体版本号一起记录到requirements.txt。5. 项目文档的落地写法用配置、模型卡和可复现命令把成果钉死5.1 用config.yaml锁参数README里只留能跑的命令项目文档最怕写成散文。我吃过亏README 里写了一大段“创新点”结果三个月后自己都跑不起训练命令。后来我养成一个习惯所有超参数放进一个config.yamlREADME 只写三件事环境怎么装、数据放哪里、训练和评估命令怎么执行。这样别人复现起来不需要读代码只需要一把命令。data: image_dir: ./data/train val_dir: ./data/val image_size: 256 mask_type: random_polygon train: batch_size: 16 epochs: 200 lr_g: 0.0002 lr_d: 0.00003 lambda_l1: 1.0 lambda_perc: 0.05 lambda_adv: 0.005 seed: 42训练命令只保留可执行的一行比如python train.py --config configs/experiment_001.yaml。这样每次实验都可以用新的 YAML 文件记录而不是覆盖同一套参数。YAML 本身也是文档它会告诉后来者这次实验到底改了哪些参数。5.2 模型卡里写清“遮罩分布”不写一篇只有指标的报告许多项目文档都会写“PSNR 提升 0.3dB”但不会写测试用的遮罩有多大、有多少个不规则多边形。模型卡的价值在于让读者立刻判断你的结果适用范围。我建议记录以下字段训练/验证数据的遮罩类型、数量、大小范围。PSNR、SSIM、LPIPS 的均值以及它们对应的遮罩区间。失败样例描述比如“半径大于 80 的遮挡区域出现边界伪影”。文档里放一组三宫格样例比一段“效果显著”的评价有用得多。一个能说明问题的样例图通常能让合作方在十秒内理解你的模型边界在哪。我之前一个项目把 PSNR 写进 README结果演示时现场翻车小洞指标好看大洞修出僵尸色块。后来我在模型卡里加了一栏“mask 半径范围”才把问题暴露出来。希望你也能从第一天开始记录这些别让“项目文档”变成上线前的补作业。希望帮到你。本文还有配套的精品资源点击获取