
一、项目背景家里那些泛黄、模糊的老照片承载着珍贵的回忆。能不能用 AI 技术让它们变清晰答案是肯定的本文将带你从零开始搭建一个带有前端界面的老照片修复网站上传模糊图片后AI 会自动进行4 倍超分辨率重建 去噪处理并在网页上用滑块对比原图和修复后的高清图先上效果二、技术栈后端Python FlaskAI 模型Real-ESRGAN超分辨率去噪前端HTML CSS JavaScript小清新风格滑块对比)三、环境准备1. 安装 Python 3.10强烈推荐本项目在 Python 3.10.11 上稳定运行。请从官网下载 Windows 64-bit 安装包安装时勾选“Add Python 3.10 to PATH”2. 安装 PyCharm社区版即可下载 PyCharm Community Edition一路默认安装。3. 创建项目并配置虚拟环境打开 PyCharm → New ProjectLocation 选择一个空文件夹例如OldPhotoRestorer解释器选择“New environment using” → VirtualenvBase interpreter 选择刚才安装的 Python 3.10点击 Create4. 安装依赖包打开 PyCharm 底部的Terminal标签依次输入以下命令确保终端前方有(venv)标识pip install flask opencv-python pillow pip install torch2.0.1 torchvision0.15.2 --index-url https://download.pytorch.org/whl/cpu pip install numpy2 pip install realesrgan basicsr解释我们使用 CPU 版 PyTorch 2.0.1 NumPy 1.x避免了 DLL 错误和 API 不兼容的问题。四、下载预训练模型Real-ESRGAN 需要预训练权重文件。下载链接RealESRGAN_x4plus.pth约 64 MB若下载慢可使用 ghproxy 加速在项目根目录创建weights文件夹将下载的.pth文件放入其中。最终目录结构如下OldPhotoRestorer/ ├── app.py ├── index.html ├── weights/ │ └── RealESRGAN_x4plus.pth ├── static/ │ ├── uploads/ │ └── outputs/ └── venv/五、后端代码app.py完整代码可直接复制使用。已内置了 CPU 加速优化、自动尺寸缩放保证处理速度在可接受范围内。import os import cv2 import torch from flask import Flask, request, jsonify, send_from_directory from werkzeug.utils import secure_filename from realesrgan import RealESRGANer from basicsr.archs.rrdbnet_arch import RRDBNet app Flask(__name__) UPLOAD_FOLDER static/uploads OUTPUT_FOLDER static/outputs os.makedirs(UPLOAD_FOLDER, exist_okTrue) os.makedirs(OUTPUT_FOLDER, exist_okTrue) # 性能优化 torch.backends.cudnn.benchmark True torch.set_num_threads(4) os.environ[OMP_NUM_THREADS] 4 os.environ[MKL_NUM_THREADS] 4 # 设备检测 device torch.device(cuda if torch.cuda.is_available() else cpu) half True if device.type cuda else False # 加载模型 print(正在加载超分模型...) model RRDBNet(num_in_ch3, num_out_ch3, num_feat64, num_block23, num_grow_ch32, scale4) upsampler RealESRGANer( scale4, model_pathweights/RealESRGAN_x4plus.pth, modelmodel, tile400 if device.type cuda else 200, tile_pad10, pre_pad0, halfhalf, devicedevice, ) print(f模型加载完毕设备{device}半精度{half}) MAX_INPUT_SIZE 800 if device.type cpu else 1600 app.route(/) def index(): return send_from_directory(., index.html) app.route(/enhance, methods[POST]) def enhance(): if image not in request.files: return jsonify({error: 没有上传图片}), 400 file request.files[image] if file.filename : return jsonify({error: 文件名为空}), 400 filename secure_filename(file.filename) upload_path os.path.join(UPLOAD_FOLDER, filename) file.save(upload_path) img cv2.imread(upload_path, cv2.IMREAD_UNCHANGED) if img is None: return jsonify({error: 图片无法读取}), 400 # 长边限制加速 h, w img.shape[:2] max_side max(h, w) if max_side MAX_INPUT_SIZE: scale MAX_INPUT_SIZE / max_side new_w, new_h int(w * scale), int(h * scale) img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_AREA) try: output, _ upsampler.enhance(img, outscale4) except Exception as e: return jsonify({error: f处理失败: {str(e)}}), 500 output_filename fenhanced_{filename} output_path os.path.join(OUTPUT_FOLDER, output_filename) cv2.imwrite(output_path, output) return jsonify({ original: f/static/uploads/{filename}, enhanced: f/static/outputs/{output_filename} }) if __name__ __main__: app.run(debugTrue, port5000)六、前端界面index.html精美小清新风格滑块对比功能已实现拖动中间白线即可查看原图左和修复图右的差异。!DOCTYPE html html langzh head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title老照片修复 - 4倍超清增强/title style :root { --primary: #88b5b6; --primary-light: #b8d8d8; --bg: #f7faf9; --card-bg: #ffffff; --text: #2f4858; --text-light: #5d7a7e; --shadow: 0 8px 30px rgba(0,0,0,0.05); --radius: 20px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, sans-serif; background: linear-gradient(135deg, #e8f0f2 0%, #f4f9f9 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .container { width: 100%; max-width: 1000px; background: var(--card-bg); border-radius: 32px; box-shadow: 0 20px 50px rgba(0,0,0,0.08); padding: 40px 40px 30px; } h1 { font-size: 2.2rem; color: var(--text); text-align: center; font-weight: 600; margin-bottom: 6px; } .subtitle { text-align: center; color: var(--text-light); margin-bottom: 30px; } .upload-area { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 30px; border: 2px dashed var(--primary-light); border-radius: var(--radius); background: #f5fbfa; cursor: pointer; margin-bottom: 25px; } .upload-area:hover { background: #eaf8f6; border-color: var(--primary); } #fileInput { display: none; } .upload-icon { font-size: 3rem; } .upload-btn { background: var(--primary); color: white; border: none; padding: 12px 32px; border-radius: 30px; font-size: 1rem; cursor: pointer; box-shadow: 0 4px 10px rgba(136,181,182,0.4); transition: 0.2s; } .upload-btn:hover { background: #7ba4a5; transform: translateY(-1px); } .hint { font-size: 0.85rem; color: #9bb7b8; } .loading { display: none; text-align: center; padding: 20px; color: var(--text-light); } .spinner { border: 4px solid #e0e8e8; border-top: 4px solid var(--primary); border-radius: 50%; width: 40px; height: 40px; animation: spin 0.8s linear infinite; margin: 0 auto 12px; } keyframes spin { to { transform: rotate(360deg); } } .error { display: none; color: #c77d7d; background: #fff0f0; padding: 12px 20px; border-radius: 12px; margin-bottom: 20px; } .compare-wrapper { display: none; position: relative; width: 100%; max-width: 700px; margin: 10px auto 0; border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); user-select: none; } .compare-wrapper img { display: block; width: 100%; height: auto; background: #eaeaea; } .img-before { position: absolute; top: 0; left: 0; width: 100%; height: 100%; clip-path: inset(0 50% 0 0); z-index: 2; } .img-before img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; } .slider-handle { position: absolute; top: 0; bottom: 0; left: 50%; width: 4px; background: white; box-shadow: 0 0 10px rgba(0,0,0,0.3); z-index: 3; cursor: ew-resize; transform: translateX(-50%); border-radius: 2px; } .slider-handle::after { content: ◀ ▶; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 8px 12px; border-radius: 20px; font-size: 0.85rem; color: var(--text); box-shadow: 0 4px 10px rgba(0,0,0,0.15); white-space: nowrap; } .labels { position: absolute; bottom: 16px; left: 16px; z-index: 4; display: flex; gap: 12px; } .label { background: rgba(255,255,255,0.85); backdrop-filter: blur(6px); padding: 5px 15px; border-radius: 20px; font-size: 0.8rem; font-weight: 500; color: var(--text); } .label-original { background: rgba(255,255,255,0.9); } .label-enhanced { background: rgba(136,181,182,0.9); color: white; } media (max-width: 600px) { .container { padding: 25px 20px; } h1 { font-size: 1.8rem; } } /style /head body div classcontainer h1 旧时光修复师/h1 p classsubtitle上传模糊老照片AI 自动 4 倍放大去噪/p div classupload-area iduploadArea input typefile idfileInput acceptimage/* div classupload-icon/div button classupload-btn onclickdocument.getElementById(fileInput).click()选择照片/button span classhint支持 JPG、PNG、WEBP/span /div div classloading idloading div classspinner/div pAI 正在精心修复中… 大约需要 10 秒/p /div div classerror iderror/div div classcompare-wrapper idcompareWrapper img idimgEnhanced src alt修复后 styleposition: relative; z-index: 1; div classimg-before idimgBeforeContainer img idimgOriginal src alt原图 /div div classslider-handle idsliderHandle/div div classlabels span classlabel label-original原图/span span classlabel label-enhanced修复 4×/span /div /div /div script const fileInput document.getElementById(fileInput); const uploadArea document.getElementById(uploadArea); const loading document.getElementById(loading); const error document.getElementById(error); const compareWrapper document.getElementById(compareWrapper); const imgEnhanced document.getElementById(imgEnhanced); const imgOriginal document.getElementById(imgOriginal); const beforeContainer document.getElementById(imgBeforeContainer); const sliderHandle document.getElementById(sliderHandle); uploadArea.addEventListener(click, (e) { if (e.target.tagName ! BUTTON) fileInput.click(); }); uploadArea.addEventListener(dragover, e e.preventDefault()); uploadArea.addEventListener(drop, (e) { e.preventDefault(); const files e.dataTransfer.files; if (files.length) { fileInput.files files; handleUpload(files[0]); } }); fileInput.addEventListener(change, () { if (fileInput.files.length) handleUpload(fileInput.files[0]); }); async function handleUpload(file) { error.style.display none; compareWrapper.style.display none; loading.style.display block; const formData new FormData(); formData.append(image, file); try { const res await fetch(/enhance, { method: POST, body: formData }); const data await res.json(); if (data.error) { error.textContent ❌ data.error; error.style.display block; loading.style.display none; return; } imgOriginal.src data.original; imgEnhanced.src data.enhanced; let loaded 0; const check () { loaded; if (loaded 2) { compareWrapper.style.display block; loading.style.display none; beforeContainer.style.clipPath inset(0 50% 0 0); sliderHandle.style.left 50%; } }; imgOriginal.onload check; imgEnhanced.onload check; if (imgOriginal.complete) imgOriginal.onload(); if (imgEnhanced.complete) imgEnhanced.onload(); } catch (err) { error.textContent ❌ 网络或服务器错误; error.style.display block; loading.style.display none; } } let dragging false; function updateSlider(clientX) { const rect compareWrapper.getBoundingClientRect(); let x clientX - rect.left; x Math.max(0, Math.min(x, rect.width)); const pct (x / rect.width) * 100; beforeContainer.style.clipPath inset(0 ${100 - pct}% 0 0); sliderHandle.style.left pct %; } sliderHandle.addEventListener(mousedown, e { dragging true; e.preventDefault(); }); window.addEventListener(mousemove, e { if (dragging) updateSlider(e.clientX); }); window.addEventListener(mouseup, () dragging false); sliderHandle.addEventListener(touchstart, e { dragging true; e.preventDefault(); }); window.addEventListener(touchmove, e { if (dragging) updateSlider(e.touches[0].clientX); }); window.addEventListener(touchend, () dragging false); compareWrapper.addEventListener(click, e { if (e.target ! sliderHandle) updateSlider(e.clientX); }); /script /body /html七、运行项目在 PyCharm 中右键app.py→Run app稍等片刻终端显示模型加载完毕设备cpu半精度False * Running on http://127.0.0.1:5000打开浏览器访问http://127.0.0.1:5000就能看到上传界面上传一张老照片建议尺寸 600×400 左右等待约 15~30 秒纯 CPU修复完成后页面会出现可拖动的滑块对比效果。八、速度与效果说明处理时间在普通笔记本i5 8G 内存上800×600 图片耗时约 20 秒如果换成 GPU 版 PyTorch3~5 秒即可完成。清晰度4 倍超分 真实世界去噪对于老旧照片的线条、纹理恢复效果显著。但因原始图像质量限制过小的头像100px提升有限这是信息论极限。滑块交互鼠标拖动或触屏滑动均可流畅无卡顿九、公开数据集说明供作业引用本项目使用 Real-ESRGAN 官方发布的预训练模型RealESRGAN_x4plus.pth训练该模型时使用了以下公开数据集数据集 简介 链接 DIV2K 1000 张 2K 高质量图像 https://data.vision.ee.ethz.ch/cvl/DIV2K/ Flickr2K 2650 张 2K 图像 https://cv.snu.ac.kr/research/EDSR/Flickr2K.tar OST 室外场景训练集 https://openmmlab.oss-cn-hangzhou.aliyuncs.com/datasets/OST_dataset.zip RealSR 真实低-高分辨率图像对 https://github.com/csjcai/RealSR本人未重新训练模型因此无需自行构建数据集。十、常见问题与解决Q1运行时报No module named torchvision.transforms.functional_tensor原因NumPy 版本过高。执行pip install numpy2即可。Q2报OSError: [WinError 1114] 动态链接库(DLL)初始化例程失败安装 VC 运行库并确保使用 Python 3.10 PyTorch 2.0.1 CPU 版。Q3处理速度太慢将app.py中的MAX_INPUT_SIZE设为 600 可进一步提速但输出分辨率会降低。十一、总结与展望通过本文你成功搭建了一个完整的 AI 老照片修复 Web 应用掌握了Real-ESRGAN 模型的部署与推理Flask 后端接口设计前后端交互与文件处理纯 CSS 实现滑块对比效果后续可扩展方向部署到云服务器让其他人也能在线使用增加批量上传功能换成更轻量的模型如 ESRGAN以提高速度如果这篇博客对你有帮助欢迎点赞、收藏、关注有问题可在评论区交流。