基于深度学习的老照片修复系统:从数据合成到Web部署全流程
简介这份资源是一套基于深度学习的老照片修复系统完整项目包采用Python与TensorFlow实现核心修复模型并配套Web交互界面适合人工智能、计算机等相关专业学生、教师及开发者用于毕业设计、课程设计或项目立项演示。压缩包共21个文件约2.15MB包含7个py源码文件、3个html页面、5个png与2个jpg示例图片以及docx设计文档和md说明文件覆盖模型定义、颜色转换、表单处理、服务接口与前端模板等模块结构清晰便于二次开发。项目代码经过测试可正常运行已有163人学习关注。读者可从中获取完整的深度学习修复流程实现、Web端调用示例、模型文件与设计文档既能直接运行体验老照片上色与修复效果也能在此基础上修改功能用于毕设、课设或作业提交对入门深度学习与Web集成具有较高参考价值。1. 老照片修复系统到底在修什么从划痕到人脸的工程拆解很多人第一次接触老照片修复脑子里想的是「一键变清晰」但真正动手做一套基于深度学习的老照片修复系统你会发现它要解决的是三类完全不同的退化物理损伤划痕、折痕、污渍、成像退化模糊、噪点、低分辨率、以及内容缺失人脸五官模糊、大面积破损。这三类问题的修复策略在模型层面是分开的混在一起做结果就是划痕修掉了但人脸糊成一团或者人脸清晰了但背景全是伪影。这套系统用 Python TensorFlow 搭建前端套一个 Web 界面本质是把「图像修复」这个学术任务包装成普通人能用的工具。适合谁一是想拿一个完整项目练手深度学习工程链路的人二是手里真有老照片要处理、又不想把照片传到不明网站的人。我下面按「数据怎么造 → 模型怎么搭 → Web 怎么接 → 坑在哪」的顺序讲每一步都给能直接跑的代码和参数。2. 训练数据从哪来老照片退化数据集的三条构造路径2.1 为什么不能直接用 DIV2K 这类超分数据集老照片修复和普通超分最大的区别在于退化类型。DIV2K、Set5 这些数据集是「干净高清图 双三次下采样」造出来的低清图退化模型太理想。真实老照片的划痕是随机线条、噪点是胶片颗粒、模糊是镜头失焦加扫描抖动用理想退化训出来的模型遇到真实老照片会直接翻车。常见做法是三条路并行第一条用公开的老照片数据集比如 FFHQ 里挑出带老照片风格的子集做参考第二条自己写退化脚本在高清图上合成划痕、噪点、模糊第三条如果有真实老照片对同一张照片的破损版和修复版直接拿来做监督。我一般以第二条为主因为可控、量大、能覆盖各种退化组合。2.2 用 Python 合成划痕、噪点和模糊的完整脚本下面这段脚本把一张干净图退化成「老照片风格」的训练对核心是三个退化函数可以独立开关、独立调参。import cv2 import numpy as np import random def add_scratch(img, num8, thickness2): 在图像上随机画划痕模拟物理损伤 h, w img.shape[:2] out img.copy() for _ in range(num): x1, y1 random.randint(0, w), random.randint(0, h) # 划痕方向随机长度控制在图像对角线的 1/4 到 1/2 angle random.uniform(0, np.pi) length random.uniform(0.25, 0.5) * np.sqrt(h**2 w**2) x2 int(x1 length * np.cos(angle)) y2 int(y1 length * np.sin(angle)) color random.randint(200, 255) # 划痕偏白 cv2.line(out, (x1, y1), (x2, y2), (color, color, color), thickness) return out def add_gaussian_noise(img, sigma15): 加高斯噪点模拟胶片颗粒和扫描噪声 noise np.random.normal(0, sigma, img.shape).astype(np.float32) out np.clip(img.astype(np.float32) noise, 0, 255).astype(np.uint8) return out def add_blur(img, ksize5): 高斯模糊模拟失焦和扫描抖动 if ksize % 2 0: ksize 1 return cv2.GaussianBlur(img, (ksize, ksize), 0) def degrade(img_path, out_path): img cv2.imread(img_path) img cv2.resize(img, (256, 256)) # 统一到 256x256 训练 deg add_scratch(img, numrandom.randint(3, 10)) deg add_gaussian_noise(deg, sigmarandom.randint(5, 25)) deg add_blur(deg, ksizerandom.choice([3, 5, 7])) cv2.imwrite(out_path, deg) return deg if __name__ __main__: degrade(clean.jpg, degraded.jpg)逻辑说明add_scratch用随机起点、随机角度、随机长度画线颜色偏白是因为老照片划痕通常露出相纸底色。add_gaussian_noise的 sigma 控制噪点强度5 到 25 覆盖从轻微颗粒到严重噪点。add_blur的 ksize 必须是奇数这是 OpenCV 高斯核的硬性要求偶数会直接报错。参数说明划痕数量 3 到 10 条比较接近真实老照片太多会变成「花屏」而不是「划痕」。噪点 sigma 超过 30 后图像基本不可辨认训练时会拖慢收敛。模糊核 7 以上会让 256 尺寸的图丢失太多细节建议不超过 7。提示合成退化时划痕、噪点、模糊的叠加顺序会影响最终效果。我一般按「先划痕、再噪点、后模糊」的顺序因为真实老照片的划痕是物理损伤噪点和模糊是后续成像环节引入的。2.3 数据集划分和加载器的两个关键参数合成完数据后按 8:1:1 划分训练、验证、测试。加载器用tf.data.Dataset这里有两个参数直接决定训练稳不稳。import tensorflow as tf def load_dataset(degraded_dir, clean_dir, batch_size16, is_trainTrue): deg_paths tf.data.Dataset.list_files(degraded_dir /*.jpg) clean_paths tf.data.Dataset.list_files(clean_dir /*.jpg) def parse(deg_path, clean_path): deg tf.io.read_file(deg_path) deg tf.image.decode_jpeg(deg, channels3) deg tf.cast(deg, tf.float32) / 127.5 - 1.0 # 归一化到 [-1, 1] clean tf.io.read_file(clean_path) clean tf.image.decode_jpeg(clean, channels3) clean tf.cast(clean, tf.float32) / 127.5 - 1.0 return deg, clean ds tf.data.Dataset.zip((deg_paths, clean_paths)) if is_train: ds ds.shuffle(1000) # 缓冲区 1000太小会导致样本顺序相关 ds ds.map(parse, num_parallel_callstf.data.AUTOTUNE) ds ds.batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds逻辑说明归一化到 [-1, 1] 而不是 [0, 1]是因为生成器最后一层用 tanh 激活输出范围就是 [-1, 1]两边对齐后损失函数才好收敛。shuffle的缓冲区设 1000如果数据集只有几百张缓冲区要相应调小否则 shuffle 效果等于没打乱。参数说明batch_size 16 是 8GB 显存下的稳妥值显存够可以上 32。num_parallel_calls和prefetch都设 AUTOTUNE让 TensorFlow 自己根据 CPU 核数决定并行度手动设固定值反而容易成为瓶颈。3. 修复模型怎么搭生成器、判别器和损失函数的选型3.1 为什么选 U-Net 生成器而不是纯 CNN老照片修复本质是 image-to-image 任务输入和输出尺寸一致但需要模型「理解」哪些区域是划痕、哪些是真实内容。纯 CNN 堆叠的问题是感受野有限大划痕修到一半就断了。U-Net 的跳跃连接把编码器的浅层特征直接送到解码器划痕的边缘信息不会在降采样过程中丢光。我一般用 4 层下采样、4 层上采样的 U-Net每层卷积后接 BatchNorm 和 LeakyReLU。下采样用步长为 2 的卷积而不是池化因为池化会丢位置信息而修复任务对位置很敏感。from tensorflow.keras import layers, Model def build_generator(input_shape(256, 256, 3)): inputs layers.Input(shapeinput_shape) # 编码器4 层下采样 e1 layers.Conv2D(64, 4, strides2, paddingsame)(inputs) e1 layers.LeakyReLU(0.2)(e1) # 64x128x128 e2 layers.Conv2D(128, 4, strides2, paddingsame)(e1) e2 layers.BatchNormalization()(e2) e2 layers.LeakyReLU(0.2)(e2) # 128x64x64 e3 layers.Conv2D(256, 4, strides2, paddingsame)(e2) e3 layers.BatchNormalization()(e3) e3 layers.LeakyReLU(0.2)(e3) # 256x32x32 e4 layers.Conv2D(512, 4, strides2, paddingsame)(e3) e4 layers.BatchNormalization()(e4) e4 layers.LeakyReLU(0.2)(e4) # 512x16x16 # 解码器4 层上采样跳跃连接 d1 layers.Conv2DTranspose(256, 4, strides2, paddingsame)(e4) d1 layers.BatchNormalization()(d1) d1 layers.ReLU()(d1) d1 layers.Concatenate()([d1, e3]) # 拼接编码器特征 d2 layers.Conv2DTranspose(128, 4, strides2, paddingsame)(d1) d2 layers.BatchNormalization()(d2) d2 layers.ReLU()(d2) d2 layers.Concatenate()([d2, e2]) d3 layers.Conv2DTranspose(64, 4, strides2, paddingsame)(d2) d3 layers.BatchNormalization()(d3) d3 layers.ReLU()(d3) d3 layers.Concatenate()([d3, e1]) d4 layers.Conv2DTranspose(3, 4, strides2, paddingsame)(d3) outputs layers.Activation(tanh)(d4) # 输出 [-1, 1] return Model(inputs, outputs)逻辑说明编码器每层通道数翻倍、尺寸减半解码器反过来。跳跃连接用Concatenate而不是Add因为拼接保留的信息更多代价是通道数增加、计算量变大。最后一层用 tanh 是因为训练数据归一化到了 [-1, 1]输出必须同范围。参数说明LeakyReLU 的负斜率 0.2 是 GAN 生成器的常用值太小会导致梯度消失太大输出会不稳定。BatchNorm 在编码器和解码器都加但最后一层不加因为输出层需要保留原始数值范围。3.2 判别器用 PatchGAN 的理由和实现判别器如果输出一个标量真/假对 256x256 的图来说太粗糙模型只会关注整体像不像忽略局部划痕。PatchGAN 输出一个 N×N 的矩阵每个元素对应原图一个感受野区域的真假判断这样局部划痕修得不好会被直接惩罚。def build_discriminator(input_shape(256, 256, 3)): inputs layers.Input(shapeinput_shape) x layers.Conv2D(64, 4, strides2, paddingsame)(inputs) x layers.LeakyReLU(0.2)(x) x layers.Conv2D(128, 4, strides2, paddingsame)(x) x layers.BatchNormalization()(x) x layers.LeakyReLU(0.2)(x) x layers.Conv2D(256, 4, strides2, paddingsame)(x) x layers.BatchNormalization()(x) x layers.LeakyReLU(0.2)(x) x layers.Conv2D(512, 4, strides1, paddingsame)(x) x layers.BatchNormalization()(x) x layers.LeakyReLU(0.2)(x) outputs layers.Conv2D(1, 4, strides1, paddingsame)(x) # 输出 patch 矩阵 return Model(inputs, outputs)逻辑说明判别器前 3 层下采样最后两层保持尺寸输出一个 30×30 左右的矩阵256 输入下。每个输出值对应原图约 70×70 的区域这就是 PatchGAN 的感受野。训练时对矩阵取平均作为最终真假判断。参数说明判别器不加 sigmoid因为损失函数用BinaryCrossentropy(from_logitsTrue)在损失内部做 sigmoid 数值更稳定。最后一层通道数为 1每个位置输出一个 logit。3.3 损失函数组合L1、对抗损失和感知损失怎么配比只用 L1 损失输出会模糊因为 L1 对像素误差取平均模型倾向于输出「安全」的模糊值。只用对抗损失输出会有伪影因为判别器只关心「像不像真图」不关心「和原图差多少」。常见做法是三者加权。import tensorflow as tf bce tf.keras.losses.BinaryCrossentropy(from_logitsTrue) def generator_loss(disc_fake, gen_output, target, lambda_l1100, lambda_perc10): # 对抗损失让判别器把生成图判为真 adv_loss bce(tf.ones_like(disc_fake), disc_fake) # L1 损失像素级对齐 l1_loss tf.reduce_mean(tf.abs(target - gen_output)) # 感知损失用 VGG 特征做高层语义对齐 vgg tf.keras.applications.VGG19(include_topFalse, weightsimagenet) vgg.trainable False feat_real vgg(target) feat_fake vgg(gen_output) perc_loss tf.reduce_mean(tf.abs(feat_real - feat_fake)) total adv_loss lambda_l1 * l1_loss lambda_perc * perc_loss return total def discriminator_loss(disc_real, disc_fake): real_loss bce(tf.ones_like(disc_real), disc_real) fake_loss bce(tf.zeros_like(disc_fake), disc_fake) return (real_loss fake_loss) * 0.5逻辑说明lambda_l1100是 Pix2Pix 论文的经典值L1 权重大是为了让输出在像素层面贴近原图对抗损失负责「锐化」。感知损失用 VGG19 的中间层特征权重 10 是经验值太大输出会偏向 VGG 的语义而丢失细节。参数说明VGG19 的权重加载一次后要设trainableFalse否则每次前向都会更新 VGG 参数训练直接崩。感知损失只在训练时用推理时不需要 VGG所以部署时可以把这部分裁掉。4. Web 界面怎么接Flask 后端和前端上传的完整链路4.1 Flask 接口设计上传、推理、返回三个端点Web 界面不需要复杂核心就三个功能上传图片、跑模型、返回修复结果。用 Flask 写一个最小可用的服务前端用原生 HTML fetch不引框架。from flask import Flask, request, jsonify, send_file import numpy as np import cv2 import io import tensorflow as tf app Flask(__name__) model tf.keras.models.load_model(generator.h5) # 加载训练好的生成器 app.route(/upload, methods[POST]) def upload(): file request.files[image] img_bytes file.read() img cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR) img cv2.resize(img, (256, 256)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 归一化到 [-1, 1]和训练时一致 inp img.astype(np.float32) / 127.5 - 1.0 inp np.expand_dims(inp, axis0) pred model.predict(inp)[0] # 输出 [-1, 1] pred ((pred 1.0) * 127.5).astype(np.uint8) # 还原到 [0, 255] pred cv2.cvtColor(pred, cv2.COLOR_RGB2BGR) _, buf cv2.imencode(.jpg, pred) return send_file(io.BytesIO(buf.tobytes()), mimetypeimage/jpeg) if __name__ __main__: app.run(host0.0.0.0, port5000)逻辑说明cv2.imdecode从字节流解码图片避免写临时文件。归一化和反归一化必须和训练时完全一致否则输出颜色会偏。model.predict返回的是 batch 维度取[0]去掉。参数说明host0.0.0.0让服务监听所有网卡局域网内其他设备也能访问。port5000是 Flask 默认端口被占用就换 5001。生产环境不要用app.run换 gunicorn 或 waitress。4.2 前端上传和结果展示的最小 HTML!DOCTYPE html html head meta charsetutf-8 title老照片修复/title /head body input typefile idfileInput acceptimage/* button onclickupload()修复/button div styledisplay:flex; gap:20px; margin-top:20px; div p原图/p img idpreview stylemax-width:300px; /div div p修复后/p img idresult stylemax-width:300px; /div /div script document.getElementById(fileInput).onchange function(e) { const file e.target.files[0]; document.getElementById(preview).src URL.createObjectURL(file); }; async function upload() { const file document.getElementById(fileInput).files[0]; if (!file) return alert(请先选图片); const form new FormData(); form.append(image, file); const resp await fetch(/upload, { method: POST, body: form }); const blob await resp.blob(); document.getElementById(result).src URL.createObjectURL(blob); } /script /body /html逻辑说明URL.createObjectURL在本地生成图片预览不需要先把图传到服务器。fetch发 POST 请求FormData自动处理 multipart 编码。返回的 blob 直接转成 URL 给 img 标签。参数说明acceptimage/*限制文件选择器只显示图片。max-width:300px防止大图撑破布局实际部署可以加 CSS 自适应。5. 避坑与排查训练和部署中最容易翻车的五个点5.1 生成器输出全灰或全黑现象训练几个 epoch 后生成器输出一张纯灰或纯黑的图损失不再下降。原因L1 损失权重过大模型发现输出「平均值」就能让 L1 最小直接躺平。或者判别器太强生成器梯度消失。解决把lambda_l1从 100 降到 10 到 50 之间试同时检查判别器学习率是否比生成器高太多。常见做法是判别器学习率设生成器的 0.5 倍。5.2 修复结果有网格状伪影现象输出图上出现规则的方格纹理尤其在平坦区域明显。原因解码器用Conv2DTranspose时如果 kernel 不能被 stride 整除会出现棋盘效应。这是转置卷积的固有缺陷。解决把Conv2DTranspose换成UpSampling2D Conv2D的组合先最近邻上采样再卷积能消除棋盘效应。代价是参数量略增。5.3 Web 上传大图后服务卡死现象上传超过 2MB 的图片Flask 服务响应极慢甚至超时。原因model.predict对 256x256 输入很快但如果前端没限制尺寸用户传 4000x3000 的图resize 之前解码就耗掉大量内存。解决在 Flask 里加MAX_CONTENT_LENGTH限制上传大小同时前端用 canvas 先压缩再上传。app.config[MAX_CONTENT_LENGTH] 5 * 1024 * 1024 # 限制 5MB5.4 训练损失正常但推理结果全偏色现象训练时验证集 PSNR 正常但 Web 端推理出来的图偏蓝或偏绿。原因OpenCV 默认 BGR 通道顺序训练时如果用的是 PIL 或 tf.image 加载RGB推理时没做 BGR 到 RGB 的转换通道就反了。解决统一通道顺序。我一般训练和推理都用 RGB只在cv2.imread后立刻cv2.cvtColor(img, cv2.COLOR_BGR2RGB)输出时再转回 BGR 给cv2.imencode。5.5 模型文件加载报 Unknown layer现象load_model(generator.h5)报错提示某个自定义层无法识别。原因如果生成器里用了自定义层或自定义损失保存时没注册加载时 TensorFlow 找不到定义。解决保存时用model.save(generator.h5, include_optimizerFalse)推理只需要权重和结构。如果还有自定义层用custom_objects参数传入或者改用 SavedModel 格式保存整个计算图。6. 把修复效果再提一档分块推理和 TTA 两个实用技巧模型训完之后直接整图推理在 256 尺寸上够用但如果你想处理更大尺寸的照片或者想让效果再稳一点有两个技巧我一直在用。第一个是分块推理加重叠融合。把大图切成 256x256 的块块之间留 32 像素重叠每块单独推理后重叠区域取平均。这样既避免了显存爆炸又消除了块与块之间的接缝。代码上就是两层循环加一个权重矩阵重叠区权重从中心向边缘递减融合时按权重加权平均。def tile_inference(model, img, tile256, overlap32): h, w img.shape[:2] output np.zeros_like(img, dtypenp.float32) weight np.zeros((h, w, 1), dtypenp.float32) step tile - overlap for y in range(0, h, step): for x in range(0, w, step): y2 min(y tile, h) x2 min(x tile, w) y1 max(0, y2 - tile) x1 max(0, x2 - tile) patch img[y1:y2, x1:x2] inp patch.astype(np.float32) / 127.5 - 1.0 inp np.expand_dims(inp, 0) pred model.predict(inp, verbose0)[0] pred (pred 1.0) * 127.5 # 重叠区用汉宁窗加权边缘权重低 wy np.hanning(y2 - y1)[:, None] wx np.hanning(x2 - x1)[None, :] w_patch wy * wx w_patch w_patch[..., None] output[y1:y2, x1:x2] pred * w_patch weight[y1:y2, x1:x2] w_patch return (output / np.maximum(weight, 1e-6)).astype(np.uint8)逻辑说明hanning窗在块中心权重接近 1边缘接近 0融合时边缘区域主要靠相邻块补接缝自然消失。np.maximum(weight, 1e-6)防止除零。参数说明overlap 设 tile 的 1/8 到 1/4 之间32 对 256 的块是 1/8够用。overlap 太大推理次数增加太小接缝消不干净。第二个技巧是 TTA测试时增强。对输入图做水平翻转、垂直翻转各推理一次然后把结果翻转回来取平均。这样相当于把模型的效果「平滑」了一遍PSNR 通常能涨 0.2 到 0.5 dB。代价是推理时间翻 4 倍适合对质量要求高、不在乎速度的场景。def tta_inference(model, img): preds [] for flip_h in [False, True]: for flip_v in [False, True]: aug img.copy() if flip_h: aug aug[:, ::-1] if flip_v: aug aug[::-1, :] inp aug.astype(np.float32) / 127.5 - 1.0 inp np.expand_dims(inp, 0) pred model.predict(inp, verbose0)[0] pred (pred 1.0) * 127.5 if flip_h: pred pred[:, ::-1] if flip_v: pred pred[::-1, :] preds.append(pred) return np.mean(preds, axis0).astype(np.uint8)逻辑说明四种翻转组合覆盖了水平、垂直、中心对称三种变换取平均后模型对方向性伪影的敏感度降低。参数说明TTA 只建议在最终输出时用训练时不要开否则训练时间翻 4 倍且收益不明显。这两个技巧我一般组合用先分块推理处理大图再对每个块做 TTA。代价是推理时间变成原来的 4 倍乘以块数但换来的是接缝消失、伪影减少、整体观感明显提升。如果你的场景是「用户传一张老照片等几秒出结果」这个代价完全值得。希望帮到你。本文还有配套的精品资源点击获取