
简介这是一份基于 PyTorch 的图像风格迁移完整项目包含全部代码与实验数据部署简单可直接运行。项目主要面向期末大作业、课程设计等实战场景配有图形界面代码注释详细即使是刚接触深度学习的新手也能快速理解并上手操作。压缩包共包含 9 个文件整体大小仅 1.46MB其中有 5 个 Python 脚本、1 个依赖清单文件、1 个项目说明文档以及用于展示效果的 gif 动图和 jpg 静态图。脚本模块划分清晰模型定义、界面交互、主控流程与工具函数各司其职便于阅读和二次开发。目前已有 147 人学习下载通过该项目读者可以深入理解风格迁移的基本原理掌握 PyTorch 模型的加载推理与界面集成方法同时获得一套结构完整、可直接扩展的代码框架作为课程设计或期末大作业能够很好地体现动手与实践能力。1. 用 PyTorch 做图像风格迁移先越过“数据集”这道坎很多人把图像风格迁移当成一个要先下载 COCO、MS-Celeb 这类大规模数据集才能开工的深度学习示例。实际上用 PyTorch 复现一个“可直接运行”的风格迁移程序核心无非三件事用预训练 VGG19 提取内容特征和风格特征用 Gram 矩阵计算风格差异再让一张初始图片同时去拟合这两类约束。真正把新人卡住的通常不是数学而是“数据集太大不想下”“图片路径写错”“跑完保存出来的图颜色发灰”这类工程细节。这篇内容围绕“完整代码数据”两个关键词展开。数据部分我会先给一段自动生成内容图和风格图的脚本保证在没有外部数据集的前提下训练循环也能在一台普通机器上跑通代码部分会给到可以保存运行、带命令行参数的完整风格迁移主程序。不管你是做毕设、公司内部 POC还是想把图片风格化功能集成到现有工具链里这套流程都能快速搭出一个可交付的版本。先说结论直接复现之前确认两件事——PyTorch 版本在 1.10 以上以及执行目录有写权限。这两个条件满足后剩下的事情本质上就是调内容权重和风格权重两个数字让“构图不丢、纹理够像”。2. PyTorch 图像风格迁移的核心VGG19 特征与 Gram 风格约束2.1 为什么风格迁移示例普遍选 VGG19而不是 ResNet 或 Swin图像风格迁移的场景里内容图提供“画了什么”风格图提供“怎么画的”。我们需要的特征提取器有两个特点特征层级清晰且越深语义越强、越浅纹理越强。VGG19 恰好是最容易被解释的那一个卷积核全部是 3×3没有残差连接和注意力模块torchvision.models.vgg19一行就能把预训练权重加载进来它的features子模块可以按索引逐层切块。ResNet 有残差结构特征图中会混入较深层的全局信息拿浅层做风格纹理时边界不如 VGG “干净”Swin Transformer 这类新模型表现力更强但特征图的语义层级与人类对“局部纹理”的直觉不一致调损失权重时很难预估效果。所以经典 Neural Style 到现在的很多教学实现都以 VGG19 的relu1_2、relu2_2、relu3_2、relu4_2、relu5_2这五个特征层作为风格约束的锚点。2.2 内容损失用特征图做 MSE风格损失用 Gram 矩阵内容约束的做法是把内容图输入网络后取出某一层特征图再让待优化的输出图片在该层生成的特征图向它逼近常见取relu3_2。深层特征包含物体的类别和空间布局少了高频细节这样优化时不会要求像素级一致。风格约束不能直接用特征图做逐像素回归。假设某层有 C 个卷积核对应 C 个通道每个通道就是一张响应图。风格信息实际体现在“哪些通道倾向于同时激活”这种通道之间的相关性就是 Gram 矩阵。在 PyTorch 中它的实现很短def gram(feature): batch, channels, height, width feature.size() f feature.view(batch, channels, height * width) g torch.bmm(f, f.transpose(1, 2)) return g / (channels * height * width)f是形状为[batch, channels, height*width]的二维化特征torch.bmm让每个 batch 样本内的 C 个通道两两做内积。除以channels * height * width是为了把数值量级归一化否则特征图分辨率变大时 Gram 矩阵的值会非线性放大风格权重很难在 256×256 与 512×512 之间通用。2.3 为什么示例用“优化像素”而不是生成网络现在工业界做实时风格迁移多用 AdaIN、SANet 这类生成式网络一个编码器把内容和风格图像映射到特征空间再由解码器一次性生成结果。这种方案速度快适合视频流和移动端但训练一个生成网络需要成对或者至少成批次的数据集工程复杂度高于一个可直接运行的示例能承载的范围。本文采用的方法属于“优化像素”路线待优化的 target 网络输入不是模型权重而是一张随机初始化的图片每次迭代根据内容损失和风格损失的梯度直接修改这张图的像素值。它慢但不需要训练数据配对也不需要为每种风格单独训练一次网络一个人动手做图像风格迁移、理解特征约束用这条路线能最快看到效果。3. 图像风格迁移的数据生成与 PyTorch 环境准备3.1 运行前的依赖检查与 PyTorch 安装如果要让标题里的“完整代码数据”成立数据必须确定性可复现。为此我采用了一个“自动生成”策略代码检测到data目录下没有content.jpg和style.jpg时会通过 PyTorch 张量运算直接画两张演示图不需要你下载任何外部数据集。依赖上一段最小命令就够了pip install torch torchvision pillow如果机器上已经有 Anaconda创建独立环境更稳妥conda create -n style_transfer python3.10 conda activate style_transfer conda install pytorch torchvision cpuonly -c pytorch这两条命令分别覆盖“已有基础 Python 环境”和“从零创建环境”两种情况。第一次运行主程序时torchvision会去官方地址加载 VGG19 预训练权重之后缓存在本地不需要额外配置如果你的宿主机遇到下载连接超时优先检查网络环境而不是换模型骨架。3.2 用一段脚本自动生成内容图和风格图没有外部数据的“可直接运行”本质是让代码自己产出数据。下面这个函数用几何图形生成内容图用法线、正弦波和随机噪声合成风格图import os import torch from torchvision.utils import save_image def make_demo_data(): torch.manual_seed(2024) t torch.linspace(-1, 1, 512) x, y torch.meshgrid(t, t, indexingxy) r torch.sqrt(x * x y * y) content torch.zeros(3, 512, 512) content[0, (r 0.15) (r 0.3)] 1 content[1, r 0.15] 1 content[2, (r 0.3) (r 0.5)] 0.7 style torch.rand(3, 512, 512) style[0] style[0] torch.sin(3 * x 2 * y) * 0.5 style[1] style[1] torch.cos(4 * x - 1.5 * y) * 0.5 style[2] style[2] ((x y) 0).float() * 0.5 style style.clamp(0.0, 1.0) os.makedirs(data, exist_okTrue) save_image(content, data/content.jpg) save_image(style, data/style.jpg)linspace生成坐标网格r是每个像素到画布中心的距离用r的区间组合画出圆形和圆环内容结构非常明显。风格图里三个通道分别叠加正弦波、余弦波和斜向分割最终clamp保证像素值落在 0 到 1 之间。为什么特意用张量绘制而不是直接找一张网图因为张量生成的样本可复现数据尺寸确定排查误差时不会引入照片本身的高频噪声。3.3 分辨率、迭代轮数和资源消耗的权衡训练尺寸对显存和耗时影响非常大这也是做图像风格迁移最容易被低估的坑。VGG19 本身参数不大但优化过程中模型要同时保存多个特征层的中间结果计算图会非线性变大。目标分辨率内容权重风格权重200 步 CPU 参考耗时200 步 GPU 参考耗时256×2561.01e5约 4 分钟约 10 秒512×5121.01e5约 15 分钟约 40 秒768×7681.01e5不推荐 CPU 跑约 150 秒如果你是第一次运行建议先用 256×256 把流程走通再逐步放大。分辨率增大后风格权重往往也需要跟着微调因为 Gram 矩阵的值做了面积归一化并不等于所有数值都按比例缩小。4. 可直接运行的 PyTorch 图像风格迁移完整代码4.1 图像预处理PIL 读图、归一化、转模型输入VGG19 在 ImageNet 上训练时使用固定的均值和标准差加载图片后必须做同样的预处理否则内容特征和风格特征的数值分布偏移Gram 矩阵计算会失真。读取函数如下from PIL import Image from torchvision import transforms def load_img(path, size512): img Image.open(path).convert(RGB) img img.resize((size, size), Image.LANCZOS) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) return transform(img).unsqueeze(0)unsqueeze(0)把三维张量扩展成[1, 3, size, size]对应 PyTorch 的 NCHW 布局。LANCZOS插值比双线性能保留更多边缘细节风格迁移输出的边缘质量会明显更干净这一点在内容图中包含文字、建筑线条时尤其关键。4.2 核心训练代码VGG19 特征截取 LBFGS 优化下面是一份可以直接保存成style_transfer.py的完整程序。它会自动检查数据文件不存在就调用上面的make_demo_data()生成然后进行风格迁移import argparse import os import torch import torch.nn as nn import torch.optim as optim from torchvision import models from torchvision.utils import save_image class VGG19Features(nn.Module): def __init__(self): super().__init__() base models.vgg19(weightsmodels.VGG19_Weights.IMAGENET1K_V1) base.eval() for p in base.parameters(): p.requires_grad_(False) conv_layers base.features self.c_idx 2 self.s_idx [0, 1, 2, 3, 4] self.slices nn.ModuleList() start 0 for stop in [3, 8, 17, 26, 35]: self.slices.append(conv_layers[start:stop 1]) start stop 1 def forward(self, x): features [] for layer in self.slices: x layer(x) features.append(x) return features def gram(feature): batch, channels, height, width feature.size() f feature.view(batch, channels, height * width) g torch.bmm(f, f.transpose(1, 2)) return g / (channels * height * width) def load_img(path, size512): from PIL import Image from torchvision import transforms img Image.open(path).convert(RGB) img img.resize((size, size), Image.LANCZOS) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) return transform(img).unsqueeze(0) def deprocess(tensor): mean torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1) std torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1) return tensor.squeeze(0).detach().cpu().mul(std).add(mean).clamp(0, 1) def main(): parser argparse.ArgumentParser() parser.add_argument(--content, defaultdata/content.jpg) parser.add_argument(--style, defaultdata/style.jpg) parser.add_argument(--steps, typeint, default120) parser.add_argument(--size, typeint, default512) parser.add_argument(--wc, typefloat, default1.0) parser.add_argument(--ws, typefloat, default1e5) args parser.parse_args() if not os.path.exists(args.content) or not os.path.exists(args.style): make_demo_data() content load_img(args.content, args.size) style load_img(args.style, args.size) net VGG19Features() style_grams [gram(f) for f in net(style)] content_target net(content)[net.c_idx] target content.clone().requires_grad_(True) optimizer optim.LBFGS([target], lr0.5, max_iter20, history_size80) for step in range(args.steps): def closure(): optimizer.zero_grad() feats net(target) c_loss nn.functional.mse_loss(feats[net.c_idx], content_target) s_loss torch.tensor(0.0) for i, weight in enumerate([1.0, 0.9, 0.7, 0.4, 0.2]): s_loss s_loss weight * nn.functional.mse_loss(gram(feats[i]), style_grams[i]) total_loss args.wc * c_loss args.ws * s_loss total_loss.backward() return total_loss loss optimizer.step(closure) if step % 20 0: print(fstep {step:4d} loss {loss:.3f}) save_image(deprocess(target), foutput_{step:04d}.jpg) save_image(deprocess(target), output_final.jpg) print(训练完成结果保存在 output_final.jpg) if __name__ __main__: main()这个脚本里VGG19Features把 VGG19 的features切成 5 段每段输出一个特征层。s_idx和c_idx不是直接给网络用的只是便于阅读时理解“哪些特征层参与了哪些损失”。内容损失固定取第三段特征也就是relu3_2风格特征从relu1_2到relu5_2逐层加权浅层传 1.0深层传 0.2。优化器使用 LBFGS 而不是 Adam因为风格迁移的目标是优化图像像素LBFGS 能用二阶信息做更平滑的步进通常几十步就能看到稳定结构。history_size80控制历史梯度保留量显存紧张时降到 40 或 20。输出张量必须经过deprocess反归一化再保存否则保存出来的图片整体发灰。4.3 最小运行命令与输出进到脚本目录后执行python style_transfer.py --steps 120 --size 512第一次运行会生成data/content.jpg和data/style.jpg然后自动开始训练。程序每 20 步输出一张中间结果方便在训练过程中就观察到图层结构的变化。如果你手上有自己的照片把命令改成python style_transfer.py --content photos/cat.jpg --style styles/van_gogh.jpg --ws 5e4就完成了从“示例数据”到“真实数据”的切换整个标题里“完整代码数据”的“数据”部分既覆盖了无网环境也覆盖了自定义图片。5. PyTorch 图像风格迁移跑完后不理想5 个排查位置5.1 损失值不降反升时先检查风格权重的量纲运行日志里最常出现的问题是 loss 不降或者前几步跳到极大值后卡住。先搞清楚你的 Gram 矩阵有没有做面积归一化。如果代码里直接torch.bmm(f, f.transpose(1, 2))而没有除以channels * height * width那么 512×512 输入的 Gram 矩阵数值会比 256×256 大得多同一个ws1e5在两种分辨率下实际产生的作用完全不同。我的参数建议是输入 512×512、内容权重wc1.0时ws从 1e4 开始向上试。每调一次放大 10 倍观察输出图里纹理出现程度。如果出现大片重复花纹说明风格权重过大把内容边缘盖住了如果输出和内容图几乎一样说明风格权重不够。5.2 输出整张图发灰或对比度丢失是反归一化掉了这是新手最容易遇到的“代码能跑但效果丑”的原因。transforms.Normalize会把像素分布从 0 到 1 挪到约 -1 到 1 的区间保存图片前不还原输出看起来就像一层灰雾。很多教程只在代码里写了save_image(target, ...)倒也能出图但颜色永远不对。正确的保存姿势是乘以方差再加均值并做一次clamp(0, 1)。也就是上文代码里的deprocess函数。这个函数拿到的是[1, 3, H, W]张量squeeze(0)去掉 batch 维之后mul(std)和add(mean)逐通道完成反归一化。注意要先乘后加顺序不能反。5.3 放大分辨率后报显存溢出优先调整 LBFGS 参数运行时如果报 CUDA out of memory常见做法是把--size降为 384 或 256。但很多情况下不一定要降分辨率可以缩小 LBFGS 的history_size从 80 改到 30这一步能省下大量保存历史梯度的显存。二是把max_iter从 20 降到 8LBFGS 内部每次迭代的线性搜索步骤变少占用也随之下降。报错现象主要排查位置常用处置CUDA out of memoryLBFGS 历史梯度缓存调低history_size或降分辨率IndexError: index out of range手工修改特征层列表后索引越界检查slices的stop是否超出features最大下标图像输出全是噪点学习率过大或未预训练权重将lr从 0.5 降到 0.1确认 VGG19 使用预训练权重风格迁移前后差别极小ws权重过低增大ws观察纹理出现程度如果你把 VGG19 换成 VGG16还需要同步修改[3, 8, 17, 26, 35]这个索引列表。VGG 的features结构虽然都是卷积和池化交替但层数不同VGG16 的relu5_2对应下标为[3, 8, 15, 22, 29]。直接复制 VGG19 的索引会出现越界报错。6. 把图像风格迁移封装成批处理工具一次产出多张风格图6.1 单张图处理函数与批量运行示例跑通后下一步通常是批量生产。把训练主逻辑抽成一个函数循环遍历风格目录即可。下面这段代码把“加载图片、运行迁移、保存结果”封装成一行可调用的接口def stylize_one(content_path, style_path, out_path, ws1e5, steps80): content load_img(content_path, 512) style load_img(style_path, 512) target run_style_transfer(content, style, stepssteps, wsws) save_image(deprocess(target), out_path)配合os.listdir遍历风格目录就可以做“一张内容图配全部风格图”的批量实验。对多张内容图和多张风格图的组合作业可以写两层 for 循环或使用进程池每个任务内部只吃 CPU 或单卡内存进程间独立互相不干扰。6.2 风格权重暴露成参数做风格混合对比实验批量工具最大的价值不是省去重复执行命令而是方便系统性对比参数效果。把--ws和--wc做成可配置项后一条 shell 循环就能产出不同风格强度的对比图for ws in 1e4 5e4 1e5 5e5; do python style_transfer.py --style style.jpg --ws $ws --steps 80 --size 384 cp output_final.jpg result_ws_$ws.jpg done这样跑一轮就能看出权重递增时纹理覆盖度的变化趋势。另一个实用的进阶玩法是风格混合把两张不同风格图的 Gram 矩阵按比例相加再作为目标。因为风格损失作用于 Gram 矩阵矩阵是线性可加的所以混合两张风格图的本质是对 Gram 目标做加权平均而不是对原始像素做平均。把风格混合加进主程序时注意一点batch 推理时 PIL 会保留原始图片的 EXIF 信息如果你读取的照片带方向标记或 CMYK 色彩空间务必统一用convert(RGB)再进模型否则混合结果里可能出现颜色怪异的色偏。这个细节在批处理几十张图时尤其明显粘贴代码跑通之后这就是决定交付结果专不专业的分水岭。本文还有配套的精品资源点击获取