从零复现 style_transformer:PyTorch 实现风格转换模型的完整项目实例
1. 从零复现 style_transformer 到底在做什么style_transformer 是一个前馈式风格迁移网络输入一张普通照片输出一张带有指定画风比如梵高星空、莫奈睡莲的新图。它和早期那种“每张图都要迭代几百步”的优化式风格迁移不一样训练阶段把风格“烧”进网络权重里推理阶段只需要一次前向传播速度能到实时级别。适合谁适合已经会写基础 PyTorch 训练循环、想搞懂感知损失perceptual loss和残差生成网络怎么落地的人。我这次复现的目标很明确给定一个 content 数据集比如 COCO 的 2 万张图和一张 style 图训练出一个能对任意输入图做风格化的模型并且能可视化训练收敛过程。整个项目拆成四块数据加载、模型定义、训练循环、推理可视化。下面按目录结构、关键代码、超参配置、验证步骤的顺序走一遍你照着敲就能跑通。先说清楚核心原理不然后面看代码会懵。风格迁移的损失由两部分组成内容损失和风格损失。内容损失用 VGG 的某一层通常是 relu3_3 或 relu4_3的特征图做 MSE保证输出图的结构和原图一致风格损失用 VGG 多层特征图的 Gram 矩阵做 MSE保证输出图的纹理、色彩分布和目标风格一致。生成网络本身是一个编码器-残差块-解码器结构编码器用步长卷积下采样中间堆 5 个残差块解码器用最近邻插值上采样加卷积。这个结构来自 Johnson 等人的 Perceptual Losses 论文也是 style_transformer 项目的标准实现。环境依赖很简单Python 3.8、PyTorch 1.12、torchvision、Pillow、tqdm。如果你用 GPUCUDA 11.6 以上都行。我实测在 3060 12G 上batch size 8、256x256 分辨率一个 epoch 大概 3 分钟。CPU 也能跑就是慢十倍左右建议至少用一张入门级显卡。目录结构我建议这样组织后面所有命令都基于这个结构style_transformer/ ├── data/ │ ├── content/ # 训练内容图jpg/png 都行 │ └── style/ # 风格图一张就够 ├── checkpoints/ # 保存模型权重 ├── output/ # 推理结果 ├── models/ │ ├── transformer.py # 生成网络定义 │ └── vgg.py # 损失网络 ├── utils/ │ ├── dataset.py # 数据加载 │ └── visualize.py # 可视化工具 ├── train.py ├── infer.py └── config.yaml数据加载这块有个坑内容图不需要标签但要做随机裁剪和归一化。归一化用 ImageNet 的均值和方差因为 VGG 是在 ImageNet 上预训练的输入分布要匹配。代码里我用torchvision.transforms组合注意RandomCrop要在Resize之后否则小图会报错。# utils/dataset.py import os from PIL import Image from torch.utils.data import Dataset import torchvision.transforms as T class ContentDataset(Dataset): def __init__(self, root, size256): self.files [os.path.join(root, f) for f in os.listdir(root) if f.lower().endswith((.jpg, .png, .jpeg))] self.transform T.Compose([ T.Resize(size), T.RandomCrop(size), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.files) def __getitem__(self, idx): img Image.open(self.files[idx]).convert(RGB) return self.transform(img)风格图只需要一张加载后同样归一化但不用随机裁剪直接 resize 到 256 就行。训练时风格图是固定的所以可以提前算好它的 Gram 矩阵缓存起来省显存也省时间。这个优化我在后面训练循环里会提。2. TaoToken 前置把 API Key 和 Base URL 配好训练风格迁移模型本身不需要联网但如果你想在训练过程中调用大模型帮你分析损失曲线、生成风格描述或者用 Claude Code 这类工具辅助写代码就需要一个稳定的 API 入口。我平时用 TaoToken 来做这件事它的 Base URL 和 Key 配置方式和 OpenAI 兼容改两行就能接上。先拿 Key。打开 https://taotoken.net/api-keys 登录后创建一个新 Key复制出来。注意 Key 只显示一次丢了就重新建。然后确认你的 Base URL 是https://taotoken.net/api这个地址不加任何 UTM 参数直接写进配置就行。如果你用 Claude Code 做代码补全配置方式是在项目根目录建.claude/settings.json写入{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的Key } }如果你用 Cline 或者别的支持 MCP 的编辑器插件配置项是三个Base URL 填https://taotoken.net/apiAPI Key 填刚才复制的Model ID 填claude-sonnet-4-20250514或者你套餐里支持的模型。这三个缺一不可少一个就会报 401。Codex 用户注意auth.json的写法是{ base_url: https://taotoken.net/api, api_key: sk-你的Key, model: claude-sonnet-4-20250514 }放对路径一般在~/.codex/auth.json。改完重启编辑器在对话框里发一句“你好”测试能回就说明通了。为什么要配这个因为复现 style_transformer 的过程中你大概率会遇到损失不下降、生成图全灰、Gram 矩阵维度对不上这些问题。有个能随时问的模型入口排障效率会高很多。而且训练脚本里的超参搜索、日志分析也可以让模型帮你读。TaoToken 的模型对话入口在 https://taotoken.net/models 可以直接在网页里试。配置完成后建议在终端里用 curl 验证一下curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-20250514,messages:[{role:user,content:hi}]}返回 JSON 里有choices字段就说明通了。如果报local proxy failed检查你的网络环境是否直连不要挂任何代理工具。如果报401检查 Key 有没有复制完整或者是不是过期了。3. 可复制配置模型定义与训练超参这一节是核心我把模型定义、损失网络、训练循环的完整代码都放出来你直接复制到对应文件里就能用。先看生成网络models/transformer.pyimport torch import torch.nn as nn class ConvLayer(nn.Module): def __init__(self, in_ch, out_ch, kernel_size, stride): super().__init__() pad kernel_size // 2 self.reflection_pad nn.ReflectionPad2d(pad) self.conv2d nn.Conv2d(in_ch, out_ch, kernel_size, stride) def forward(self, x): return self.conv2d(self.reflection_pad(x)) class ResidualBlock(nn.Module): def __init__(self, channels): super().__init__() self.conv1 ConvLayer(channels, channels, 3, 1) self.in1 nn.InstanceNorm2d(channels, affineTrue) self.conv2 ConvLayer(channels, channels, 3, 1) self.in2 nn.InstanceNorm2d(channels, affineTrue) self.relu nn.ReLU() def forward(self, x): residual x out self.relu(self.in1(self.conv1(x))) out self.in2(self.conv2(out)) return out residual class UpsampleConvLayer(nn.Module): def __init__(self, in_ch, out_ch, kernel_size, stride, upsampleNone): super().__init__() self.upsample upsample pad kernel_size // 2 self.reflection_pad nn.ReflectionPad2d(pad) self.conv2d nn.Conv2d(in_ch, out_ch, kernel_size, stride) def forward(self, x): if self.upsample: x nn.functional.interpolate(x, modenearest, scale_factorself.upsample) return self.conv2d(self.reflection_pad(x)) class TransformerNet(nn.Module): def __init__(self): super().__init__() self.conv1 ConvLayer(3, 32, 9, 1) self.in1 nn.InstanceNorm2d(32, affineTrue) self.conv2 ConvLayer(32, 64, 3, 2) self.in2 nn.InstanceNorm2d(64, affineTrue) self.conv3 ConvLayer(64, 128, 3, 2) self.in3 nn.InstanceNorm2d(128, affineTrue) self.res1 ResidualBlock(128) self.res2 ResidualBlock(128) self.res3 ResidualBlock(128) self.res4 ResidualBlock(128) self.res5 ResidualBlock(128) self.deconv1 UpsampleConvLayer(128, 64, 3, 1, upsample2) self.in4 nn.InstanceNorm2d(64, affineTrue) self.deconv2 UpsampleConvLayer(64, 32, 3, 1, upsample2) self.in5 nn.InstanceNorm2d(32, affineTrue) self.deconv3 ConvLayer(32, 3, 9, 1) self.relu nn.ReLU() def forward(self, x): y self.relu(self.in1(self.conv1(x))) y self.relu(self.in2(self.conv2(y))) y self.relu(self.in3(self.conv3(y))) y self.res1(y) y self.res2(y) y self.res3(y) y self.res4(y) y self.res5(y) y self.relu(self.in4(self.deconv1(y))) y self.relu(self.in5(self.deconv2(y))) return self.deconv3(y)损失网络models/vgg.py用预训练 VGG16只取前几层参数冻结import torch import torch.nn as nn from torchvision import models class VGG16(nn.Module): def __init__(self): super().__init__() vgg models.vgg16(pretrainedTrue).features self.slice1 vgg[:4] # relu1_2 self.slice2 vgg[4:9] # relu2_2 self.slice3 vgg[9:16] # relu3_3 self.slice4 vgg[16:23] # relu4_3 for p in self.parameters(): p.requires_grad False def forward(self, x): h1 self.slice1(x) h2 self.slice2(h1) h3 self.slice3(h2) h4 self.slice4(h3) return h1, h2, h3, h4Gram 矩阵计算和风格损失def gram_matrix(x): b, c, h, w x.size() features x.view(b, c, h * w) gram torch.bmm(features, features.transpose(1, 2)) return gram / (c * h * w) def style_loss(feat, target_gram): gram gram_matrix(feat) return nn.functional.mse_loss(gram, target_gram) def content_loss(feat, target_feat): return nn.functional.mse_loss(feat, target_feat)训练循环train.py的关键部分超参我放在config.yaml里# config.yaml content_dir: ./data/content style_path: ./data/style/starry_night.jpg checkpoint_dir: ./checkpoints batch_size: 8 lr: 1e-3 epochs: 2 content_weight: 1.0 style_weight: 1e5 tv_weight: 1e-6 log_interval: 100训练主循环import yaml, torch, os from torch.utils.data import DataLoader from torch.optim import Adam from models.transformer import TransformerNet from models.vgg import VGG16, gram_matrix, style_loss, content_loss from utils.dataset import ContentDataset from PIL import Image import torchvision.transforms as T cfg yaml.safe_load(open(config.yaml)) device torch.device(cuda if torch.cuda.is_available() else cpu) # 数据 dataset ContentDataset(cfg[content_dir]) loader DataLoader(dataset, batch_sizecfg[batch_size], shuffleTrue) # 模型 transformer TransformerNet().to(device) vgg VGG16().to(device) # 风格图预处理 style_img Image.open(cfg[style_path]).convert(RGB) style_tf T.Compose([T.Resize(256), T.ToTensor(), T.Normalize(mean[0.485,0.456,0.406], std[0.229,0.224,0.225])]) style_tensor style_tf(style_img).unsqueeze(0).to(device) # 预计算风格 Gram with torch.no_grad(): style_feats vgg(style_tensor) style_grams [gram_matrix(f) for f in style_feats] optimizer Adam(transformer.parameters(), lrcfg[lr]) for epoch in range(cfg[epochs]): for i, content in enumerate(loader): content content.to(device) optimizer.zero_grad() output transformer(content) out_feats vgg(output) with torch.no_grad(): content_feats vgg(content) c_loss content_loss(out_feats[2], content_feats[2]) s_loss sum(style_loss(out_feats[j], style_grams[j]) for j in range(4)) loss cfg[content_weight] * c_loss cfg[style_weight] * s_loss loss.backward() optimizer.step() if i % cfg[log_interval] 0: print(fepoch {epoch} step {i} loss {loss.item():.4f} fcontent {c_loss.item():.4f} style {s_loss.item():.4f}) torch.save(transformer.state_dict(), os.path.join(cfg[checkpoint_dir], fepoch_{epoch}.pth))注意style_weight给到 1e5 是因为 Gram 矩阵的数值量级比内容特征小很多不加权的话风格根本学不动。tv_weight是总变分损失用来平滑输出减少噪点我在上面代码里没加你可以自己补上加在 loss 里就行。4. 验证请求与成功结果推理可视化和收敛判断训练完两个 epoch 后checkpoints 目录下会有epoch_0.pth和epoch_1.pth。推理脚本infer.py这样写import torch, os from PIL import Image import torchvision.transforms as T from models.transformer import TransformerNet device torch.device(cuda if torch.cuda.is_available() else cpu) model TransformerNet().to(device) model.load_state_dict(torch.load(./checkpoints/epoch_1.pth, map_locationdevice)) model.eval() tf T.Compose([T.Resize(512), T.ToTensor(), T.Normalize(mean[0.485,0.456,0.406], std[0.229,0.224,0.225])]) img Image.open(./data/content/test.jpg).convert(RGB) input_tensor tf(img).unsqueeze(0).to(device) with torch.no_grad(): output model(input_tensor) # 反归一化 output output.squeeze(0).cpu() output output * torch.tensor([0.229,0.224,0.225]).view(3,1,1) \ torch.tensor([0.485,0.456,0.406]).view(3,1,1) output output.clamp(0, 1) T.ToPILImage()(output).save(./output/stylized.jpg) print(saved to ./output/stylized.jpg)跑完之后打开output/stylized.jpg如果看到原图的结构还在但颜色和笔触变成了星空风格就说明成功了。我实测下来两个 epoch 已经能出效果但边缘会有噪点五个 epoch 以上会干净很多。怎么判断收敛看训练日志里的 loss 曲线。内容损失一般稳定在 0.5 到 2 之间风格损失从几万降到几千就说明在学。如果风格损失一直不降检查style_weight是不是太小或者风格图有没有正确归一化。如果内容损失爆炸把学习率降到 1e-4 试试。可视化方面我建议每 500 步存一张输出图拼成网格看变化。可以用torchvision.utils.make_grid把输入、输出、风格图拼在一起from torchvision.utils import make_grid, save_image grid make_grid([input_tensor.squeeze(0), output, style_tensor.squeeze(0)], nrow3) save_image(grid, ./output/compare.jpg)这样一眼就能看出风格迁移的程度。如果输出图偏灰、对比度低多半是 InstanceNorm 的 affine 参数没学好多训几个 epoch 会改善。5. 本篇常见错排查401、local proxy failed、reading choices复现过程中最容易卡住的不是模型本身而是环境和 API 配置。我把踩过的坑列出来你对照报错直接定位。第一个高频错误是401 Unauthorized。这个基本只出现在你调用 TaoToken API 的时候。原因有三个Key 复制不完整少了前缀或者多了空格、Key 过期、Base URL 写错。检查方法把 Key 重新复制一遍确认 Base URL 是https://taotoken.net/api注意结尾没有斜杠。如果还报 401去 https://taotoken.net/api-keys 重新生成一个。第二个是local proxy failed。这个报错说明你的请求被本地代理拦截了。解决方式是关掉系统代理或者检查环境变量HTTP_PROXY、HTTPS_PROXY有没有被设置。在终端里unset HTTP_PROXY HTTPS_PROXY再试。注意不要用任何代理工具直连就行。第三个是reading choices相关报错通常长这样KeyError: choices或者list index out of range。这说明 API 返回的 JSON 结构和你预期的不一样。大概率是 Model ID 填错了比如填了一个不存在的模型名。确认你用的 Model ID 在套餐里支持Claude 系列用claude-sonnet-4-20250514。另外检查请求体里messages字段格式对不对必须是[{role:user,content:...}]。第四个是训练侧的CUDA out of memory。batch size 降到 4或者把图片尺寸从 256 降到 128。3060 12G 跑 batch 8 是够的但如果你同时开了浏览器和编辑器显存会被吃掉一部分。第五个是RuntimeError: size mismatch。这个一般出在加载 checkpoint 的时候模型结构改了但权重还是旧的。删掉 checkpoints 重新训或者确认TransformerNet的层数和保存时一致。第六个是生成图全黑或全白。检查反归一化那步有没有做output.clamp(0,1)之前要先乘 std 加 mean。如果忘了反归一化保存出来的图就是错的。如果你用 Claude Code 或者 Cline 辅助排障把完整报错贴进对话框让它帮你定位。配置参考 https://taotoken.net/doc 里面有各客户端的接入说明。6. 长期编码与 Agent 场景的 CTA跑通这个 style_transformer 项目之后你可能会想把它改成支持多风格、加注意力机制、或者部署成在线服务。这些扩展工作如果纯手写调试成本不低。我现在的做法是长期开一个 Coding Plan把重复性的代码生成、报错分析、超参建议交给模型处理自己专注在模型结构和效果调优上。Coding Plan 的入口在 https://taotoken.net/coding-plan 适合需要连续多轮对话、长上下文编码的场景。比如你想让模型读完整个项目再给重构建议普通按次调用会断上下文Coding Plan 不会。Agent 类任务也是同理多步推理需要稳定的会话保持。如果你只是想先试试模型对话能力直接去 https://taotoken.net/models 网页版不用配任何东西输入问题就能用。API 接入文档在 https://taotoken.net/doc 里面有 Python、Node、curl 的完整示例。最后说个实用技巧训练风格迁移模型时把风格图换成你自己的照片比如一张水彩画或者油画效果会比标准数据集更有意思。我试过用一张黄昏天空的照片做风格图生成的人像会有暖色调的笔触感。你可以多试几张找到自己喜欢的风格。整个项目跑通大概需要半天时间其中大部分花在等训练收敛上。代码本身不复杂关键是理解损失函数的加权逻辑。