☰
非实时图像风格迁移:TransformerNet训练与推理避坑
2026/11/5 3:09:07 网站建设 项目流程

简介:这份智能计算系统实验资料聚焦非实时图像风格迁移,面向已具备Python与深度学习基础、希望动手理解卷积神经网络应用的学习者,也可作为计算机视觉课程实验的参考。内容围绕VGG19等预训练模型展开,涉及内容损失与风格损失的构建、权重平衡及反向传播优化,可用于艺术化图像生成与图像编辑等场景的入门实践。压缩包共6个文件、约90KB,以5个Python脚本和1张jpg效果图为主:脚本分别承载网络层结构、实验主流程与包初始化等用途,效果图便于对照迁移前后的视觉差异。目前已有1241人学习下载。读者可据此梳理风格迁移的完整实现脉络,复现非实时迁移流程,并通过调整损失权重观察内容保留与风格强度之间的取舍,积累调参与排错经验。

1. 从一张街景照片到一幅油画:这个实验到底在做什么

把一张普通的街头照片丢进脚本,两分钟后拿回来的图带着明显的油画笔触——这是我第一次跑通非实时图像风格迁移时的全部感受。它属于智能计算系统课程里偏工程的那一节,把一个在人工智能里被反复讲的概念落成能跑的代码:先离线训一个前馈网络,再让它对任意图片做一次前向。别指望它是手机上的实时滤镜,名字里"非实时"三个字就是答案,训练要按小时算,出图却是毫秒级。适合已经能写 PyTorch 训练循环、想搞清楚感知损失怎么落地的人,也适合课程实验卡在环境配置上、需要一份能对照的完整实现的人。读完至少能做到三件事:把训练跑起来、看懂 TransformerNet 的每一层、知道换一张风格图要改哪几个值。

2. 环境与数据:跑通之前要准备的四样东西

这个实验最劝退的地方不在模型,而在于"要训练"这件事本身。大多人拿到的第一份代码是推理脚本,跑完发现只有一张图,接着想换风格才发现权重文件不存在,得自己训。所以第 2 章先把环境、数据、入口三件事按顺序摆清楚,后面再拆模型。

2.1 目录结构与依赖版本

我习惯把工程切成四块:数据、模型、训练、推理,各自一个文件,不要写成一个八百行的main.py。第一次跑通之后再回头改,这个结构能省下大量翻文件的时间。

style_transfer/ ├── data/ │ ├── content/ # 内容图,COCO 或自己拍的照片 │ └── style/ # 风格图,一张就够 ├── checkpoints/ # 训练产出的权重 ├── outputs/ # 推理结果 ├── dataset.py # Dataset 与预处理 ├── model.py # TransformerNet + VGG 特征提取 ├── loss.py # 内容损失与风格损失 ├── train.py # 训练入口 └── stylize.py # 单图推理

依赖不用堆太多,这套组合足够:

pip install torch torchvision pillow tqdm numpy

版本上有个血泪经验:torchvision的vgg16权重接口在不同版本间改过。老版本写pretrained=True,新版本要求用weights=VGG16_Weights.IMAGENET1K_V1。我一般直接写新接口,然后在校验脚本里加一句torch.__version__的打印,省得换机器时再猜。CUDA 版本按机器驱动来,模型本身不大,显存 6GB 的卡跑 256×256、batch_size=4 完全够用。

还有一点容易被忽略:InstanceNorm2d在 CPU 上跑得比 GPU 慢很多,如果只是验证代码逻辑通不通,把 batch_size 调到 1、图片缩到 128 就行,别硬等。

2.2 内容数据与风格数据的组织

数据集这一块,内容图和风格图的角色完全不同,不能混着当普通分类数据来读。内容图需要成千上万张、尺寸各异的自然照片,用来让网络学会"任意内容都能处理";风格图只需要一张,训练全程反复用同一张,网络学的是把这张图的笔触迁移出去。常见做法是内容集用公开的大规模自然图像集,风格图自己从画册里挑一张,或者拿课程给的示例图。

实现上用Dataset而不是ImageFolder,因为后者会去读子目录名当标签,我们不需要标签:

