图像风格迁移:让照片变成梵高画作
本教程共 60 篇 · 第 39 篇 · 更新于 2026-08-17 · 约 4 分钟阅读
本节目标:搞懂风格迁移的原理——内容用特征图表示、风格用 Gram 矩阵表示,会用预训练 VGG19 搭出两种损失,并了解「优化输入图片」这套不走寻常路的流程。
2015 年,论文《A Neural Algorithm of Artistic Style》让深度学习第一次和艺术挂上钩。给它一张内容图和一张风格图,它能把前者的构图、后者的笔触揉成一张新图:让街拍变成梵高画风。这个技术叫风格迁移(Neural Style Transfer)。
它和前面所有任务有个根本区别:训练的不是网络权重,而是一张图片。网络是现成的预训练模型,锁死不动;要优化的是输入图片的像素值,让它一步步「长」成目标的样子。听着像魔法,拆开看其实很朴素。
内容与风格怎么表示
先说内容怎么表示。把两张图都送进预训练网络,对比中间层的特征图(feature map)。特征图是网络对图片的抽象理解,两张图的特征图越接近,内容就越像。所以内容损失(content loss)就是两张特征图之间的均方误差:
import torch
import torch.nn as nn
import torch.nn.functional as F
class ContentLoss(nn.Module):
"""对比特征图,衡量内容差距。它是"透明层",数据照常流过"""
def __init__(self, target):
super().__init__()
self.target = target.detach() # 目标特征固定,不能跟着梯度跑
def forward(self, input):
self.loss = F.mse_loss(input, self.target)
return input
风格又怎么表示?答案是 Gram 矩阵。把一层特征图摊平成若干向量,两两做内积。内积大说明两个特征经常同时出现——「竖条纹」和「暖黄色」总绑在一起,这就是风格的指纹。空间位置信息被抹掉了,留下的是纹理统计规律。
def gram_matrix(input):
a, b, c, d = input.size() # 批次、通道、高、宽
features = input.view(a * b, c * d)
G = torch.mm(features, features.t())
return G.div(a * b * c * d) # 除以元素总数,防止数值过大
风格损失和内容损失长得一样,只是比的是 Gram 矩阵。区别在取层:内容损失通常只取一层(论文用 conv_4),风格损失要浅到深都取(conv_1 到 conv_5)。为什么这么分?内容信息在网络中层比较稳定,取一层就够;风格的纹理却层层递进——浅层是笔触粗细,深层是构图氛围——只取一层容易丢味道。
用 VGG19 当特征提取器
特征提取器选 VGG19,torchvision 里一行就能拿到。为什么预训练网络能当「审美裁判」?因为它在 ImageNet 上见过几百万张图,早就学会了纹理、形状、颜色这些底层视觉概念,而它们恰好就是风格和内容的组成材料。
import torchvision.models as models
cnn = models.vgg19(weights=models.VGG19_Weights.DEFAULT).features
cnn.eval()
只取 features 部分——风格迁移用不上最后的全连接层,卷积层才是特征的家。调 eval() 是因为 VGG 里有 Dropout,推理时必须关掉,否则每次前向结果都不同,损失会抖来抖去。
Note老教程里的
models.vgg19(pretrained=True)已弃用,改成上面这种weights=写法。还有一件事:VGG 是在特定归一化下训练的——均值 [0.485, 0.456, 0.406]、标准差 [0.229, 0.224, 0.225]。图片进网络前必须照做,不然特征全乱。另外 PIL 图转张量后数值在 0 到 1,别把 0 到 255 的原值直接喂进去。
图片也要先处理:内容和风格图缩放成同样大小、转张量、加一个批次维:
from PIL import Image
import torchvision.transforms as transforms
loader = transforms.Compose([transforms.Resize(256), transforms.ToTensor()])
def image_loader(path):
image = Image.open(path).convert("RGB")
return loader(image).unsqueeze(0) # 加一个批次维
content_img = image_loader("content.jpg")
style_img = image_loader("style.jpg")
把损失层插进网络
输入图用什么初始化?最省事的做法是克隆内容图,内容损失起点低,收敛快,效果稳定;也可以从白噪声开始,让画面从无到有长出来,艺术感更强,但要多等不少迭代。论文原版就是从白噪声起步的,初学建议先用内容图克隆,跑通了再玩花样。
三种图的关系再理一遍:内容图贡献「画什么」,风格图贡献「怎么画」,输入图是待优化的画布,最终成品就是优化完的输入图。
下面把损失层插进 VGG 的指定卷积层后面,拼出风格迁移模型。损失层是「透明」的:数据照常流过,顺带记一笔损失:
import copy
mean = torch.tensor([0.485, 0.456, 0.406]).view(-1, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225]).view(-1, 1, 1)
class Normalization(nn.Module):
def forward(self, img):
return (img - mean) / std
class StyleLoss(nn.Module):
def __init__(self, target_feature):
super().__init__()
self.target = gram_matrix(target_feature).detach()
def forward(self, input):
G = gram_matrix(input)
self.loss = F.mse_loss(G, self.target)
return input
def get_style_model_and_losses(cnn, style_img, content_img,
content_layers=["conv_4"],
style_layers=["conv_1", "conv_2", "conv_3",
"conv_4", "conv_5"]):
cnn = copy.deepcopy(cnn)
model = nn.Sequential(Normalization())
content_losses, style_losses = [], []
i = 0
for layer in cnn.children():
if isinstance(layer, nn.Conv2d):
i += 1
name = f"conv_{i}"
elif isinstance(layer, nn.ReLU):
name = f"relu_{i}"
layer = nn.ReLU(inplace=False)
elif isinstance(layer, nn.MaxPool2d):
name = f"pool_{i}"
else:
continue
model.add_module(name, layer)
if name in content_layers:
cl = ContentLoss(model(content_img).detach())
model.add_module(f"content_loss_{i}", cl)
content_losses.append(cl)
if name in style_layers:
sl = StyleLoss(model(style_img).detach())
model.add_module(f"style_loss_{i}", sl)
style_losses.append(sl)
return model, style_losses, content_losses
函数里的两个 detach() 是重点:目标特征是「标准答案」,存成固定值。等会儿优化输入图片时,要保证梯度只流向图片,不流向目标。
总损失是两部分的加权和:
loss = content_weight * sum(c.loss for c in content_losses) \
+ style_weight * sum(s.loss for s in style_losses)
注意风格权重要给得很大,常见配比是一百万比一。因为 Gram 矩阵的数值量级比特征图小得多,不给足权重,风格根本抢不到话语权。
风格权重也是最好玩的旋钮:调大,笔触浓烈到盖过内容,画面只剩纹理;调小,基本就是原图加层滤镜。动手试两三个值,比看十篇教程印象深。
优化的是图片,不是网络
最后是优化。这里不更新网络,而是把输入图片当作参数,用 LBFGS 优化器一步步迭代。LBFGS 收敛快但耗内存,这种「单张图优化」的小场景正合适:
import torch.optim as optim
input_img = content_img.clone().requires_grad_()
optimizer = optim.LBFGS([input_img])
def closure():
optimizer.zero_grad()
model(input_img)
loss = style_weight * sum(s.loss for s in style_losses) \
+ content_weight * sum(c.loss for c in content_losses)
loss.backward()
return loss
for step in range(300):
optimizer.step(closure)
input_img.data.clamp_(0, 1) # 像素值不能越界
想观察进度,可以在 closure 里打印两种损失的数值:风格损失下降说明纹理在逼近,内容损失下降说明构图在对齐,两个数都稳步往下走,画面基本就成型了。顺便提醒一句:不同图片跑出来的损失量级不一样,这个数值看趋势就好,横向比较没有意义。分辨率也是权衡:图越大,笔触越细腻,但 Gram 矩阵和反向传播的开销同步变大,256 到 512 之间是常见选择。
每迭代一步,图片就往「内容像内容图、风格像风格图」挪一点。三百步下来,一张新画作就诞生了。
Tip我踩过的坑:忘了
detach()目标特征,结果目标跟着梯度一起漂,越「对齐」越跑偏。还有,这算法出了名的慢——CPU 上老老实实用 256 像素以内的小图,有 GPU 再上 512。
风格迁移当年出一张图要跑好几分钟,后来有了训练前馈网络的快速版本,输入图片一步前向就出结果,风格也能任意切换。代价是每个风格都要单独训练一个网络,而且风格强度不可调。如今扩散模型几秒就能出图。但这个「用预训练特征定义距离、把图片当参数优化」的思路,是理解后续生成模型的一把钥匙。