Node.js调用DeOldify API:构建全栈图像处理应用
老照片承载着珍贵的记忆,但褪色、发黄总是让人遗憾。手动修复不仅需要专业技能,还耗时耗力。现在,借助AI技术,我们可以让老照片自动恢复色彩,重现昔日光彩。
今天,我想和你分享一个完整的项目实践:如何用Node.js技术栈,从前端到后端,亲手搭建一个老照片上色的Web应用。这个应用不仅能让你上传黑白照片,还能实时看到处理进度,最后下载色彩鲜艳的修复结果。整个过程,我们将使用Express框架来封装强大的DeOldify图像上色服务。
无论你是想为自己的家庭相册添加一个酷炫的工具,还是想学习如何将AI模型集成到Web应用中,这篇文章都会带你一步步实现。我们会从环境搭建讲到前后端联调,并提供关键代码和避坑指南。准备好了吗?让我们开始吧。
1. 项目整体设计与技术选型
在动手写代码之前,我们先来看看这个应用要做什么,以及为什么选择这些技术。
想象一下用户的使用流程:他打开一个网页,上传一张爷爷奶奶的旧照片,点击“开始上色”按钮。然后,网页上会显示一个进度条,告诉他“正在处理中...”。几分钟后,处理完成,页面上并排展示原图和焕然一新的彩色图,用户可以满意地下载保存。
为了实现这个流程,我们需要一个后端服务来处理图像上色的重头戏,以及一个前端界面来让用户方便地操作。这里我选择了全栈JavaScript的方案,也就是前后端都用Node.js生态下的工具,这样技术栈统一,学习和维护起来都更简单。
后端核心(Server Side):
- Node.js & Express:这是我们的基石。Node.js让JavaScript可以运行在服务器端,而Express是最流行、最轻量的Web框架,用它来搭建API接口和路由非常快捷。
- DeOldify API:图像上色的“魔法”来源。DeOldify是一个基于深度学习的老照片着色项目,效果非常出色。我们不会直接部署复杂的模型,而是通过调用其提供的API服务(或自己部署的API端点)来完成上色任务。这大大降低了技术门槛。
- 文件处理与队列:用户上传的图片需要暂存,上色任务可能需要排队处理(特别是如果同时有很多用户),处理完的图片也需要提供下载。我们会用到
multer处理文件上传,bull或类似库来管理任务队列,确保应用稳定。
前端界面(Client Side):
- HTML/CSS/JavaScript:经典的前端三件套,足够构建我们这个交互不算特别复杂的页面。我们会用原生JS配合一些现代API(如
Fetch API、FileReader)来实现上传、预览和进度显示。 - 进度反馈:这是提升用户体验的关键。通过WebSocket或简单的轮询(Polling),前端可以不断向后端询问“我的照片处理到哪一步了”,并把进度实时展示给用户。
整个系统的数据流很简单:前端上传图片到后端 -> 后端接收图片,将其加入处理队列 -> DeOldify API处理图片 -> 后端保存处理结果 -> 前端查询进度并最终展示和提供下载。
选择全栈Node.js来构建,最大的好处就是“一种语言,全栈通吃”。你不需要在Python(AI模型)、Java(后端)和JavaScript(前端)之间来回切换上下文,开发效率会高很多。
2. 后端搭建:Express服务与API封装
后端是我们的“大脑”和“调度中心”。它要接收前端的请求,协调文件存储、任务队列,并最终调用DeOldify服务。让我们一步步把它搭建起来。
2.1 环境准备与项目初始化
首先,确保你的电脑上已经安装了Node.js。打开终端,运行node -v和npm -v,如果能显示版本号(比如v18.x或更高),就说明环境没问题。如果没有,可以去Node.js官网下载安装包,安装过程很简单,一直点“下一步”就行。
接下来,我们创建一个新的项目目录并初始化它:
mkdir deoldify-web-app cd deoldify-web-app npm init -y这个命令会生成一个package.json文件,它就像项目的“身份证”和“菜单”,记录了项目信息和需要的各种工具包(依赖)。
现在,安装我们后端需要的核心“工具包”:
npm install express multer axios bull cors dotenv我来简单解释一下它们各自的作用:
express:Web框架,用来快速搭建服务器和定义API。multer:中间件,专门处理前端传过来的文件(比如图片)。axios:一个非常好用的HTTP客户端,我们用它来向DeOldify的API发送请求。bull:一个Node.js的队列库。想象一下银行排队叫号,bull就是那个管理排队系统的工具。当多个用户同时上传照片时,它能让任务一个一个有序处理,不会挤垮服务器。cors:中间件,用来解决前端和后端在不同域名下通信被浏览器拦截的问题(开发时很常见)。dotenv:让我们可以方便地从.env文件里读取配置,比如API密钥、服务器端口,这样就不用把敏感信息硬写在代码里。
2.2 核心服务器与路由设计
环境准备好后,我们开始写代码。在项目根目录创建一个server.js文件,这是后端的入口。
// server.js const express = require('express'); const cors = require('cors'); const path = require('path'); require('dotenv').config(); // 加载环境变量 const app = express(); const PORT = process.env.PORT || 3000; // 应用中间件 app.use(cors()); // 允许跨域请求 app.use(express.json()); // 解析JSON格式的请求体 app.use(express.urlencoded({ extended: true })); // 解析URL编码的请求体 // 设置静态文件目录,用于存放上传的图片和处理后的图片,并允许前端访问 app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/results', express.static(path.join(__dirname, 'results'))); // 引入我们即将编写的路由 const uploadRoutes = require('./routes/upload'); const taskRoutes = require('./routes/task'); app.use('/api/upload', uploadRoutes); app.use('/api/task', taskRoutes); // 基础健康检查路由 app.get('/health', (req, res) => { res.json({ status: 'OK', message: 'DeOldify 服务后端运行正常' }); }); // 启动服务器 app.listen(PORT, () => { console.log(`后端服务器已启动,运行在 http://localhost:${PORT}`); });代码很清晰:我们创建了一个Express应用,设置了端口,应用了必要的中间件,指定了存放图片的文件夹可以被公开访问,并挂载了两个主要的API路由。最后启动了服务器。
接下来,我们创建routes文件夹,并在里面定义这两个核心路由。
2.3 文件上传与任务队列实现
首先处理上传。在routes/upload.js中,我们使用multer来接收前端传过来的图片文件。
// routes/upload.js const express = require('express'); const multer = require('multer'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); // 用于生成唯一任务ID const { processImageQueue } = require('../queues/imageQueue'); // 引入任务队列 const router = express.Router(); // 配置multer:指定图片存储位置和文件名 const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, 'uploads/'); // 图片临时存放在 uploads 文件夹 }, filename: function (req, file, cb) { // 生成唯一文件名,避免重复 const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, uniqueSuffix + path.extname(file.originalname)); } }); const upload = multer({ storage: storage }); // 文件上传接口 router.post('/', upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: '请上传图片文件' }); } const taskId = uuidv4(); // 为这个上色任务生成唯一ID const originalImagePath = req.file.path; const originalImageUrl = `/uploads/${req.file.filename}`; // 将任务信息存入队列 const job = await processImageQueue.add({ taskId, originalImagePath, originalImageUrl, filename: req.file.filename }); // 立即返回任务ID和原始图URL给前端,前端凭此查询进度 res.json({ success: true, message: '图片上传成功,已加入处理队列', taskId: job.id, // Bull队列任务的ID originalImageUrl: originalImageUrl }); } catch (error) { console.error('上传处理错误:', error); res.status(500).json({ error: '服务器处理上传时出错' }); } }); module.exports = router;这个接口做了几件事:接收文件、存到uploads文件夹、生成一个唯一任务ID,然后最关键的一步——把任务信息丢进一个叫processImageQueue的队列里,而不是立即处理。这样做的好处是,即使瞬间有大量上传请求,服务器也能从容应对,按顺序处理。
现在来看看队列是怎么工作的。我们创建queues/imageQueue.js:
// queues/imageQueue.js const Queue = require('bull'); const { colorizeImage } = require('../services/deoldifyService'); // 引入真正的上色服务 const path = require('path'); // 创建一个名为 'imageColorization' 的队列 const processImageQueue = new Queue('imageColorization', { redis: { // Bull需要Redis来存储队列状态,你需要本地安装并运行Redis host: '127.0.0.1', port: 6379 } }); // 定义队列中的任务处理器 processImageQueue.process(async (job) => { const { taskId, originalImagePath, filename } = job.data; console.log(`开始处理任务: ${taskId}`); // 更新任务状态为“处理中” job.progress(50); try { // 调用DeOldify服务进行上色,这是核心步骤 const resultImagePath = await colorizeImage(originalImagePath, filename); // 模拟一个耗时过程,实际调用API可能需要几秒到几十秒 // await new Promise(resolve => setTimeout(resolve, 3000)); const resultImageUrl = `/results/${path.basename(resultImagePath)}`; // 更新任务状态为“完成” job.progress(100); // 返回处理结果 return { taskId, status: 'completed', resultImageUrl: resultImageUrl }; } catch (error) { console.error(`任务 ${taskId} 处理失败:`, error); throw error; // 抛出错误,Bull会将任务标记为失败 } }); module.exports = { processImageQueue };队列就像一个流水线,process函数定义了这个流水线上的工人做什么。工人拿到任务(job)后,会去调用我们接下来要封装的colorizeImage函数(也就是联系DeOldify API),处理完后把结果路径返回。
2.4 封装DeOldify API调用
这是后端最“魔法”的部分。我们在services/deoldifyService.js中封装与DeOldify服务的通信。
// services/deoldifyService.js const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); require('dotenv').config(); // 从环境变量读取DeOldify API的地址和密钥(如果有的话) const DEOLDIFY_API_URL = process.env.DEOLDIFY_API_URL || 'https://api.example.com/colorize'; // 替换为你的实际API地址 const API_KEY = process.env.DEOLDIFY_API_KEY; // 如果API需要密钥 /** * 调用DeOldify API为图片上色 * @param {string} imagePath - 原始图片的本地路径 * @param {string} filename - 原始文件名 * @returns {Promise<string>} - 返回处理后图片的本地路径 */ async function colorizeImage(imagePath, filename) { try { console.log(`正在调用DeOldify API处理图片: ${filename}`); // 1. 读取本地图片文件,准备上传 const imageBuffer = await fs.readFile(imagePath); // 2. 构建FormData(通过axios的multipart/form-data格式) const formData = new FormData(); const blob = new Blob([imageBuffer]); // 注意:Node.js环境需使用form-data库或Buffer直接发送 // 在Node.js中,我们通常这样构造: const FormData = require('form-data'); const form = new FormData(); form.append('image', imageBuffer, filename); // 3. 配置请求头(包括API密钥) const headers = { ...form.getHeaders(), }; if (API_KEY) { headers['Authorization'] = `Bearer ${API_KEY}`; } // 4. 发送POST请求到DeOldify API const response = await axios.post(DEOLDIFY_API_URL, form, { headers: headers, responseType: 'arraybuffer', // 因为返回的是图片二进制数据 timeout: 60000 // 设置超时时间,处理图片可能较慢 }); // 5. 检查响应是否成功 if (response.status !== 200) { throw new Error(`API调用失败,状态码: ${response.status}`); } // 6. 保存处理后的图片到 results 目录 const resultFilename = `colorized_${Date.now()}_${filename}`; const resultPath = path.join(__dirname, '..', 'results', resultFilename); await fs.writeFile(resultPath, response.data); console.log(`图片上色完成,已保存至: ${resultPath}`); return resultPath; } catch (error) { console.error('调用DeOldify API时发生错误:', error.message); // 这里可以添加更详细的错误处理,比如重试逻辑 throw new Error(`图片上色处理失败: ${error.message}`); } } module.exports = { colorizeImage };这段代码是后端与AI模型服务的桥梁。它读取用户上传的图片,通过HTTP请求发送给DeOldify服务,接收处理好的彩色图片,并保存到服务器上。你需要将DEOLDIFY_API_URL替换成你实际可用的DeOldify API端点。这可能是一个你自己部署的模型服务,也可能是某个云服务商提供的API。
2.5 任务状态查询接口
任务进入队列后,前端需要知道处理进度。我们在routes/task.js中提供一个查询接口。
// routes/task.js const express = require('express'); const { processImageQueue } = require('../queues/imageQueue'); const router = express.Router(); // 查询任务状态接口 router.get('/:jobId', async (req, res) => { const { jobId } = req.params; try { const job = await processImageQueue.getJob(jobId); if (!job) { return res.status(404).json({ error: '未找到该任务' }); } const state = await job.getState(); // 获取任务当前状态:waiting, active, completed, failed等 const progress = job.progress(); // 获取进度(我们之前设置了50和100) let responseData = { jobId, state, progress }; // 如果任务完成,把结果图片的URL也返回 if (state === 'completed') { const result = await job.returnvalue; responseData.result = result; } // 如果任务失败,返回错误信息 if (state === 'failed') { responseData.error = job.failedReason; } res.json(responseData); } catch (error) { console.error('查询任务状态错误:', error); res.status(500).json({ error: '查询任务状态时出错' }); } }); module.exports = router;这样,前端就可以定期调用GET /api/task/你的任务ID来获取任务的最新状态和进度了。
至此,一个具备文件上传、任务队列管理、AI服务调用和状态查询能力的后端就搭建好了。别忘了,你还需要在本地或服务器上安装并运行Redis,因为Bull队列依赖它。安装Redis后,在终端运行redis-server启动它。
3. 前端实现:交互式用户界面
后端在默默工作,前端则需要提供一个友好的界面给用户。我们将创建一个简单的单页面,包含上传、预览、进度展示和结果下载功能。
在项目根目录创建一个public文件夹,里面放我们的前端页面index.html和相关的CSS、JS文件。
3.1 页面结构与样式
我们先来写一个简洁明了的HTML页面。
<!-- public/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>老照片AI上色工具</title> <link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> </head> <body> <div class="container"> <header> <h1><i class="fas fa-palette"></i> 老照片AI上色工具</h1> <p class="subtitle">上传黑白或褪色老照片,让人工智能为它恢复鲜艳色彩</p> </header> <main> <section class="upload-section"> <h2>1. 上传照片</h2> <div class="upload-area" id="uploadArea"> <i class="fas fa-cloud-upload-alt upload-icon"></i> <p>点击或拖拽图片到此处上传</p> <p class="upload-hint">支持 JPG, PNG 格式,建议图片小于5MB</p> <input type="file" id="fileInput" accept="image/*" hidden> </div> <button id="uploadBtn" class="btn btn-primary" disabled> <i class="fas fa-upload"></i> 开始上色处理 </button> </section> <section class="preview-section" id="previewSection" style="display: none;"> <h2>2. 图片预览</h2> <div class="preview-container"> <div class="image-box"> <h3>原图</h3> <img id="originalPreview" src="" alt="原始图片"> </div> <div class="image-box"> <h3>AI上色结果 <span id="statusBadge" class="badge">等待中</span></h3> <div class="result-placeholder" id="resultPlaceholder"> <i class="fas fa-hourglass-half"></i> <p>正在努力上色中...</p> </div> <img id="resultPreview" src="" alt="上色结果" style="display: none;"> </div> </div> </section> <section class="progress-section" id="progressSection" style="display: none;"> <h2>3. 处理进度</h2> <div class="progress-container"> <div class="progress-bar"> <div class="progress-fill" id="progressFill"></div> </div> <div class="progress-text" id="progressText">准备中...</div> </div> <div class="task-info"> <p>任务ID: <code id="taskIdDisplay"></code></p> </div> </section> <section class="action-section" id="actionSection" style="display: none;"> <h2>4. 完成</h2> <p>处理完成!你可以下载焕然一新的彩色照片。</p> <div class="action-buttons"> <a id="downloadBtn" class="btn btn-success" href="#" download> <i class="fas fa-download"></i> 下载彩色照片 </a> <button id="resetBtn" class="btn btn-secondary"> <i class="fas fa-redo"></i> 处理新照片 </button> </div> </section> </main> <footer> <p>本工具基于 DeOldify AI 模型构建 | 全栈项目演示</p> </footer> </div> <script src="script.js"></script> </body> </html>为了让页面好看一点,我们加一些简单的CSS样式。
/* public/style.css */ * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } body { background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 20px; } .container { background-color: white; border-radius: 20px; box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); width: 100%; max-width: 1000px; padding: 40px; margin: 20px auto; } header { text-align: center; margin-bottom: 40px; } header h1 { color: #2c3e50; font-size: 2.8rem; margin-bottom: 10px; } header .subtitle { color: #7f8c8d; font-size: 1.2rem; } section { margin-bottom: 40px; padding-bottom: 30px; border-bottom: 1px solid #ecf0f1; } section:last-of-type { border-bottom: none; } h2 { color: #3498db; margin-bottom: 20px; font-size: 1.8rem; } /* 上传区域样式 */ .upload-area { border: 3px dashed #3498db; border-radius: 15px; padding: 60px 20px; text-align: center; cursor: pointer; transition: all 0.3s ease; background-color: #f8fafc; margin-bottom: 25px; } .upload-area:hover { background-color: #e8f4fc; border-color: #2980b9; } .upload-icon { font-size: 4rem; color: #3498db; margin-bottom: 20px; } .upload-hint { color: #95a5a6; font-size: 0.9rem; margin-top: 10px; } /* 按钮样式 */ .btn { padding: 15px 30px; border: none; border-radius: 10px; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; display: inline-flex; align-items: center; justify-content: center; gap: 10px; } .btn-primary { background-color: #3498db; color: white; } .btn-primary:hover:not(:disabled) { background-color: #2980b9; transform: translateY(-3px); box-shadow: 0 7px 14px rgba(52, 152, 219, 0.3); } .btn-primary:disabled { background-color: #bdc3c7; cursor: not-allowed; } /* 预览区域样式 */ .preview-container { display: flex; flex-wrap: wrap; gap: 30px; justify-content: center; } .image-box { flex: 1; min-width: 300px; border: 1px solid #ddd; border-radius: 10px; padding: 20px; background-color: #f9f9f9; text-align: center; } .image-box h3 { color: #2c3e50; margin-bottom: 20px; font-size: 1.4rem; } .image-box img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.08); } .result-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 300px; color: #7f8c8d; font-size: 1.2rem; } .result-placeholder i { font-size: 4rem; margin-bottom: 20px; color: #3498db; } /* 进度条样式 */ .progress-container { background-color: #f8f9fa; padding: 25px; border-radius: 10px; margin-top: 15px; } .progress-bar { height: 25px; background-color: #e9ecef; border-radius: 12px; overflow: hidden; margin-bottom: 15px; } .progress-fill { height: 100%; background: linear-gradient(90deg, #3498db, #2ecc71); width: 0%; border-radius: 12px; transition: width 0.5s ease; } .progress-text { text-align: center; font-weight: 600; color: #2c3e50; font-size: 1.1rem; } .task-info { text-align: center; margin-top: 15px; color: #7f8c8d; } /* 状态徽章 */ .badge { display: inline-block; padding: 5px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: bold; margin-left: 10px; } /* 完成区域按钮 */ .action-buttons { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; } .btn-success { background-color: #2ecc71; color: white; } .btn-success:hover { background-color: #27ae60; transform: translateY(-3px); box-shadow: 0 7px 14px rgba(46, 204, 113, 0.3); } .btn-secondary { background-color: #95a5a6; color: white; } .btn-secondary:hover { background-color: #7f8c8d; } footer { text-align: center; margin-top: 40px; color: #95a5a6; font-size: 0.9rem; padding-top: 20px; border-top: 1px solid #ecf0f1; }3.2 前端逻辑与交互
页面有了样子,现在需要让它“动”起来。我们编写script.js来处理所有用户交互。
// public/script.js document.addEventListener('DOMContentLoaded', function() { // 获取DOM元素 const uploadArea = document.getElementById('uploadArea'); const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const originalPreview = document.getElementById('originalPreview'); const resultPreview = document.getElementById('resultPreview'); const resultPlaceholder = document.getElementById('resultPlaceholder'); const progressFill = document.getElementById('progressFill'); const progressText = document.getElementById('progressText'); const taskIdDisplay = document.getElementById('taskIdDisplay'); const downloadBtn = document.getElementById('downloadBtn'); const resetBtn = document.getElementById('resetBtn'); const previewSection = document.getElementById('previewSection'); const progressSection = document.getElementById('progressSection'); const actionSection = document.getElementById('actionSection'); let currentTaskId = null; let pollInterval = null; const API_BASE_URL = 'http://localhost:3000/api'; // 后端API地址,根据实际部署修改 // 1. 上传区域点击事件 uploadArea.addEventListener('click', () => fileInput.click()); uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.style.backgroundColor = '#e8f4fc'; }); uploadArea.addEventListener('dragleave', () => { uploadArea.style.backgroundColor = '#f8fafc'; }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.style.backgroundColor = '#f8fafc'; if (e.dataTransfer.files.length) { handleFileSelect(e.dataTransfer.files[0]); } }); // 2. 文件选择事件 fileInput.addEventListener('change', (e) => { if (e.target.files.length) { handleFileSelect(e.target.files[0]); } }); function handleFileSelect(file) { // 简单的文件类型和大小校验 if (!file.type.match('image.*')) { alert('请选择图片文件(JPG, PNG等)'); return; } if (file.size > 5 * 1024 * 1024) { // 5MB限制 alert('图片大小不能超过5MB'); return; } // 预览原图 const reader = new FileReader(); reader.onload = function(e) { originalPreview.src = e.target.result; previewSection.style.display = 'block'; uploadBtn.disabled = false; // 启用上传按钮 }; reader.readAsDataURL(file); // 保存文件对象,用于后续上传 window.selectedFile = file; } // 3. 开始上色按钮点击事件 uploadBtn.addEventListener('click', async () => { if (!window.selectedFile) return; uploadBtn.disabled = true; uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 上传中...'; const formData = new FormData(); formData.append('image', window.selectedFile); try { const response = await fetch(`${API_BASE_URL}/upload`, { method: 'POST', body: formData }); const result = await response.json(); if (result.success) { currentTaskId = result.taskId; taskIdDisplay.textContent = currentTaskId; // 显示进度区域 progressSection.style.display = 'block'; progressText.textContent = '已加入队列,等待处理...'; progressFill.style.width = '10%'; // 开始轮询查询任务状态 startPollingTaskStatus(); uploadBtn.innerHTML = '<i class="fas fa-check"></i> 已提交'; } else { alert('上传失败: ' + (result.error || '未知错误')); resetUploadBtn(); } } catch (error) { console.error('上传请求失败:', error); alert('网络错误,上传失败'); resetUploadBtn(); } }); // 4. 轮询查询任务状态 function startPollingTaskStatus() { if (pollInterval) clearInterval(pollInterval); pollInterval = setInterval(async () => { if (!currentTaskId) return; try { const response = await fetch(`${API_BASE_URL}/task/${currentTaskId}`); const taskStatus = await response.json(); updateProgress(taskStatus); // 根据状态决定后续操作 if (taskStatus.state === 'completed') { clearInterval(pollInterval); handleTaskCompleted(taskStatus.result); } else if (taskStatus.state === 'failed') { clearInterval(pollInterval); handleTaskFailed(taskStatus.error); } // 其他状态(waiting, active)继续轮询 } catch (error) { console.error('查询任务状态失败:', error); // 网络错误时继续尝试,不停止轮询 } }, 2000); // 每2秒查询一次 } function updateProgress(status) { let progress = status.progress || 0; let stateText = ''; switch(status.state) { case 'waiting': stateText = '排队中...'; progress = 20; break; case 'active': stateText = 'AI正在上色...'; progress = Math.max(progress, 50); // 确保有进度显示 break; case 'completed': stateText = '处理完成!'; progress = 100; break; case 'failed': stateText = '处理失败'; break; default: stateText = '处理中...'; } progressFill.style.width = `${progress}%`; progressText.textContent = `${stateText} (${progress}%)`; } function handleTaskCompleted(result) { // 显示处理后的图片 resultPreview.src = result.resultImageUrl; resultPreview.style.display = 'block'; resultPlaceholder.style.display = 'none'; // 更新状态徽章 document.getElementById('statusBadge').textContent = '已完成'; document.getElementById('statusBadge').style.backgroundColor = '#2ecc71'; // 设置下载链接 downloadBtn.href = result.resultImageUrl; downloadBtn.download = `colorized_${window.selectedFile.name}`; // 显示完成操作区域 actionSection.style.display = 'block'; progressText.textContent = '处理完成! (100%)'; } function handleTaskFailed(errorMsg) { progressText.textContent = `处理失败: ${errorMsg}`; progressFill.style.backgroundColor = '#e74c3c'; document.getElementById('statusBadge').textContent = '失败'; document.getElementById('statusBadge').style.backgroundColor = '#e74c3c'; // 显示重试或重置按钮 uploadBtn.disabled = false; uploadBtn.innerHTML = '<i class="fas fa-redo"></i> 重新上传并处理'; uploadBtn.onclick = () => location.reload(); // 简单重载页面 } // 5. 重置按钮 resetBtn.addEventListener('click', () => { location.reload(); }); function resetUploadBtn() { uploadBtn.disabled = false; uploadBtn.innerHTML = '<i class="fas fa-upload"></i> 开始上色处理'; } });前端逻辑主要分为几个部分:处理文件拖拽/选择、预览图片、将图片上传到后端、然后不断询问后端“我的照片处理好了吗?”(轮询),最后根据后端返回的状态更新进度条,并在处理完成后展示彩色图片和下载按钮。
4. 运行、测试与部署要点
代码都写好了,让我们把它跑起来,看看效果如何。
4.1 本地运行与测试
- 启动Redis:打开一个终端窗口,运行
redis-server。确保它成功启动并监听在默认的6379端口。 - 启动后端服务:打开另一个终端窗口,进入项目目录,运行:
你应该看到“后端服务器已启动,运行在 http://localhost:3000”的提示。node server.js - 访问前端页面:打开浏览器,访问
http://localhost:3000。等等,你会发现访问不了,因为我们还没有告诉Express去提供index.html这个页面。
我们需要修改一下server.js,在文件末尾启动服务器之前,添加一个路由来处理根路径,返回我们的前端页面。
// 在 server.js 的 app.listen 之前添加 // 提供前端静态页面 app.use(express.static(path.join(__dirname, 'public'))); // 所有未匹配的API路由返回前端应用(用于支持前端路由) app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });现在,重启后端服务(按Ctrl+C停止,再运行node server.js),然后刷新浏览器访问http://localhost:3000。你应该能看到我们设计好的界面了。
- 功能测试:
- 点击上传区域,选择一张黑白或老旧的照片。
- 点击“开始上色处理”按钮。
- 观察页面变化:原图出现,进度条开始走动,状态更新。
- 注意:由于我们代码中的
DEOLDIFY_API_URL是一个占位符,实际调用会失败。你需要将其替换为一个真实可用的DeOldify API端点。你可以选择:- 自行部署DeOldify模型:这需要一定的机器学习和GPU资源,可以参考DeOldify官方GitHub仓库。
- 使用第三方API服务:一些云平台或AI服务商可能提供类似的图像上色API。
- 本地模拟:为了测试前后端流程,你可以在
colorizeImage函数里注释掉真正的API调用,用setTimeout模拟一个延迟,并返回一张预设的彩色图片路径。这可以帮你验证整个上传-队列-进度-下载的流程是否通畅。
4.2 部署到生产环境
如果你想把这个应用分享给朋友或放到网上,就需要部署。这里有一些关键点:
- 环境变量管理:确保
DEOLDIFY_API_URL和API_KEY等敏感信息通过环境变量(如.env文件)配置,并且.env文件不被提交到代码仓库(记得加入.gitignore)。 - 进程管理:在生产环境,不要直接用
node server.js运行。使用进程管理工具如PM2,它可以保持应用持续运行,并在崩溃时自动重启。npm install -g pm2 pm2 start server.js --name "deoldify-app" - Redis配置:生产环境的Redis可能需要设置密码,并确保其安全性和持久化。在
bull队列配置中需要更新连接信息。 - 文件存储:本地文件系统(
uploads/,results/)在服务器重启后文件会丢失,且不适合分布式部署。应考虑使用对象存储服务(如AWS S3、阿里云OSS、腾讯云COS)或至少挂载一个持久化卷。 - 安全性:
- 文件类型校验:后端应对上传的文件进行更严格的校验(检查文件头魔数,而不仅仅是扩展名)。
- 文件大小限制:在
multer和前端都要限制文件大小,防止恶意上传大文件。 - 速率限制:使用
express-rate-limit等中间件对API进行限流,防止滥用。 - CORS配置:在生产环境,应将
cors中间件的来源(origin)设置为你的前端域名,而不是*。
- 前端API地址:将
script.js中的API_BASE_URL从localhost:3000改为你实际的后端生产环境域名。 - 使用反向代理:通常会用Nginx这样的Web服务器作为反向代理,处理静态文件、SSL加密(HTTPS)并将动态请求转发给Node.js应用。
5. 总结与展望
跟着上面的步骤走一遍,一个具备完整功能的老照片上色Web应用就搭建起来了。这个过程涵盖了现代Web开发的几个核心环节:用Express构建RESTful API、用Multer处理文件上传、用Bull管理异步任务队列、用Fetch API实现前后端交互,以及最重要的——将第三方AI服务集成到自己的应用中。
这个项目的价值不仅仅在于修复老照片。它提供了一个可复用的全栈模式。你可以把“DeOldify API”替换成任何其他AI服务,比如人脸识别、风格迁移、文本摘要等等,快速构建出新的AI应用原型。队列系统的引入,使得应用能够优雅地处理耗时任务,提升用户体验和系统稳定性。
在实际使用中,你可能会遇到一些可以优化的点。比如,目前的进度反馈是通过前端轮询实现的,虽然简单,但不够实时且增加服务器压力。可以考虑升级为WebSocket双向通信,让后端在处理进度更新时主动推送给前端。再比如,图片处理是CPU/GPU密集型任务,当用户量增大时,单一的Node.js后端可能成为瓶颈。这时可以考虑将耗时的DeOldify调用剥离出来,用Python(或其他更适合AI推理的语言)编写独立的微服务,Node.js后端只负责业务逻辑和调度。
希望这个项目能成为你探索AI应用开发的一块敲门砖。技术的乐趣在于动手实践和不断迭代,不妨从这个基础版本开始,添加更多你喜欢的特性,比如用户账户、历史记录、多种滤镜风格选择等等。最重要的是,享受创造的过程,看着代码一步步变成能解决实际问题的工具,这种感觉非常棒。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。