import glob, os from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class ContentDataset(Dataset): def __init__(self, root, size=256, crop=256): # 递归收集常见图片格式,注意这里不做标签划分 exts = ("*.jpg", "*.jpeg", "*.png", "*.JPEG") self.paths = [] for ext in exts: self.paths += glob.glob(os.path.join(root, "**", ext), recursive=True) self.paths = sorted(set(self.paths)) if not self.paths: raise FileNotFoundError(f"没有在 {root} 下找到图片") # Resize 传元组而不是单个整数,短边不够的图也不会在 Crop 时炸掉 self.tf = transforms.Compose([ transforms.Resize((size, size)), transforms.CenterCrop(crop), transforms.ToTensor(), # 输出 [0,1],不在这里做 ImageNet 归一化 ]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("RGB") return self.tf(img)

几个参数值得单独说。size和crop通常取同一个值,训练分辨率就是 256。如果你用单整数做Resize(256),它会把短边缩到 256、长边按比例走,遇到长宽比极端或者原图小于 256 的样本,CenterCrop直接抛异常,训练跑到第 3000 步突然崩就是这么来的。传(256, 256)是强制拉伸,会牺牲一部分长宽比,但对风格迁移这种任务影响很小,省心。

ToTensor()之后不做 ImageNet 归一化,是为了让模型输出直接落在[0,1]区间,保存图片时不用反算。归一化放到损失计算里面做,这样模型本身是"端到端看得懂图片"的,调试的时候直接把输出存成 PNG 就能看出网络学到了什么。

2.3 训练入口与参数怎么定

训练脚本用argparse暴露参数,方便反复调。真正需要动的其实只有五六个,其余固定即可。

参数常用取值说明
--datasetdata/content内容图根目录
--style-imagedata/style/xxx.jpg风格图路径,单张
--epochs2一 epoch 通常就是几万张,2 轮已经能出效果
--batch-size4显存 6GB 时的安全值
--lr1e-3Adam 的默认量级,配合weight_decay微调
--content-weight1e5内容保真度权重
--style-weight1e10风格强度权重,和上一项相差好几个数量级是正常的
--image-size256训练分辨率

启动命令长这样:

python train.py \ --dataset data/content \ --style-image data/style/starry.jpg \ --epochs 2 \ --batch-size 4 \ --lr 1e-3 \ --content-weight 1e5 \ --style-weight 1e10 \ --image-size 256 \ --checkpoint-dir checkpoints

命令本身没什么门槛,门槛在两个权重值的数量级差。content_weight和style_weight不是"越大越强"的关系,它们是在两个损失之间做加权求和,比例才决定风格化程度。差三四个数量级很正常,看到1e5对1e10别急着以为自己写错了。我一般先在 256 分辨率、batch 4 的配置下跑 2000 步看看合成图,觉得风格不够就调style_weight,觉得内容走形就调content_weight,一次只动一个。

3. 模型拆解:TransformerNet 一次前向到底走了什么

风格迁移有两类实现路线:一类是每次出图都做一遍优化迭代,慢但灵活;另一类是先用大量图片训一个前馈网络,之后一次前向就出图。这个实验用的是后者,模型叫 TransformerNet,名字和注意力机制没关系,纯粹是"变换网络"的意思,别被误导。

3.1 编码、残差、解码三段式结构

整个网络可以粗暴地看成三段:下采样编码、残差堆叠、上采样解码。输入端一张 3 通道图,经过两个 stride=2 的卷积把空间尺寸压到 1/4、通道数升到 128;中间五个残差块在低分辨率上做特征变换,全程尺寸和通道不变;最后两次最近邻上采样把图恢复到原尺寸,通道降回 3。整个过程没有池化,也没有全连接,全是卷积,所以对输入尺寸没有硬性约束——这也是它能在推理时处理任意大小图片的原因。

关键参数就三个:通道数 32/64/128、残差块数量 5、首尾卷积核 9×9。9×9 的大核是为了扩大感受野,让笔触看起来更连续;中间用 3×3 是为了控制参数量。5 个残差块是经验值,加到 8 个风格更明显但训练更容易过拟合,减到 3 个则风格偏弱、细节糊。

