神經(jīng)風格遷移:VGG19與Gram矩陣讓照片秒變梵高畫作)
先問一個我自己也琢磨了很久的問題當你把一張隨手拍的照片丟進某個工具幾秒鐘后得到一張星空流轉(zhuǎn)的“梵高版”圖片時你有沒有想過機器到底是怎么把“這張照片里有什么”和“畫家的筆觸怎么畫”這兩件事分開的這背后就是這篇文章要聊的主角——AI繪畫風格遷移。它不是濾鏡不是貼紙而是一套“提取風格、重組內(nèi)容”的算法邏輯。這篇文章我會直接把“照片轉(zhuǎn)梵高、莫奈畫風”這件事拆開做一遍從原理、手寫代碼、調(diào)參到批量落地都會覆蓋適合那些已經(jīng)跑過一兩個現(xiàn)成工具、想真正理解甚至自己實現(xiàn)一遍的讀者。1. 想讓照片擁有畫家的筆觸先弄明白風格信號藏在哪兒很多人第一次玩風格遷移腦子里默認它是“把畫貼在照片上”的合成操作。但實際上最經(jīng)典的一類風格遷移算法根本不做任何像素級的替換而是借用一個已經(jīng)訓練好的圖像識別網(wǎng)絡(luò)把“內(nèi)容”和“風格”當成兩個可以被獨立衡量、獨立優(yōu)化的目標。我強烈建議你先把這個底層邏輯吃透否則后面調(diào)參全是在瞎猜。1.1 卷積網(wǎng)絡(luò)如何同時讀出“畫的是什么”和“怎么畫的”這里用到的骨干網(wǎng)絡(luò)通常是VGG19這類在ImageNet上預(yù)訓練過的卷積神經(jīng)網(wǎng)絡(luò)。它之所以能做風格遷移是因為卷積網(wǎng)絡(luò)本身具有一個非常自然的屬性淺層卷積核關(guān)注的是邊緣、顏色、短線、小紋理層數(shù)越深感受野越大神經(jīng)元響應(yīng)的就是“眼睛”“船帆”“建筑輪廓”這類語義目標。風格遷移的關(guān)鍵操作就是在某個中間層把“內(nèi)容信號”和“風格信號”分別抽出來。你可以把整張圖喂進網(wǎng)絡(luò)在某個淺層拿到的特征圖就是“怎么畫的”在深層拿到的特征圖就是“畫的是什么”。這里有個生活化類比你遠遠看一張莫奈的《睡蓮》先注意到的是水面反光的顏色關(guān)系走近了才看到一層層堆疊的短促筆觸。人類視覺是這樣分層的卷積網(wǎng)絡(luò)其實也是。我會在后面的代碼里選擇VGG19的conv_4層作為內(nèi)容提取層這不是隨便選的——太淺的內(nèi)容層會把邊緣紋理也當成內(nèi)容死死鎖住導(dǎo)致結(jié)果像一張加過濾鏡的線稿太深的內(nèi)容層又會丟失細節(jié)結(jié)果結(jié)構(gòu)松散。conv_4層是一個相對均衡的位置很多經(jīng)典實現(xiàn)都用它。1.2 內(nèi)容損失讓輸出圖盡量保留“畫的是什么”內(nèi)容損失的思路其實非常簡單把內(nèi)容照片和目標輸出圖像分別經(jīng)過同一個卷積網(wǎng)絡(luò)取某一層的特征圖然后算這兩組特征圖之間的均方誤差MSE。誤差越小說明兩張圖在這一層“看到的內(nèi)容”越接近。你可能會問為什么不直接在像素層面比較兩張圖因為像素層面的接近不代表語義內(nèi)容接近稍微偏移幾個像素像素值就完全變了但人眼看起來還是同一棟房子反過來兩張圖在conv_4層的特征圖接近基本可以認為它們“畫的是同一個東西”。這就是所謂的內(nèi)容與風格解耦的第一步——內(nèi)容用深層特征衡量而不是用像素衡量。在實際訓練中內(nèi)容損失只鎖定在深層特征上風格損失則分布在多個淺層和深層。最終優(yōu)化器要做的事情就是讓輸出圖同時滿足兩個要求在深層特征上和照片接近在各種統(tǒng)計特征上和畫作風格接近。1.3 風格損失Gram矩陣就是畫面風格的“統(tǒng)計指紋”風格損失才是這套算法的靈魂。理解它的核心是理解Gram矩陣。假設(shè)網(wǎng)絡(luò)某一層有C個特征通道每個通道是一張H×W的特征圖。把每個通道的特征圖拉平成一個大向量然后計算這C個向量兩兩之間的內(nèi)積就得到一個C×C的矩陣這就是Gram矩陣。它記錄的是這一層里任意兩個特征模式在空間上“共現(xiàn)”的概率。舉個例子。如果一幅梵高畫的淺層特征里“黃色短筆觸”這個特征常常與“旋渦狀走向”的特征同時出現(xiàn)Gram矩陣里對應(yīng)的那個位置數(shù)值就會很大。如果換成另一幅正常照片該位置數(shù)值就比較小。所以Gram矩陣本質(zhì)上是一種高維統(tǒng)計指紋它忽略掉“這些特征具體出現(xiàn)在哪里”只保留“這些特征傾向于怎樣搭配出現(xiàn)”。這就是風格遷移不管筆觸位置、只管筆觸氣質(zhì)的原因也是它能做到“畫面完全不同但味道相同”的根本機制。寫代碼的時候你會在五個卷積層上都計算風格損失淺層負責筆觸紋理深層負責色調(diào)布局各管一段。2. 不靠現(xiàn)成濾鏡PyTorch手寫一套最小神經(jīng)風格遷移原理講完就該上手了。下面這套實現(xiàn)是經(jīng)典論文“A Neural Algorithm of Artistic Style”的現(xiàn)代PyTorch復(fù)刻我盡量把核心代碼壓縮到最小可運行范圍。只要你有環(huán)境、有一張內(nèi)容照片、一張風格畫作梵高或莫奈的圖片都可以就能復(fù)現(xiàn)整個過程。2.1 環(huán)境與模型選型為什么還是VGG選型時我用的是torchvision自帶的VGG19預(yù)訓練權(quán)重而不是新潮的ResNet或Transformer系列。原因有兩個一是VGG是純卷積堆疊沒有殘差連接和全局池化特征連續(xù)性更好風格損失在這些層上表現(xiàn)更穩(wěn)定二是這個領(lǐng)域十幾年的論文和代碼都建立在VGG特征上遇到問題也最容易查到對照資料。環(huán)境上只需要Python、PyTorch、TorchVision、Pillow沒有其他復(fù)雜依賴。如果你的機器有NVIDIA顯卡后面優(yōu)化可以提速沒有也沒關(guān)系把輸入圖縮到512×512純CPU迭代幾百步也能出結(jié)果只是要多等幾分鐘。2.2 核心代碼構(gòu)建帶損失節(jié)點的前向模型下面這段代碼把VGG19的前幾層包裝成一個可以隨時輸出損失值的容器。import torch import torch.nn as nn import torch.optim as optim import torchvision.transforms as transforms import torchvision.models as models from PIL import Image device torch.device(cuda if torch.cuda.is_available() else cpu) # ---------- 圖像預(yù)處理 ---------- imsize 512 loader transforms.Compose([ transforms.Resize((imsize, imsize)), transforms.ToTensor(), ]) def load_image(path): img Image.open(path).convert(RGB) return loader(img).unsqueeze(0).to(device) # ---------- 歸一化層把輸入圖像轉(zhuǎn)到VGG能理解的分布 ---------- class Normalization(nn.Module): def __init__(self, mean, std): super().__init__() self.mean torch.tensor(mean).view(-1, 1, 1).to(device) self.std torch.tensor(std).view(-1, 1, 1).to(device) def forward(self, x): return (x - self.mean) / self.std # ---------- Gram矩陣 ---------- def gram_matrix(feature): b, c, h, w feature.size() feat feature.view(b * c, h * w) G torch.mm(feat, feat.t()) return G.div(b * c * h * w) # ---------- 風格損失與內(nèi)容損失 ---------- class StyleLoss(nn.Module): def __init__(self, target_feature): super().__init__() self.target gram_matrix(target_feature).detach() def forward(self, x): G gram_matrix(x) self.loss nn.functional.mse_loss(G, self.target) return x class ContentLoss(nn.Module): def __init__(self, target_feature): super().__init__() self.target target_feature.detach() def forward(self, x): self.loss nn.functional.mse_loss(x, self.target) return x重點來了下面這段代碼負責把VGG19的卷積層和損失節(jié)點串到一起cnn models.vgg19(pretrainedTrue).features.to(device).eval() for p in cnn.parameters(): p.requires_grad_(False) content_layers [conv_4] style_layers [conv_1, conv_2, conv_3, conv_4, conv_5] def build_model(style_img, content_img): model nn.Sequential( Normalization([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ) content_losses, style_losses [], [] i 0 for layer in cnn.children(): if isinstance(layer, nn.Conv2d): i 1 name fconv_{i} elif isinstance(layer, nn.ReLU): name frelu_{i} layer nn.ReLU(inplaceFalse) # 必須inplaceFalse elif isinstance(layer, nn.MaxPool2d): name fpool_{i} else: name fbn_{i} model.add_module(name, layer) if name in style_layers: target model(style_img).detach() style_loss StyleLoss(target) model.add_module(fstyle_loss_{i}, style_loss) style_losses.append(style_loss) if name in content_layers: target model(content_img).detach() content_loss ContentLoss(target) model.add_module(fcontent_loss_{i}, content_loss) content_losses.append(content_loss) return model, content_losses, style_losses這里有個不能省的細節(jié)原版VGG的ReLU是inplace操作但我們后面的反向傳播要同時經(jīng)過多個損失節(jié)點inplace會破壞中間特征必須改成inplaceFalse。另一個細節(jié)是凍結(jié)所有預(yù)訓練參數(shù)只優(yōu)化輸入否則梯度路徑會亂掉。2.3 優(yōu)化循環(huán)讓一張“內(nèi)容照片的副本”慢慢長出筆觸模型搭好后開始迭代。我給輸入圖像初始化成內(nèi)容照片的克隆這樣比從白噪聲出發(fā)收斂快得多。優(yōu)化器用的是L-BFGS它在幾十到幾百輪內(nèi)就能收斂而且不需要手動選學習率。style_img load_image(style.jpg) # 梵高或莫奈畫作 content_img load_image(content.jpg) # 你的照片 model, content_losses, style_losses build_model(style_img, content_img) input_img content_img.clone().requires_grad_(True) optimizer optim.LBFGS([input_img]) style_weight 1e6 content_weight 1 def closure(): optimizer.zero_grad() model(input_img) style_score sum(sl.loss for sl in style_losses) content_score sum(cl.loss for cl in content_losses) total_loss style_weight * style_score content_weight * content_score total_loss.backward() return total_loss for step in range(300): optimizer.step(closure) input_img.data.clamp_(0, 1) # 保存結(jié)果 from torchvision.utils import save_image save_image(input_img.squeeze(0), output.jpg)每次optimizer.step(closure)模型都會對當前輸入做一遍前向風格和內(nèi)容損失被記錄在對應(yīng)的損失模塊里然后統(tǒng)一加權(quán)回傳。因為只有輸入是optimizer管理的變量反向傳播不會去改VGG的權(quán)重。300步通常已經(jīng)能看到清晰畫風如果想更細膩可以提到500到800步。2.4 第一張結(jié)果出現(xiàn)后必須檢查的幾件事你跑出來的第一張圖大概率不是完美作品但通過現(xiàn)象能快速定位問題輸出圖還像一張干凈照片幾乎沒風格風格權(quán)重太小或者迭代次數(shù)不夠。把style_weight往上提到1e7試試。輸出圖糊成一團物體結(jié)構(gòu)認不出來風格權(quán)重太大或者內(nèi)容權(quán)重太小。把style_weight降到1e5附近同時確認內(nèi)容層是conv_4而不是更深。畫面上出現(xiàn)大量規(guī)則性噪點或不自然的條紋輸入圖像尺寸太大時容易出現(xiàn)先把imsize改成256或384跑通再放大。色彩發(fā)灰發(fā)暗不用急著改代碼保存后在后處理階段做一次直方圖匹配就好我會在第四部分展開。3. 同一套代碼為什么梵高一試就出彩、莫奈經(jīng)常翻車我最早做風格遷移時有個很直觀的感受同樣一張照片用梵高的《星夜》當風格圖出來的效果一眼驚艷換成莫奈的《日出·印象》結(jié)果要不是像加了層薄濾鏡要不就是把畫面攪成一團色塊。我一度以為是代碼有bug后來把不同風格圖的Gram矩陣差異拉出來對比才徹底明白問題出在風格信號本身的結(jié)構(gòu)上。3.1 梵高的風格信號強在淺層高頻筆觸讓Gram統(tǒng)計差異明顯梵高的畫作特征極其鮮明短促的線條、明顯的螺旋紋理、高飽和的對比色塊。這些高頻筆觸在VGG的淺層卷積核里會產(chǎn)生強烈的激活Gram矩陣當中對應(yīng)的統(tǒng)計指紋和普通照片的Gram矩陣差距非常大。這帶來一個很大的便利神經(jīng)風格遷移在優(yōu)化時只需要較小風格權(quán)重就能把“筆觸感”從風格圖里拉出來。內(nèi)容權(quán)重稍微高一點也不怕因為淺層的風格信號足夠強優(yōu)化器不會為了滿足風格損失而犧牲太多內(nèi)容結(jié)構(gòu)。換句話說梵高風格是“強信號風格”容錯率高。我自己實測下來用《星夜》做風格圖時style_weight1e5到1e6之間都能得到不錯的平衡內(nèi)容細節(jié)保留度也高非常新手友好。3.2 莫奈和印象派的問題風格藏在中深層淺層統(tǒng)計差異不多莫奈這類的印象派畫作筆觸其實是碎而短的但整體色調(diào)柔和、邊緣模糊、反光混散不像梵高那樣有強方向性線條。VGG淺層對這些筆觸單獨激活并不弱但它們的空間共現(xiàn)關(guān)系比較溫和Gram矩陣對比普通照片的差異幅度明顯偏小。于是訓練時會出現(xiàn)一種兩難風格權(quán)重不足輸出就像一張輕微調(diào)色的照片風格權(quán)重一旦加大優(yōu)化器為了讓中深層Gram矩陣靠近莫奈目標會把畫面里的銳利邊緣和結(jié)構(gòu)全部抹平物體輪廓變成一團霧面。這就是莫奈風格經(jīng)?!胺嚒钡母驹颉皇强烤植抗P觸而是靠整體色調(diào)關(guān)系的“弱風格”。3.3 針對不同畫家微調(diào)的配置模板我整理了一個在實際項目中反復(fù)使用的模板你可直接抄風格類型風格層權(quán)重分布style_weightcontent_weight提示梵高星夜/向日葵conv_1到conv_5均勻分布1e5 ~ 5e51淺層筆觸強權(quán)重不用太激進莫奈日出/睡蓮重點放在conv_3、conv_4淺層減半1e6 ~ 1e70.5 ~ 1需要更激進的風格壓力混合/自己不知道的畫風先均勻再看結(jié)果1e6起步1每跑完一輪看是“不夠味”還是“糊了”這里只是起點真正微調(diào)要在低分辨率下多跑幾輪。我習慣把迭代過程中的中間圖每50步保存一次這樣能直觀看到風格是從淺層筆觸開始逐步覆蓋到色調(diào)的出現(xiàn)問題時也能確認是權(quán)重配比還是收斂方向的問題。4. 控制畫面風格的三個開關(guān)權(quán)重、層級與后處理風格遷移不是一鍵生成的魔法它有三個控制畫面風格的開關(guān)。很多人拿到代碼只會調(diào)style_weight和content_weight這兩個全局量但實際上層級分配和后處理的影響同樣巨大有時候比盲目改權(quán)重更高效。4.1 內(nèi)容權(quán)重與風格權(quán)重這架天平要放到什么位置全局權(quán)重是第一個開關(guān)也是最直觀的。它們的目標函數(shù)如下總損失 content_weight × L_content style_weight × L_stylecontent_weight管的是“畫面里還是不是那棟房子、那個人”style_weight管的是“畫面氣質(zhì)夠不夠畫家”。兩者的比例關(guān)系遠比絕對值重要。我做過一組對照實驗固定內(nèi)容權(quán)重為1風格權(quán)重分別取1e4、1e5、1e6、1e7style_weight畫面效果1e4幾乎沒風格只是輕微色彩偏移1e5看得出筆觸內(nèi)容結(jié)構(gòu)完整梵高風格的最佳區(qū)間1e6風格非常明顯部分紋理開始覆蓋細節(jié)1e7內(nèi)容語無倫次畫面淪為抽象紋理莫奈容忍度略高這不是越變越大就越好的關(guān)系而是要根據(jù)風格強度和內(nèi)容價值動態(tài)調(diào)整。如果是人像我通常會把content_weight提到2防止五官結(jié)構(gòu)被攪亂如果是風景照content_weight保持1甚至更低反而更有繪畫感。4.2 分層權(quán)重淺層管筆觸深層管色調(diào)第二個開關(guān)是風格層的分配權(quán)重。默認情況下五個風格損失是簡單相加但我們可以改造它讓每個層的權(quán)重不同。比如莫奈風格需要更重視中深層這里給出一段修改示例style_layer_weights { conv_1: 0.4, conv_2: 0.8, conv_3: 1.0, conv_4: 1.2, conv_5: 1.0, } # 在迭代時計算 style_score sum( w * sl.loss for sl, w in zip(style_losses, style_layer_weights.values()) )淺層權(quán)重決定筆觸顆粒度權(quán)重大了筆觸更碎、紋理更細權(quán)重小了畫面會比較平滑深層權(quán)重決定整體色調(diào)布局權(quán)重大了畫面會更接近畫家的大色塊關(guān)系但容易丟結(jié)構(gòu)。我做梵高風時會把conv_1權(quán)重稍微調(diào)大一點為了讓螺旋筆觸更細碎做莫奈風時則把conv_1壓到0.5以下避免把畫面搞成噪點。4.3 風格插值與多風格融合第三個開關(guān)是風格插值。把兩張風格圖的Gram矩陣做線性混合完全可以得到一種中間風格。比如0.7的梵高加上0.3的莫奈能出一張“筆觸是梵高的、色調(diào)是莫奈的”畫面我自己試下來經(jīng)常有驚喜。實現(xiàn)方式非常樸素# 假設(shè) G_vangogh 和 G_monet 是對應(yīng)層的 Gram 矩陣 G_mixed 0.7 * G_vangogh 0.3 * G_monet然后再在StyleLoss里用G_mixed當目標。你也可以不給每個層都配同一個混合比比如淺層偏梵高、深層偏莫奈這樣能玩出更細的進度控制。不少現(xiàn)成工具里的“風格強度”滑塊底層就是這種邏輯理解了就知道它不是魔法只是一個權(quán)重插值。4.4 后處理三板斧色彩匹配、去噪、超分神經(jīng)風格遷移輸出圖往往還有三個毛病色調(diào)有時和原圖亮度分布不匹配、局部會有偽影、分辨率偏小。我的落地流程里一定會加三步后處理直方圖匹配將輸出圖的RGB或Lab通道直方圖對齊到原圖恢復(fù)真實光影關(guān)系讓風格化后的畫面更自然。非局部均值或雙邊濾波去噪處理高風格權(quán)重下出現(xiàn)的細小噪點。輕量超分把512或768的結(jié)果放大到1080p以上讓人物和建筑細節(jié)更利落。超分模型有很多現(xiàn)成選擇不必自己訓。這三步不需要改變訓練代碼但能明顯提升出圖的“成品感”。項目中時間允許的話我還會做一次局部對比度增強提高畫面通透度。5. 從單張優(yōu)化到批量落地這才是實戰(zhàn)和Demo的分水嶺單張風格遷移做完你會覺得自己已經(jīng)會了。但真要讓它變成可交付的東西比如給一批照片批量出圖或者給一段視頻做風格化中間又會冒出大量新問題。這個環(huán)節(jié)是我踩坑最多的部分。5.1 批處理流程顯存、緩存與失敗重試批量處理照片時第一個問題是顯存占用。512×512輸入大概需要2到4GB顯存768×768就要8GB以上。大批量處理時不能把所有圖一次性載入我是按隊列逐張讀取、逐張優(yōu)化、逐張保存的。每張圖跑300步L-BFGS在有GPU情況下大約20到50秒一張CPU可能要三到十分鐘。其次是失敗重試機制。風格圖不是對所有內(nèi)容照片都友好比如一張光照復(fù)雜的夜景照片強制套用莫奈風格可能出現(xiàn)色階斷層。我現(xiàn)在的做法是每張輸出圖自動檢查像素值方差和局部梯度異常的直接降級為低風格權(quán)重重跑一遍而不是讓整條流水線中斷。這種小細節(jié)在批量項目里比調(diào)參更值錢。5.2 視頻風格化為什么會閃我試過的破解方向把視頻逐幀丟進同樣的流程你會得到一個非常難受的結(jié)論畫面閃爍。原因很直接相鄰兩幀是獨立優(yōu)化的即使內(nèi)容差異很小優(yōu)化路徑也會讓風格紋理出現(xiàn)隨機分布。人眼看著就是背景在微微蠕動。我試過幾個破解方向。最省事的方案是先用內(nèi)容照片的中間幀作為初始化再順帶把前一幀輸出做輕微對齊后作為當前幀的起點這樣能壓住一部分明顯閃爍。更穩(wěn)的是引入光流約束讓相鄰幀的風格紋理隨光流做時間一致遷移。還有一條路線是用擴散模型類方案速度和質(zhì)量都更好但顯存和時間代價不是每臺機器都扛得住。如果你只是做短視頻片段我的建議是跳過逐幀優(yōu)化直接生成關(guān)鍵幀后做補間性價比最高。5.3 自己訓練神經(jīng)風格遷移還是用整合工具的取舍說到落地必然要面對一個選擇是保留這套自己控制的手寫流程還是用現(xiàn)成的整合型工具。我的判斷標準是看需求數(shù)量級和靈活度。需要一次性產(chǎn)出幾十上百張圖、要精確控制每種風格的權(quán)重配比或者要處理某種特殊畫風時手寫這套流程更有價值因為權(quán)重的每一個調(diào)整你都能立刻對應(yīng)到畫面變化。但如果只是偶爾玩幾張已經(jīng)有大量集成了AdaIN、擴散模型的成熟工具它們處理常見風格幾乎都是秒級出圖效果還更穩(wěn)定。缺點是風格調(diào)節(jié)的自由度往往有限遇到不支持的畫風就無解。所謂實戰(zhàn)不是非要從零手寫一切而是你知道自己在用什么、想要調(diào)什么、出了問題知道從哪個環(huán)節(jié)下手。這個判斷力才是“手寫一遍神經(jīng)風格遷移”給我留下的最大收獲。最后再分享一個我自己的習慣不要一開始就在1024分辨率上折騰。先用256或384把風格方向和權(quán)重配比試出來確認畫面構(gòu)圖、筆觸感、色調(diào)都對了再放到512甚至1024精調(diào)。這樣一輪試探的成本能縮短到原來的五分之一。后面拿著這個流程去處理莫奈那種偏柔和的風格時你會發(fā)現(xiàn)低分辨率下看似糊成一團的結(jié)果放大后反而會呈現(xiàn)出非常舒服的氣韻。風格遷移這個東西調(diào)參階段永遠是一張圖一張圖磨出來的而不是靠某個萬能公式一步到位。