首页 / PyTorch 入门教程 / 图像风格迁移:让照片变成梵高画作

PyTorch 入门教程

图像风格迁移:让照片变成梵高画作

本教程共 60 篇 · 第 39 篇 · 更新于 2026-08-17 · 约 4 分钟阅读

PyTorch风格迁移Neural TransferVGGGram矩阵内容损失风格损失

本节目标:搞懂风格迁移的原理——内容用特征图表示、风格用 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。

风格迁移当年出一张图要跑好几分钟,后来有了训练前馈网络的快速版本,输入图片一步前向就出结果,风格也能任意切换。代价是每个风格都要单独训练一个网络,而且风格强度不可调。如今扩散模型几秒就能出图。但这个「用预训练特征定义距离、把图片当参数优化」的思路,是理解后续生成模型的一把钥匙。