3.2 逐层代码与形状推演

先把基础积木写出来,一个卷积 + 实例归一化 + 可选激活的组合:

import torch import torch.nn as nn class ConvIN(nn.Sequential): """Conv2d + InstanceNorm2d (+ ReLU),padding 按 kernel 自动算""" def __init__(self, in_ch, out_ch, kernel, stride=1, relu=True): pad = kernel // 2 layers = [ nn.Conv2d(in_ch, out_ch, kernel, stride, pad, bias=False), nn.InstanceNorm2d(out_ch, affine=True), ] if relu: layers.append(nn.ReLU(inplace=True)) super().__init__(*layers) class ResidualBlock(nn.Module): """两个不带激活的 ConvIN 相加,保留低分辨率上的特征""" def __init__(self, ch): super().__init__() self.block = nn.Sequential( ConvIN(ch, ch, 3, relu=False), ConvIN(ch, ch, 3, relu=False), ) def forward(self, x): return x + self.block(x)

残差块里两次卷积都不加 ReLU,这是刻意的。如果在相加之前就过激活,残差分支的输出会被截断到非负,x + f(x)的恒等映射路径就被破坏了,网络在浅层学不到"什么都不做"这个解,训练前期会明显更抖。这一条踩过坑之后就没再改过。

主网络:

class TransformerNet(nn.Module): def __init__(self, num_res_blocks=5): super().__init__() # 编码:256 -> 128 -> 64 self.encoder = nn.Sequential( ConvIN(3, 32, 9, stride=1), # 256x256x32 ConvIN(32, 64, 3, stride=2), # 128x128x64 ConvIN(64, 128, 3, stride=2), # 64x64x128 ) # 变换:尺寸不变,只做特征重组 self.transform = nn.Sequential( *[ResidualBlock(128) for _ in range(num_res_blocks)] ) # 解码:64 -> 128 -> 256,通道回到 3 self.decoder = nn.Sequential( nn.Upsample(scale_factor=2, mode="nearest"), # 128x128x128 ConvIN(128, 64, 3, stride=1), # 128x128x64 nn.Upsample(scale_factor=2, mode="nearest"), # 256x256x64 ConvIN(64, 32, 3, stride=1), # 256x256x32 nn.Conv2d(32, 3, 9, 1, 4), # 256x256x3,不加归一化 ) def forward(self, x): x = self.encoder(x) x = self.transform(x) x = self.decoder(x) return x

形状推演值得在纸上画一遍。输入[B,3,256,256],第一个 9×9 卷积 pad=4、stride=1,输出还是 256;第二个 3×3、stride=2、pad=1,输出边长是(256+2-3)//2+1 = 128,对上了;第三个同理到 64。解码端两个Upsample(scale_factor=2)把 64 拉回 256,最后 9×9 卷积把 32 通道映射到 3 通道。

最后一层卷积故意不加归一化和激活。归一化会把输出强行拉回零均值、单位方差,激活会把负数截掉,两者都会让输出的颜色分布崩掉——图像是[0,1]区间的东西,不是特征图。这一层就是网络对"画出来"的最后一次表态,要让它自由。

3.3 实例归一化在这里为什么不能换成批归一化

把InstanceNorm2d换成BatchNorm2d,代码一样能跑,但效果会明显不一样。批归一化统计的是整个 batch 的均值和方差,batch_size=4 的时候这个统计量本身就很不稳,而且推理时用的是训练期累积的 running stats,遇到训练集里没出现过的图像分布就容易发灰。实例归一化对每张图、每个通道单独统计,天然和 batch 大小无关,风格迁移又恰好是"每张图自成一派"的任务,所以它是更匹配的选择。

代价是它丢掉了通道间的信息交互,风格迁移不介意这一点。另外affine=True是必须的,它给每个通道配了一组可学习的缩放和平移参数,否则归一化之后所有通道都被拉到同一量纲,网络就失去了调整色彩的能力。这一项默认是False,忘了开的话训练半天图都是灰的。

4. 训练链路:损失函数和一次完整迭代

模型只是"怎么算",损失才决定"学什么"。风格迁移最反直觉的一点是:整条链路上没有任何一张"标准答案"图片,损失是拿预训练 VGG 的特征图算出来的。

4.1 内容损失、风格损失与 Gram 矩阵

内容损失比较的是合成图和内容图在 VGG 高层特征上的差异,用relu3_3这一层的输出,它保留了物体轮廓和布局,但丢掉了纹理细节。风格损失比较的是 Gram 矩阵,Gram 矩阵由特征图的通道两两做内积得到,本质上描述的是"哪些纹理同时出现",和空间位置无关,所以迁移的是笔触而不是构图。

import torch import torch.nn as nn from torchvision import models from torchvision.models import VGG16_Weights IMAGENET_MEAN = (0.485, 0.456, 0.406) IMAGENET_STD = (0.229, 0.224, 0.225) def gram_matrix(feat): b, c, h, w = feat.size() f = feat.view(b, c, h * w) # 除以元素个数,防止特征图越大 Gram 数值越大,导致风格权重失效 return f.bmm(f.transpose(1, 2)) / (c * h * w) class VGGFeatures(nn.Module): def __init__(self, weights=VGG16_Weights.IMAGENET1K_V1): super().__init__() features = models.vgg16(weights=weights).features self.s1 = nn.Sequential(*features[0:4]) # relu1_2 self.s2 = nn.Sequential(*features[4:9]) # relu2_2 self.s3 = nn.Sequential(*features[9:16]) # relu3_3 self.s4 = nn.Sequential(*features[16:23]) # relu4_3 for p in self.parameters(): p.requires_grad = False def forward(self, x): h1 = self.s1(x) h2 = self.s2(h1) h3 = self.s3(h2) h4 = self.s4(h3) return [h1, h2, h3, h4]

切片下标必须对着 VGG16 的层序数一遍:features[3]是第一个 relu1_2,features[8]是 relu2_2,features[15]是 relu3_3,features[22]是 relu4_3。写错一位,损失函数照样能算出数,只是算的是别的东西,很难从 loss 曲线上看出来,属于典型的黑匣子故障。

损失模块把归一化和 Gram 缓存都收进来:

class StyleLoss(nn.Module): def __init__(self, style_img, content_weight=1e5, style_weight=1e10): super().__init__() self.vgg = VGGFeatures().eval() self.cw = content_weight self.sw = style_weight self.register_buffer("mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1)) self.register_buffer("std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1)) # 风格图的 Gram 是常量,训练前算一次就够,别每步重复算 with torch.no_grad(): self.register_buffer( "style_grams", torch.stack([gram_matrix(f) for f in self.vgg(self._norm(style_img))]), ) def _norm(self, x): return (x - self.mean) / self.std def forward(self, output, content): out_feats = self.vgg(self._norm(output)) cnt_feats = self.vgg(self._norm(content)) c_loss = nn.functional.mse_loss(out_feats[2], cnt_feats[2]) s_loss = 0.0 for out_f, gram_t in zip(out_feats, self.style_grams): s_loss = s_loss + nn.functional.mse_loss(gram_matrix(out_f), gram_t) total = self.cw * c_loss + self.sw * s_loss return total, c_loss, s_loss

三个细节。第一,VGG 参数全部冻结,并且设为eval(),否则 BatchNorm 层会在训练中被更新,损失基准一直在漂。第二,风格图的 Gram 用register_buffer存起来,跟着to(device)自动搬,不用手动cuda()。第三,返回总损失的同时把两项单独返回,训练时打日志用,这样调权重的时候能看出是内容丢了还是风格没上去。

4.2 训练主循环与权重保存

训练循环没什么花活,就是标准的四步:前向、算损失、反向、更新。唯一要强调的是别把 VGG 放进来一起优化。

import os, torch from torch.utils.data import DataLoader from tqdm import tqdm def train(args, dataset, style_img, device): loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True, num_workers=4, drop_last=True) model = TransformerNet().to(device) criterion = StyleLoss(style_img.to(device), args.content_weight, args.style_weight).to(device) # 只传 TransformerNet 的参数,VGG 不参与优化 optimizer = torch.optim.Adam(model.parameters(), lr=args.lr) os.makedirs(args.checkpoint_dir, exist_ok=True) step = 0 for epoch in range(args.epochs): model.train() bar = tqdm(loader, desc=f"epoch {epoch}") for content in bar: content = content.to(device) output = model(content) total, c_loss, s_loss = criterion(output, content) optimizer.zero_grad() total.backward() optimizer.step() step += 1 if step % 50 == 0: bar.set_postfix(step=step, total=f"{total.item():.2f}", content=f"{c_loss.item():.4f}", style=f"{s_loss.item():.2f}") # 每 1000 步存一次,训练中断不至于前功尽弃 if step % 1000 == 0: path = os.path.join(args.checkpoint_dir, f"ckpt_{step}.pth") torch.save(model.state_dict(), path) return model

drop_last=True是有意加的,因为最后一批数量不足时,实例归一化虽然不受影响,但日志里 loss 的波动会变大,看起来像训练崩了,其实是 batch 太小。

Adam的学习率用 1e-3 起步,跑 2000 步没有明显收敛就降到 5e-4。别加weight_decay,它会让输出的笔触变得保守,风格不明显。

日志里三项分开打,是为了判断问题出在哪。content项长期在 0.0x 附近不动、style项持续下降,说明网络在疯狂往风格上靠,内容可能已经走形;反过来style降不下去,多半是style_weight给太小,或者风格图的 Gram 有问题。

4.3 保存图片与单图推理

训练时的输出是[0,1]附近的浮点张量,存图前要裁剪。这里有一个我踩过的坑:某一版网络输出的值会略微超出[0,1],直接ToPILImage会被截断,颜色看起来有点脏,一开始以为是训练不够。

import torch from torchvision.utils import save_image @torch.no_grad() def stylize(model, img_tensor, out_path, device): model.eval() # 别漏,InstanceNorm 的行为依赖这个 img_tensor = img_tensor.unsqueeze(0).to(device) output = model(img_tensor) output = output.clamp(0, 1) # 显式裁剪,避免溢出导致的色偏 save_image(output, out_path) return output

model.eval()这一行不是形式主义。如果哪天为了做实验给InstanceNorm2d加上了track_running_stats=True,训练模式走的是每批的实例统计,推理模式走的是累积的 running stats,后者是在 batch_size=4 上算出来的,单张图丢进去,输出会明显发灰。这个坑排查起来非常费劲,因为训练日志一切正常,只有出图不对。

推理时的图片尺寸不用和训练一致,网络是全卷积的,随便给。但如果尺寸不是 32 的整数倍,上采样两次之后可能差一两个像素,save_image会报尺寸不匹配。稳妥做法是推理前先Resize到 512 或 1024,既规避问题,出图质量也更好。

5. 避坑记录:五条我真实踩过的坎

5.1 训练阶段的问题

现象一:训练跑到中途直接抛异常退出,报张量尺寸不匹配。原因基本可以锁定在数据预处理。Resize传单个整数时按短边缩放,长宽比极端的图片(比如全景照)裁出来的尺寸和别的样本不一致,DataLoader拼 batch 时就炸了。 解决是把Resize((size, size))写成元组,强制所有样本统一尺寸。如果不想牺牲长宽比,就在__getitem__里加个try/except,遇到异常样本跳过并打印路径,别让一条脏数据毁掉两小时的训练。

现象二:训练几千步后合成图变成色块,内容完全看不出。style_weight给得太大了,或者学习率过高导致参数飞掉。风格损失和内容损失是加权求和,风格项压过内容项之后,网络会倾向于输出一张"平均笔触图",和输入内容彻底无关。 解决顺序是先把lr降到 5e-4,再回头看content_weight。判断标准很简单:训练日志里c_loss应该是一个持续下降、最后稳定在较小值的曲线,如果它一路往上走,就是风格项在欺负它。

现象三:loss 数值正常下降,但输出图整体发灰、对比度极低。两个常见原因。一是InstanceNorm2d忘了开affine=True,通道失去了可学习的缩放平移能力;二是损失计算里的 ImageNet 归一化和模型输出端没对齐,VGG 拿到的是未归一化的图,特征分布和预训练时差很远,算出来的损失虽然能降,但语义是错的。 解决是把模型输出直接存成 PNG 看一眼。如果存出来的图本身就是灰的,问题在模型;如果存出来正常但 loss 很怪,问题在归一化对齐。

现象四:显存持续上涨,跑到几百步就 OOM。VGG 前向的时候没包在torch.no_grad()里,导致每一层的中间激活都被记进计算图。VGG16 在 256 分辨率下前向一次的激活占用不小,加上风格图 Gram 每步重算,显存很快就顶不住。 解决是把 VGG 的参数requires_grad=False,Gram 目标值只在初始化时算一次,训练循环里对 VGG 部分不保留梯度。显存还紧张就把 batch_size 降到 2,或者用torch.cuda.amp做混合精度。

5.2 推理与部署阶段的问题

现象五:训练集上出图正常,换一张自己的照片就变成噪点或者纯色。最常见的原因是训练数据分布太窄。内容集如果全是风景照,拿一张室内图进去,网络没见过这种纹理密度,输出就会崩。另一个原因是推理时忘了model.eval(),配合自定义的归一化层时行为不一致。 解决是把训练集换成更杂的样本,实在不行就在推理前把输入图做一个轻微的对比度归一化,让输入分布靠拢训练分布。另外,推理分辨率比训练高太多时(比如训练 256、推理 2048),激活值的量纲会变,出图偏色,这时先把图缩到 512 试一版,能对上就说明是这个原因。

提示:遇到任何"训练正常但出图异常"的情况,先固定随机种子、固定一张测试图、固定一个 checkpoint,把变量收到只剩一个再查。风格迁移的排查如果同时改三个参数,基本等于没查。

6. 二次开发:换风格、降显存与端侧改造

跑通之后真正有意思的部分才开始。最常见的改造需求有三个:换一张风格图、在有限显存上训更高分辨率、把模型塞进移动端。

换风格图最简单,但有个前提:一个 checkpoint 只对应一张风格图。TransformerNet 学的是"把任意内容映射到这一种风格",不是"理解任意风格"。想同时支持多种风格,两条路:一是每个风格训一个权重,推理时按名字选文件,工程上最省事;二是给网络加条件输入,把风格编码成一个向量拼进中间特征层,代价是训练数据要准备多种风格图、损失要对齐到各自的 Gram,工作量翻几倍。课程实验走第一条路完全够用。

降显存的关键不是砍 batch,而是砍 VGG 的重复计算。内容图的 VGG 特征是每步都要算的,但同一批内容图在训练中只会被用一次,没必要缓存。真正能省的是把 VGG 换成更浅的特征提取器,或者只在relu3_3一层算风格损失,丢掉relu1_2和relu4_3的贡献,显存能降三分之一,代价是笔触的层次感变薄。

端侧改造的思路是把训练和推理彻底分开。训练完的state_dict大概几 MB,用torch.jit.trace导成静态图,输入固定成[1,3,512,512],能再压掉一部分开销。真正部署前记得做一次数值对齐验证:

import torch model = TransformerNet() model.load_state_dict(torch.load("checkpoints/ckpt_4000.pth", map_location="cpu")) model.eval() dummy = torch.rand(1, 3, 512, 512) traced = torch.jit.trace(model, dummy) traced.save("transformer_net.pt") # 对齐验证:trace 前后输出必须一致,否则说明有动态控制流被固化 with torch.no_grad(): diff = (model(dummy) - traced(dummy)).abs().max().item() print("max diff:", diff) # 应该在 1e-5 量级

max diff明显大于 1e-4 就说明 trace 过程吃掉了某些动态行为,检查一下forward里有没有依赖输入的具体数值做判断的写法。这套流程我现在每次导出模型都强制走一遍,因为端侧出问题是没有日志可看的,只能靠离线对齐提前拦住。

最后说个习惯:每改一次模型结构,先别急着开训练,用batch_size=2、epochs=1、image_size=128跑一轮,确认 loss 能降、能存图、能推理,再去跑正式的那一版。这个习惯让我少熬了很多个夜晚。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询