深度學(xué)習(xí)框架PyTorch入門與實踐:第八章 AI藝術(shù)家:神經(jīng)網(wǎng)絡(luò)風(fēng)格遷移

本章我們將介紹一個酷炫的深度學(xué)習(xí)應(yīng)用——風(fēng)格遷移(Style Transfer)。近年來,由深度學(xué)習(xí)引領(lǐng)的人工智能技術(shù)浪潮越來越廣泛地應(yīng)用到社會各個領(lǐng)域播歼。這其中,手機(jī)應(yīng)用Prisma掰读,嘗試為用戶的照片生成名畫效果秘狞,一經(jīng)推出就吸引了海量用戶叭莫,登頂App Store下載排行榜。這神奇背后的核心技術(shù)就是基于深度學(xué)習(xí)的圖像風(fēng)格遷移烁试。

風(fēng)格遷移又稱風(fēng)格轉(zhuǎn)換食寡,直觀點的類比就是給輸入的圖像加個濾鏡,但是又不同于傳統(tǒng)濾鏡廓潜。風(fēng)格遷移基于人工智能,每個風(fēng)格都是由真正的藝術(shù)家作品訓(xùn)練善榛、創(chuàng)作而成辩蛋。只需要給定原始圖片,并選擇藝術(shù)家的風(fēng)格圖片移盆,就能把原始圖片轉(zhuǎn)化成具有相應(yīng)藝術(shù)家風(fēng)格的圖片悼院。如下圖所示,給定一張風(fēng)格圖片(左上角咒循,手繪糖果圖)和一張內(nèi)容圖片(右上角据途,斯坦福校園圖),神經(jīng)網(wǎng)絡(luò)能夠生成手繪風(fēng)格的斯坦福校園圖(下圖)叙甸。

image.png

本章我們將一起學(xué)習(xí)風(fēng)格遷移的原理颖医,并利用PyTorch從頭實現(xiàn)一個風(fēng)格遷移的神經(jīng)網(wǎng)絡(luò),來看看人工智能與藝術(shù)的交叉碰撞會產(chǎn)生什么樣的有趣結(jié)果裆蒸。

8.1 風(fēng)格遷移原理介紹

風(fēng)格遷移中有兩類圖片熔萧,一類是風(fēng)格圖片,通常是一些藝術(shù)家的作品僚祷,比較經(jīng)典的有梵高的《星月夜》《向日葵》佛致,畢加索的《A muse》,莫奈的《印象-日出》辙谜,日本浮世繪的《神奈川沖浪里》等俺榆,這些圖片往往具有比較明顯的藝術(shù)家風(fēng)格,包括色彩装哆、線條罐脊、輪廓等;另一類是內(nèi)容圖片蜕琴,這些圖片通常來自現(xiàn)實世界中爹殊,例如用戶個人攝影。利用風(fēng)格遷移能夠?qū)?nèi)容圖片轉(zhuǎn)換成具有藝術(shù)家風(fēng)格的圖片奸绷。

2015年梗夸,來自德國圖賓根大學(xué)(University of Tubingen)Bethge實驗室的三位研究院萊昂-蓋提斯(Leon Gatys)、亞歷山大-昂抛恚克(Alexander Ecker)和馬蒂亞斯-貝特格(Matthias Bethge)研發(fā)了一種算法反症,模擬人類視覺的處理方式辛块,通過訓(xùn)練多層卷積神經(jīng)網(wǎng)絡(luò)(CNN),讓計算機(jī)識別并學(xué)會了梵高的“風(fēng)格”铅碍,然后將任何一張普通的照片變成梵高的《星空》润绵。2015年,他們的發(fā)現(xiàn)被整理成兩篇論文《A Neural Algorithm of Artistic Style》《Texture Synthesis Using Convolutional Neural Networks》胞谈,引起了學(xué)術(shù)界和工業(yè)界的極大興趣尘盼。

Gatys等人提出的方法稱為Neural Style,然而他們的做法在實現(xiàn)上過于復(fù)雜烦绳,每次進(jìn)行風(fēng)格遷移都需要幾十分鐘甚至幾個小時的訓(xùn)練卿捎。斯坦福博士生Justin Johnson于2016年在ECCV上發(fā)表論文《Perceptual Losses for Real-Time Style Transfer and Super-Resolution》,提出了一種快速實現(xiàn)風(fēng)格遷移的算法径密,這種方法通常被稱為Fast Neural Style午阵。當(dāng)用Fast Neural Style訓(xùn)練好某一個風(fēng)格的模型之后,通常只需要GPU運行幾秒享扔,就能生成對應(yīng)的風(fēng)格遷移結(jié)果底桂。本章中介紹的主要是基于Justin Johnson的Fast Neural Style方法,即快速風(fēng)格遷移惧眠。

Fast Neural Style和Neural Style主要有以下兩點區(qū)別籽懦。

(1)Fast Neural Style針對每一個風(fēng)格圖片訓(xùn)練一個模型(在GPU上運行大概4個小時),而后可以反復(fù)利用氛魁,進(jìn)行快速風(fēng)格遷移(幾秒到20秒)猫十。Neural Style不需要專門訓(xùn)練模型,只需要從噪聲中不斷地調(diào)整圖像的像素值呆盖,直到最后得到結(jié)果拖云,速度較慢,需要十幾分鐘到幾十分鐘不等应又。

(2)普遍認(rèn)為Neural Style生成的圖片效果會比Fast Neural Style的效果好宙项。

關(guān)于Neural Style的實現(xiàn),可以參考PyTorch官方的Tutorial中的教程株扛,實現(xiàn)也比較簡單尤筐。本節(jié)主要介紹Fast Neural Style的實現(xiàn)。

要產(chǎn)生效果逼真的風(fēng)格遷移的圖片有兩個要求洞就。一是要生成的圖片在內(nèi)容盆繁、細(xì)節(jié)上盡可能地與輸入的內(nèi)容圖片相似;二是要生成的圖片在風(fēng)格上盡可能地與風(fēng)格圖片相似旬蟋。相應(yīng)地油昂,我們定義兩個損失content loss和style loss,分別用來衡量上述兩個指標(biāo)。

圖像的內(nèi)容和風(fēng)格含義廣泛冕碟,并且沒有嚴(yán)格統(tǒng)一的數(shù)學(xué)定義拦惋,具有很大程度上的主觀性,因此很難表示安寺。content loss比較常用的做法是采用逐像素計算差值厕妖,又稱pixel-wise loss,追求生成的圖片和原始圖片逐像素的差值盡可能小挑庶。這種做法有諸多不合理的地方言秸,Justin在論文中提出了一種更好的計算content loss的方法:perceptual loss。不同于pixel-wise loss計算像素層面的差異迎捺,perceptual loss計算的是圖像在更高語義層次上的差異举畸,論文中使用預(yù)訓(xùn)練好的神經(jīng)網(wǎng)絡(luò)的高層輸入作為圖片的知覺特征,進(jìn)而計算二者的差異值作為perceptual loss破加。

深度學(xué)習(xí)之所以被稱為“深度”,就在于它采用了深層的網(wǎng)絡(luò)結(jié)構(gòu)雹嗦,網(wǎng)絡(luò)的不同層學(xué)到的是圖像不同層面的特征信息范舀。深度學(xué)習(xí)網(wǎng)絡(luò)的輸入是像素信息,也可以認(rèn)為是點了罪,研究表明锭环,幾乎所有神經(jīng)網(wǎng)絡(luò)的第一層學(xué)習(xí)到的都是關(guān)于線條和顏色的信息,直觀理解就是像素組成色彩泊藕,點組成線辅辩,這與人眼的感知特征十分相像。再往上娃圆,神經(jīng)網(wǎng)絡(luò)開始關(guān)注一些復(fù)雜的特征玫锋,例如拐角或者某些特殊的形狀,這些特征可以看成是低層次的特征組合讼呢。隨著深度的加深撩鹿,神經(jīng)網(wǎng)絡(luò)關(guān)注的信息逐漸抽象,例如有些卷積核關(guān)注的是這張圖中有個鼻子悦屏,或者是圖中有張人臉节沦,以及對象之間的空間關(guān)系,例如鼻子在人臉的中間等础爬。

在進(jìn)行風(fēng)格遷移時甫贯,我們并不要求生成圖片的像素和原始圖片中的每一個像素都一樣,我們追求的是生成圖片和原始圖片具有相同的特征:例如原圖中有只貓看蚜,我們希望風(fēng)格遷移之后的圖片依舊有貓叫搁。圖片中“有貓”這個概念不就是我們分類問題最后一層的輸出嗎?但最后一層的特征對我們來說抽象程度太高,因為我們不僅希望圖片中有只貓常熙,還希望保存這只貓的部分細(xì)節(jié)信息纬乍,例如它的形狀、動作等信息裸卫,這些信息相對來說沒有那么高的層次仿贬。因此我們使用中間某些層的特征作為目標(biāo),希望原圖像和風(fēng)格遷移的結(jié)果在這些層輸出的結(jié)果盡可能相似墓贿,即將圖片在深度模型的中間某些層的輸出作為圖像的知覺特征茧泪。

我們一般使用Gram矩陣來表示圖像的風(fēng)格特征。對于每一張圖片聋袋,卷積層的輸出形狀為C \times H \times W队伟,C是卷積核的通道數(shù),一般稱為有C個卷積核幽勒,每個卷積核學(xué)習(xí)圖像的不同特征嗜侮。每一個卷積核輸出的H \times W代表這張圖像的一個feature map,可以認(rèn)為是一張?zhí)厥獾膱D像——原始彩色圖像可以看作RGB三個feature map拼接組合成的特殊feature maps啥容。通過計算每個feature map之間的相似性锈颗,我們可以得到圖像的風(fēng)格特征。對于一個C \times H \times W的feature maps F咪惠,Gram Matrix的形狀為C \times C击吱,其第ij個元素G_{i,j}的計算方式定義如下:

G_{i,j}=\sum_k F_{ik}F_{jk}

其中F_{ik}代表第i個feature map的第k個像素點遥昧。關(guān)于Gram Matrix覆醇,以下三點值得注意:

  • Gram Matrix的計算采用了累加的形式,拋棄了空間信息炭臭。一張圖片的像素隨機(jī)打亂之后計算得到的Gram Matrix和原始Gram Matrix一樣永脓。所以可以認(rèn)為Gram Matrix拋棄了元素之間的空間信息。
  • Gram Matrix的結(jié)果與feature maps F的尺度無關(guān)鞋仍,只與通道數(shù)有關(guān)憨奸。無論HW的大小如何凿试,最后Gram Matrix的形狀都是C \times C排宰。
  • 對于一個C \times H \times W的feature maps,可以通過調(diào)整形狀和矩陣乘法快速計算它的Gram Matrix那婉,即先將F調(diào)整為C \times (HW)的二維矩陣板甘,然后再計算F \cdot F^T,結(jié)果計算Gram Matrix详炬。

下圖展現(xiàn)了Gram Matrix的特點:注重風(fēng)格紋理等特征盐类,忽略空間信息寞奸。圖中第一行是輸入的原圖片,經(jīng)過神經(jīng)網(wǎng)絡(luò)計算出不同層的Gram Matrix在跳,然后嘗試從這些層的Gram Matrix恢復(fù)出原圖枪萄,換一種角度說,我們可以認(rèn)為每一列的圖像的Gram Matrix值都很接近猫妙。我們可以明顯地看出瓷翻,無論恢復(fù)的圖像清晰度如何,圖像的空間信息在計算Gram Matrix時都被舍棄割坠,但是紋理齐帚、色彩等風(fēng)格信息被保存下來。

image.png

實踐證明利用Gram Matrix表征圖像的風(fēng)格特征在風(fēng)格遷移彼哼、紋理合成等任務(wù)中的表現(xiàn)十分出眾对妄。

總結(jié)如下。

  • 神經(jīng)網(wǎng)絡(luò)的高層輸出可以作為圖像的知覺特征描述敢朱。
  • 神經(jīng)網(wǎng)絡(luò)的高層輸出的Gram Matrix可以作為圖像的風(fēng)格特征描述剪菱。
  • 風(fēng)格遷移的目標(biāo)是使生成圖片和原圖片的知覺特征盡可能相似,并且和風(fēng)格圖片的風(fēng)格特征盡可能地相似拴签。

在最初的Neural Style論文中孝常,隨機(jī)初始化目標(biāo)圖片為噪聲,然后利用梯度下降法調(diào)整圖片篓吁,使目標(biāo)圖片和風(fēng)格圖片的風(fēng)格特征(即Gram Matrix)盡可能地相似茫因,和原圖片的知覺特征也盡可能地相似蚪拦。這種做法生成的圖片效果很好杖剪,但其十分耗時!每次都需要從一個噪聲開始調(diào)整圖片驰贷,直到得到最終的目標(biāo)圖片盛嘿,在GPU上完成一次風(fēng)格遷移需要十幾分鐘甚至數(shù)小時。

2016年Justin Johnson提出了一種快速風(fēng)格遷移算法括袒,這種算法被稱為Fast Neural Style或Fast Style Transfer次兆。與Neural Style相比,F(xiàn)ast Neural Style專門設(shè)計了一個網(wǎng)絡(luò)用來進(jìn)行風(fēng)格遷移锹锰,輸入原圖片芥炭,網(wǎng)絡(luò)自動生成目標(biāo)圖片。這個網(wǎng)絡(luò)需要針對每一種風(fēng)格圖片馴良一個相對應(yīng)的風(fēng)格網(wǎng)絡(luò)恃慧,但是一旦訓(xùn)練完成园蝠,便只需要20秒甚至更短的時間就能完成一次風(fēng)格遷移。

Fast Neural Style的網(wǎng)絡(luò)結(jié)構(gòu)如下所示痢士。x是輸入圖像彪薛,在風(fēng)格遷移任務(wù)中y_c=xy_s是風(fēng)格圖片,Image Transform Net fw是我們設(shè)計的風(fēng)格遷移網(wǎng)絡(luò)善延,針對輸入的圖像x少态,能夠返回一張新的圖像\hat y\hat y在圖像內(nèi)容上與y_c相似易遣,但在風(fēng)格上與y_s相似彼妻。損失網(wǎng)絡(luò)(Loss Network)不用訓(xùn)練,只是用來計算知覺特征和風(fēng)格特征训挡。在論文中損失網(wǎng)絡(luò)采用在ImageNet上預(yù)訓(xùn)練好的VGG-16澳骤。

image.png

VGG-16的網(wǎng)絡(luò)結(jié)構(gòu)如下所示(第D列)。網(wǎng)絡(luò)從左到右有5個卷積塊澜薄,兩個卷積塊之間通過MaxPooling層區(qū)分为肮。每個卷積塊有2~3個卷積層,每一個卷積層后面都跟著一個ReLU激活層肤京。上面的relu2_2表示第2個卷積塊的第2個卷積層的激活層(ReLU)輸出颊艳。

image.png
image.png

Fast Neural Style的訓(xùn)練步驟如下。

(1)輸入一張圖片xfw中得到結(jié)果\hat y忘分。
(2)將\hat yy_c(其實就是x)輸入到loss network(VGG-16)中棋枕,計算它在relu3_3的輸出,并計算它們之間的均方誤差作為content loss妒峦。
(3)將\hat yy_s(風(fēng)格圖片)輸入到loss network中重斑,計算它在relu1_2、relu2_2肯骇、relu3_3和relu4_3的輸出窥浪,再計算它們的Gram Matrix的均方誤差作為style loss。
(4)兩個損失相加笛丙,并反向傳播漾脂。更新fw參數(shù),固定loss network不動胚鸯。
(5)跳回第一步骨稿,繼續(xù)訓(xùn)練fw

在講解如何用PyTorch實現(xiàn)風(fēng)格遷移之前姜钳,我們先來了解全卷積網(wǎng)絡(luò)的結(jié)構(gòu)坦冠。我們知道風(fēng)格遷移網(wǎng)絡(luò)的輸入是圖片,輸出也是圖片哥桥,對這種網(wǎng)絡(luò)我們一般實現(xiàn)為一個全部都是卷積層而沒有全連接層的網(wǎng)絡(luò)結(jié)構(gòu)辙浑。對于卷積層,當(dāng)輸入feature maps(或者圖片)的尺寸為C_{in} \times H_{in} \times W_{in}泰讽,卷積核有C_{out}個例衍,卷積核尺寸為K昔期,padding大小為P,步長為S時佛玄,輸出feature maps的形狀為C_{out} \tiimes H_{out} \times W_{out}硼一,其中

H_{out} = floor(H_{in} + 2 * P - K) / S + 1

W_{out} = floor(W_{in} + 2 * P - K) / S + 1

舉例來說,如果我們輸入的圖片尺寸是3 x 256 x 256梦抢,第一層卷積的卷積核大小為3般贼,padding為1,步長為2奥吩,通道數(shù)為128哼蛆,那么輸出的feature maps形狀,按照上述公式計算的結(jié)果就是:

H_{out} = floor(256 + 2 * 1 - 3) / 2 + 1 = 128

W_{out} = floor(256 + 2 * 1 - 3) / 2 + 1 = 128

所以最后的輸出是C_{out} \times H_{out} \times W_{out} = 128 \times 128 \times 128霞赫,即尺寸縮小一倍腮介,通道數(shù)增加。如果把步長由2改成1端衰,則輸出的形狀就是128 x 256 x 256叠洗,即尺寸不變,只是通道數(shù)增加旅东。

除卷積層之外灭抑,還有一種叫做轉(zhuǎn)置卷積層(Transposed Convolution),也有人稱之為反卷積(DeConvolution)抵代,它可以看成是卷積操作的逆運算腾节。對于卷積操作,當(dāng)步長大于1時荤牍,執(zhí)行的是類似于上采樣的操作案腺。全卷積網(wǎng)絡(luò)的一個重要優(yōu)勢在于對輸入的尺寸沒有要求,這樣在進(jìn)行風(fēng)格遷移時就能夠接受不同分辨率的圖片参淫。

論文中提到的風(fēng)格遷移結(jié)構(gòu)全部由卷積層救湖、Batch Normalization和激活層組成愧杯,不包含全連接層涎才。在這里我們不使用Batch Normalization,取而代之的是Instance Normalization力九。Instance Normalization和Batch Normalization的唯一區(qū)別就在于InstanceNorm只對每一個樣本求均值和方差耍铜,而BatchNorm則會對一個batch中所有的樣本求均值。例如對于一個B \times C \times H \times W的tensor跌前,在Batch Normalization中計算均值時棕兼,就會計算B \times H \times W個數(shù)的均值,共有C個均值抵乓;而Instance Normalization會計算H \times W個數(shù)的均值伴挚,即共有B \times C個均值靶衍。在Dmitry Ulyanov的論文《Instance Normalization:The Missing Ingredient for Fast Stylization》中提到過,用InstanceNorm代替BatchNorm能顯著地提升風(fēng)格遷移的效果茎芋。

8.2 用PyTorch實現(xiàn)風(fēng)格遷移

本章所有代碼和圖片數(shù)據(jù):百度網(wǎng)盤下載颅眶,提取碼:nyf9。

我們先來看看本次實驗的文件組織:

checkpoints/
data/coco/
main.py
PackedVGG.py
style.jpg
transformer_net.py
utils.py

上述各個文件的主要內(nèi)容和作用如下田弥。

  • checkpoints/:用來保存模型涛酗。
  • data/:用于保存數(shù)據(jù),可以把數(shù)據(jù)直接保存于coco文件夾下偷厦。
  • main.py:主函數(shù),包括訓(xùn)練和測試。
  • PackedVGG.py:預(yù)訓(xùn)練好的VGG-16窟却,為了提取中間層的輸出软驰,所以做了一些修改簡化。
  • transformer_net.py:風(fēng)格遷移網(wǎng)絡(luò)请唱。輸入一張圖片枯途,輸出一張圖片。
  • utils.py:工具集合籍滴,主要是可視化工具visdom的封裝和計算Gram Matrix等酪夷。

首先來看看如何使用預(yù)訓(xùn)練的VGG,這部分代碼保存在PackedVGG.py中孽惰。在torchvision的倉庫中有預(yù)訓(xùn)練好的VGG-16網(wǎng)絡(luò)晚岭,使用十分方便,但在風(fēng)格遷移網(wǎng)絡(luò)中勋功,我們需要獲得中間層的輸出坦报,因此需要修改網(wǎng)絡(luò)的前向傳播過程,將相應(yīng)層的輸出保存下來狂鞋。同時有很層不再需要片择,可刪除以節(jié)省內(nèi)存占用。實現(xiàn)的代碼如下骚揍。

# coding:utf8
import torch
import torch.nn as nn
from torchvision.models import vgg16
from collections import namedtuple


class Vgg16(torch.nn.Module):
    def __init__(self):
        super(Vgg16, self).__init__()
        features = list(vgg16(pretrained=True).features)[:23]
        # features的第3字管,8,15信不,22層分別是: relu1_2,relu2_2,relu3_3,relu4_3
        self.features = nn.ModuleList(features).eval()

    def forward(self, x):
        results = []
        for ii, model in enumerate(self.features):
            x = model(x)
            if ii in {3, 8, 15, 22}:
                results.append(x)

        vgg_outputs = namedtuple("VggOutputs", ['relu1_2', 'relu2_2', 'relu3_3', 'relu4_3'])
        return vgg_outputs(*results)

在torchvision中嘲叔,VGG的實現(xiàn)由兩個nn.Sequential對象組成,第一個是features抽活,包含卷積硫戈、激活和MaxPool等層,用來提取圖片特征下硕,另一個是classifier丁逝,包含全連接層等汁胆,用來分類。用以通過vgg.features直接獲得對應(yīng)的nn.Sequential對象霜幼。這樣在前向傳播時沦泌,當(dāng)計算完指定層的輸出后,將結(jié)果保存于一個list中辛掠,然后再使用namedtuple進(jìn)行名稱綁定谢谦,這樣可以通過output.relu1_2訪問第一個元素,更為方便和直觀萝衩。當(dāng)然也可以利用layer.register_forward_hook的方式獲取相應(yīng)層的輸出回挽,但是在本例中相對比較麻煩。

接下來要實現(xiàn)的是風(fēng)格遷移網(wǎng)絡(luò)猩谊,其代碼在transformer_net.py中千劈,實現(xiàn)時參考了PyTorch的官方示例,其網(wǎng)絡(luò)結(jié)構(gòu)如下圖所示牌捷。

image.png

圖中(b)是網(wǎng)絡(luò)的總體結(jié)構(gòu)墙牌,左邊(d)是一個殘差單元的結(jié)構(gòu)圖,右邊(c)和(d)分別是下采樣和上采樣單元的結(jié)構(gòu)圖暗甥。網(wǎng)絡(luò)結(jié)構(gòu)總結(jié)起來有以下幾個特點喜滨。

  • 先下采樣,然后上采樣撤防,這種做法使計算量變小虽风,詳細(xì)說明請參考論文。
  • 使用殘差結(jié)構(gòu)使網(wǎng)絡(luò)變深寄月。
  • 邊緣補(bǔ)齊的方式不再是傳統(tǒng)的補(bǔ)0辜膝,而是采用一種被稱為Reflection Pad的補(bǔ)齊策略:上下左右反射邊緣的像素進(jìn)行補(bǔ)齊。
  • 上采樣不再是使用傳統(tǒng)的ConvTranspose2d漾肮,而是先用Upsample厂抖,然后用Conv2d,這種做法能避免Checkboard Artifacts現(xiàn)象克懊。
  • Batch Normalization全部改成Instance Normalization忱辅。
  • 網(wǎng)絡(luò)中沒有全連接層,線性 操作是卷積保檐。因此對輸入和輸出圖片的尺寸沒有要求耕蝉,這里我們輸入和輸出圖片的尺寸都是3 * 256 * 256(其他尺寸的一樣可以)崔梗。

在第6章中我們提到過夜只,對于常見的網(wǎng)絡(luò)結(jié)構(gòu),可以實現(xiàn)為nn.Module對象蒜魄,作為一個特殊的層扔亥。在本例中场躯,Conv、UpConv和Residual Block都可以如此實現(xiàn)旅挤。

例如Conv踢关,可以實現(xiàn)為:

class ConvLayer(nn.Module):
    """
    add ReflectionPad for Conv
    默認(rèn)的卷積的padding操作是補(bǔ)0,這里使用邊界反射填充
    """

    def __init__(self, in_channels, out_channels, kernel_size, stride):
        super(ConvLayer, self).__init__()
        reflection_padding = int(np.floor(kernel_size / 2))
        self.reflection_pad = nn.ReflectionPad2d(reflection_padding)
        self.conv2d = nn.Conv2d(in_channels, out_channels, kernel_size, stride)

    def forward(self, x):
        out = self.reflection_pad(x)
        out = self.conv2d(out)
        return out

UpConv和Residual Block的實現(xiàn)也是類似的粘茄,這里不再細(xì)說签舞,具體內(nèi)容請看本章配套代碼。

主模型主要包含三個部分:下采樣的卷積層柒瓣、深度殘差層和上采樣的卷積層儒搭。實現(xiàn)時充分利用了nn.Sequential,避免在forward中重復(fù)寫代碼芙贫,其代碼如下搂鲫。

class TransformerNet(nn.Module):
    def __init__(self):
        super(TransformerNet, self).__init__()

        # 下卷積層
        self.initial_layers = nn.Sequential(
            ConvLayer(3, 32, kernel_size=9, stride=1),
            nn.InstanceNorm2d(32, affine=True),
            nn.ReLU(True),
            ConvLayer(32, 64, kernel_size=3, stride=2),
            nn.InstanceNorm2d(64, affine=True),
            nn.ReLU(True),
            ConvLayer(64, 128, kernel_size=3, stride=2),
            nn.InstanceNorm2d(128, affine=True),
            nn.ReLU(True),
        )

        # Residual layers(殘差層)
        self.res_layers = nn.Sequential(
            ResidualBlock(128),
            ResidualBlock(128),
            ResidualBlock(128),
            ResidualBlock(128),
            ResidualBlock(128)
        )

        # Upsampling Layers(上卷積層)
        self.upsample_layers = nn.Sequential(
            UpsampleConvLayer(128, 64, kernel_size=3, stride=1, upsample=2),
            nn.InstanceNorm2d(64, affine=True),
            nn.ReLU(True),
            UpsampleConvLayer(64, 32, kernel_size=3, stride=1, upsample=2),
            nn.InstanceNorm2d(32, affine=True),
            nn.ReLU(True),
            ConvLayer(32, 3, kernel_size=9, stride=1)
        )

    def forward(self, x):
        x = self.initial_layers(x)
        x = self.res_layers(x)
        x = self.upsample_layers(x)
        return x

搭建完網(wǎng)絡(luò)之后,我們還要實現(xiàn)一些工具函數(shù)磺平,這部分的代碼保存于utils.py中魂仍。代碼如下:

# coding:utf8
from itertools import chain
import visdom
import torch as t
import time
import torchvision as tv
import numpy as np

IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]


def gram_matrix(y):
    """
    輸入 b,c,h,w
    輸出 b,c,c
    """
    (b, ch, h, w) = y.size()
    features = y.view(b, ch, w * h)
    features_t = features.transpose(1, 2)
    gram = features.bmm(features_t) / (ch * h * w)
    return gram


class Visualizer():
    """
    封裝了visdom的基本操作,但是你仍然可以通過`self.vis.function`
    調(diào)用原生的visdom接口
    """

    def __init__(self, env='default', **kwargs):
        import visdom
        self.vis = visdom.Visdom(env=env, use_incoming_socket=False, **kwargs)

        # 畫的第幾個數(shù)拣挪,相當(dāng)于橫座標(biāo)
        # 保存(’loss',23) 即loss的第23個點
        self.index = {}
        self.log_text = ''

    def reinit(self, env='default', **kwargs):
        """
        修改visdom的配置
        """
        self.vis = visdom.Visdom(env=env,use_incoming_socket=False,  **kwargs)
        return self

    def plot_many(self, d):
        """
        一次plot多個
        @params d: dict (name,value) i.e. ('loss',0.11)
        """
        for k, v in d.items():
            self.plot(k, v)

    def img_many(self, d):
        for k, v in d.items():
            self.img(k, v)

    def plot(self, name, y):
        """
        self.plot('loss',1.00)
        """
        x = self.index.get(name, 0)
        self.vis.line(Y=np.array([y]), X=np.array([x]),
                      win=name,
                      opts=dict(title=name),
                      update=None if x == 0 else 'append'
                      )
        self.index[name] = x + 1

    def img(self, name, img_):
        """
        self.img('input_img',t.Tensor(64,64))
        """

        if len(img_.size()) < 3:
            img_ = img_.cpu().unsqueeze(0)
        self.vis.image(img_.cpu(),
                       win=name,
                       opts=dict(title=name)
                       )

    def img_grid_many(self, d):
        for k, v in d.items():
            self.img_grid(k, v)

    def img_grid(self, name, input_3d):
        """
        一個batch的圖片轉(zhuǎn)成一個網(wǎng)格圖擦酌,i.e. input(36,64菠劝,64)
        會變成 6*6 的網(wǎng)格圖仑氛,每個格子大小64*64
        """
        self.img(name, tv.utils.make_grid(
            input_3d.cpu()[0].unsqueeze(1).clamp(max=1, min=0)))

    def log(self, info, win='log_text'):
        """
        self.log({'loss':1,'lr':0.0001})
        """

        self.log_text += ('[{time}] {info} <br>'.format(
            time=time.strftime('%m%d_%H%M%S'),
            info=info))
        self.vis.text(self.log_text, win=win)

    def __getattr__(self, name):
        return getattr(self.vis, name)


def get_style_data(path):
    """
    加載風(fēng)格圖片,
    輸入: path闸英, 文件路徑
    返回: 形狀 1*c*h*w锯岖, 分布 -2~2
    """
    style_transform = tv.transforms.Compose([
        tv.transforms.ToTensor(),
        tv.transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ])

    style_image = tv.datasets.folder.default_loader(path)
    style_tensor = style_transform(style_image)
    return style_tensor.unsqueeze(0)


def normalize_batch(batch):
    """
    輸入: b,ch,h,w  0~255
    輸出: b,ch,h,w  -2~2
    """
    mean = batch.data.new(IMAGENET_MEAN).view(1, -1, 1, 1)
    std = batch.data.new(IMAGENET_STD).view(1, -1, 1, 1)
    mean = (mean.expand_as(batch.data))
    std = (std.expand_as(batch.data))
    return (batch / 255.0 - mean) / std

可以看出utils.py中主要包含以下三個功能。

  • 計算Gram Matrix:利用矩陣轉(zhuǎn)置的乘法即可實現(xiàn)甫何,但是這里我們要對batch中的每一個樣本計算Gram Matrix出吹,因此用的是tensor.bmm(tensor2),而不是tensor.mm(tensor2)辙喂。
  • 獲取圖片風(fēng)格:根據(jù)文件名讀取圖片捶牢,并將它轉(zhuǎn)化成tensor。這里圖片的均值和標(biāo)準(zhǔn)差不是0.5和0.5巍耗,而是使用他人專門計算的ImageNet上所有圖片的均值和標(biāo)準(zhǔn)差秋麸,更符合真實世界的圖片的分布。人們發(fā)現(xiàn)按照這個數(shù)值處理的圖片比簡單地使用0.5作為均值和標(biāo)準(zhǔn)差效果更好炬太。
  • 將分布在0 ~ 255的圖片進(jìn)行標(biāo)準(zhǔn)化灸蟆,和上述對風(fēng)格圖片的處理類似,需要注意亲族,這里是針對Variable對象的處理炒考。

除此之外可缚,還有對visdom操作的封裝,這里不再展示斋枢。

當(dāng)我們將上述網(wǎng)絡(luò)定義和工具函數(shù)寫完之后帘靡,就可以開始訓(xùn)練網(wǎng)絡(luò)了。首先來看看在config.py中都有哪些可以配置的參數(shù)瓤帚。

class Config(object):
    image_size = 256  # 圖片大小
    batch_size = 8
    data_root = 'data/'  # 數(shù)據(jù)集存放路徑:data/coco/a.jpg
    num_workers = 4  # 多線程加載數(shù)據(jù)
    use_gpu = True  # 使用GPU

    style_path = 'style.jpg'  # 風(fēng)格圖片存放路徑
    lr = 1e-3  # 學(xué)習(xí)率

    env = 'neural-style'  # visdom env
    plot_every = 10  # 每10個batch可視化一次

    epoches = 2  # 訓(xùn)練epoch

    content_weight = 1e5  # content_loss 的權(quán)重
    style_weight = 1e10  # style_loss的權(quán)重

    model_path = None  # 預(yù)訓(xùn)練模型的路徑
    debug_file = 'debug/debug.txt'  # touch $debug_fie 進(jìn)入調(diào)試模式

    content_path = 'input.png'  # 需要進(jìn)行分割遷移的圖片
    result_path = 'output.png'  # 風(fēng)格遷移結(jié)果的保存路徑

論文中訓(xùn)練用的圖片是MS COCO數(shù)據(jù)集描姚,讀者可以從官網(wǎng)下載。其訓(xùn)練集中包含超過8萬張圖片戈次,超過13GB轰胁。筆者認(rèn)為COCO的數(shù)據(jù)比ImageNet的數(shù)據(jù)更復(fù)雜,更像是日常生活的照片朝扼。讀者如果有ImageNet的圖片赃阀,也一樣可以用。獲取數(shù)據(jù)之后擎颖,將數(shù)據(jù)解壓到data/coco/文件夾下榛斯,或放到任意位置,然后在訓(xùn)練時指定對應(yīng)路徑搂捧。

我們可以在main.py中直接利用ImageFolder和DataLoader加載數(shù)據(jù)驮俗。

    # 數(shù)據(jù)加載
    transfroms = tv.transforms.Compose([
        tv.transforms.Resize(opt.image_size),
        tv.transforms.CenterCrop(opt.image_size),
        tv.transforms.ToTensor(),
        tv.transforms.Lambda(lambda x: x * 255)
    ])
    dataset = tv.datasets.ImageFolder(opt.data_root, transfroms)
    dataloader = data.DataLoader(dataset, opt.batch_size)

    # 轉(zhuǎn)換網(wǎng)絡(luò)
    transformer = TransformerNet()
    if opt.model_path:
        transformer.load_state_dict(t.load(opt.model_path, map_location=lambda _s, _: _s))
    transformer.to(device)

    # 損失網(wǎng)絡(luò) Vgg16
    vgg = Vgg16().eval()
    vgg.to(device)
    for param in vgg.parameters():
        param.requires_grad = False

    # 優(yōu)化器
    optimizer = t.optim.Adam(transformer.parameters(), opt.lr)

    # 獲取風(fēng)格圖片的數(shù)據(jù)
    style = utils.get_style_data(opt.style_path)
    vis.img('style', (style.data[0] * 0.225 + 0.45).clamp(min=0, max=1))
    style = style.to(device)


    # 風(fēng)格圖片的gram矩陣
    with t.no_grad():
        features_style = vgg(style)
        gram_style = [utils.gram_matrix(y) for y in features_style]

    # 損失統(tǒng)計
    style_meter = tnt.meter.AverageValueMeter()
    content_meter = tnt.meter.AverageValueMeter()

訓(xùn)練步驟在8.1節(jié)已經(jīng)講過,按照訓(xùn)練步驟允跑,很容易寫出如下訓(xùn)練代碼:

    for epoch in range(opt.epoches):
        content_meter.reset()
        style_meter.reset()

        for ii, (x, _) in tqdm.tqdm(enumerate(dataloader)):

            # 訓(xùn)練
            optimizer.zero_grad()
            x = x.to(device)
            y = transformer(x)
            y = utils.normalize_batch(y)
            x = utils.normalize_batch(x)
            features_y = vgg(y)
            features_x = vgg(x)

            # content loss
            content_loss = opt.content_weight * F.mse_loss(features_y.relu2_2, features_x.relu2_2)

            # style loss
            style_loss = 0.
            for ft_y, gm_s in zip(features_y, gram_style):
                gram_y = utils.gram_matrix(ft_y)
                style_loss += F.mse_loss(gram_y, gm_s.expand_as(gram_y))
            style_loss *= opt.style_weight

            total_loss = content_loss + style_loss
            total_loss.backward()
            optimizer.step()

完整的代碼請查看本書的配套源碼王凑。這個程序中容易讓人混淆的是圖片的尺度,有時是0 ~ 1聋丝,有時是-2 ~ 2索烹,還有時是0 ~ 255,統(tǒng)一說明如下弱睦。

  • 圖片每個像素的取值范圍為0 ~ 255百姓。
  • 調(diào)用torchvision的transforms.ToTensor()操作,像素會被轉(zhuǎn)換到0 ~ 1况木。
  • 這時如果進(jìn)行標(biāo)準(zhǔn)化(減去均值垒拢、除以標(biāo)準(zhǔn)差),均值和標(biāo)準(zhǔn)差均為0.5火惊,那么標(biāo)準(zhǔn)化之后圖片的分布就是-1 ~ 1求类,但在本次實驗中使用的均值和標(biāo)準(zhǔn)差不是0.5,而是[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]屹耐,這時在ImageNet 100萬張圖片上計算得到的圖片的均值和標(biāo)準(zhǔn)差尸疆,可以估算得知這時圖片的分布范圍大概在\frac{(0-0.485)}{0.229} \approx -2.1\frac{(1-0.406)}{0.225} \approx 2.7之間。盡管這時它的分布在-2.1 ~ 2.7,但是它的均值接近0仓技,標(biāo)準(zhǔn)差接近1鸵贬,采用ImageNet圖片的均值和標(biāo)準(zhǔn)差作為標(biāo)準(zhǔn)化參數(shù)的目的是圖像的各個像素的分布接近標(biāo)準(zhǔn)分布俗他。
  • VGG-16網(wǎng)絡(luò)的輸入圖像數(shù)值大小為使用ImageNet均值和標(biāo)準(zhǔn)差進(jìn)行標(biāo)準(zhǔn)化之后的圖片數(shù)據(jù)脖捻,即-2.1 ~ 2.7。
  • TransformerNet網(wǎng)絡(luò)的輸入圖片的像素值是0 ~ 255兆衅,輸出的像素值也希望是0 ~ 255地沮,但是由于沒有做特殊處理,所以可能出現(xiàn)小于0和大于255的像素羡亩。
  • 使用visdom images進(jìn)行可視化和使用torchvision.utils.save_image保存圖片時摩疑,希望tensor的數(shù)據(jù)位于0 ~ 1。

當(dāng)我們掌握了上述內(nèi)容后畏铆,就不難理解為什么在代碼中時不時地出現(xiàn)各種尺度變換(乘以標(biāo)準(zhǔn)差雷袋、加上均值)和截斷操作。尺度變換是為了從一個尺度變成另一個尺度辞居,截斷是為了確保數(shù)值在一定范圍之內(nèi)(0 ~ 1或者0 ~ 255)楷怒。

除了訓(xùn)練模型,我們還希望能加載預(yù)訓(xùn)練好的模型對指定的圖片進(jìn)行風(fēng)格遷移的操作瓦灶,這部分的代碼實現(xiàn)如下鸠删。

@t.no_grad()
def stylize(**kwargs):
    opt = Config()

    for k_, v_ in kwargs.items():
        setattr(opt, k_, v_)
    device=t.device('cuda') if opt.use_gpu else t.device('cpu')
    
    # 圖片處理
    content_image = tv.datasets.folder.default_loader(opt.content_path)
    content_transform = tv.transforms.Compose([
        tv.transforms.ToTensor(),
        tv.transforms.Lambda(lambda x: x.mul(255))
    ])
    content_image = content_transform(content_image)
    content_image = content_image.unsqueeze(0).to(device).detach()

    # 模型
    style_model = TransformerNet().eval()
    style_model.load_state_dict(t.load(opt.model_path, map_location=lambda _s, _: _s))
    style_model.to(device)

    # 風(fēng)格遷移與保存
    output = style_model(content_image)
    output_data = output.cpu().data[0]
    tv.utils.save_image(((output_data / 255)).clamp(min=0, max=1), opt.result_path)

這樣,我們就可以通過命令行的方式訓(xùn)練贼陶,或者加載預(yù)訓(xùn)練好的模型進(jìn)行風(fēng)格遷移刃泡。

# 訓(xùn)練,使用GPU碉怔,數(shù)據(jù)存放于data/下的一個文件夾中
python main.py train \
                   --use-gpu \
                   --data-root=data \
                   --batch-size=2

# 風(fēng)格遷移烘贴,不使用GPU
python main.py stylize \
             --model-path='transformer.pth' \
             --content-path='amber.jpg' \
             --result-path='output.png' \
             --use-gpu=False

8.3 實驗結(jié)果分析

在本例中我們只訓(xùn)練了一個風(fēng)格的模型,風(fēng)格圖片如下圖所示撮胧。

image.png

風(fēng)格遷移的結(jié)果所下圖所示庙楚。上面一行是原始圖片,下面一行是風(fēng)格遷移的結(jié)果趴樱。

neural-style-results.png

隨書附帶代碼中帶有這個預(yù)訓(xùn)練的模型馒闷,讀者可以用其他圖片查看風(fēng)格遷移的效果,圖片分辨率越高叁征,風(fēng)格遷移的效果越好纳账。另外讀者也可以通過指定--style-path=my_style.png訓(xùn)練不同風(fēng)格的遷移圖片。

例如:我們有一張高清美女圖片如下捺疼,使用自己訓(xùn)練出來的模型遷移風(fēng)格疏虫,效果如下。

python main.py stylize \
--model-path='checkpoints/1_style.pth' \
--content-path='amber.jpg' \
--result-path='output.png' \
--use-gpu=False
amber.jpg
output.png

除了風(fēng)格遷移,類似的應(yīng)用還有Google DeepDream卧秘,可以輸入一張圖片生成神經(jīng)網(wǎng)絡(luò)眼中的這張圖片的樣子呢袱,網(wǎng)絡(luò)越深,生成的圖片包含的奇幻東西越多翅敌,效果如下所示羞福。

image.png
image.png

2017年有兩個比較吸引人的風(fēng)格遷移項目,一個是來自Adobe的圖片風(fēng)格深度遷移(Deep Photo Style Transfer)蚯涮,這是由康奈爾大學(xué)的中國留學(xué)生和Adobe公司的工程師共同合作的一個新項目治专,通過深度學(xué)習(xí)的圖片處理方法,直接提取了參考圖片的風(fēng)格遭顶,并轉(zhuǎn)換為相對應(yīng)的濾鏡张峰,其效果如下圖所示,最左邊是原圖棒旗,中間是目標(biāo)風(fēng)格圖片喘批,最右邊是將中間的風(fēng)格遷移到左邊的結(jié)果。

image.png

另一個項目是來自UC Berkeley的CycleGAN铣揉,它能夠勝任任何的圖像轉(zhuǎn)換和圖像翻譯任務(wù)饶深,在風(fēng)格遷移上的效果尤其令人矚目。CycleGAN的網(wǎng)絡(luò)結(jié)構(gòu)和Fast Neural Style的transformer類似老速,但它采用了GAN的訓(xùn)練方式粥喜,能夠?qū)崿F(xiàn)風(fēng)格的雙向轉(zhuǎn)換,更加通用橘券。

image.png

在本章额湘,我們學(xué)會了如何實現(xiàn)一個深度學(xué)習(xí)中很酷的應(yīng)用:風(fēng)格遷移。不僅講解了它的原理旁舰、風(fēng)格損失和內(nèi)容損失的實現(xiàn)锋华,還用PyTorch實現(xiàn)了相應(yīng)的代碼。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末箭窜,一起剝皮案震驚了整個濱河市毯焕,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌磺樱,老刑警劉巖纳猫,帶你破解...
    沈念sama閱讀 211,561評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異竹捉,居然都是意外死亡芜辕,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,218評論 3 385
  • 文/潘曉璐 我一進(jìn)店門块差,熙熙樓的掌柜王于貴愁眉苦臉地迎上來侵续,“玉大人倔丈,你說我怎么就攤上這事∽次希” “怎么了需五?”我有些...
    開封第一講書人閱讀 157,162評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長轧坎。 經(jīng)常有香客問我宏邮,道長,這世上最難降的妖魔是什么眶根? 我笑而不...
    開封第一講書人閱讀 56,470評論 1 283
  • 正文 為了忘掉前任蜀铲,我火速辦了婚禮边琉,結(jié)果婚禮上属百,老公的妹妹穿的比我還像新娘。我一直安慰自己变姨,他們只是感情好族扰,可當(dāng)我...
    茶點故事閱讀 65,550評論 6 385
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著定欧,像睡著了一般渔呵。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上砍鸠,一...
    開封第一講書人閱讀 49,806評論 1 290
  • 那天扩氢,我揣著相機(jī)與錄音,去河邊找鬼爷辱。 笑死录豺,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的饭弓。 我是一名探鬼主播双饥,決...
    沈念sama閱讀 38,951評論 3 407
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼弟断!你這毒婦竟也來了咏花?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,712評論 0 266
  • 序言:老撾萬榮一對情侶失蹤阀趴,失蹤者是張志新(化名)和其女友劉穎昏翰,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體刘急,經(jīng)...
    沈念sama閱讀 44,166評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡棚菊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,510評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了排霉。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片窍株。...
    茶點故事閱讀 38,643評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡民轴,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出球订,到底是詐尸還是另有隱情后裸,我是刑警寧澤,帶...
    沈念sama閱讀 34,306評論 4 330
  • 正文 年R本政府宣布冒滩,位于F島的核電站微驶,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏开睡。R本人自食惡果不足惜因苹,卻給世界環(huán)境...
    茶點故事閱讀 39,930評論 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望篇恒。 院中可真熱鬧扶檐,春花似錦、人聲如沸胁艰。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,745評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽腾么。三九已至奈梳,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間解虱,已是汗流浹背攘须。 一陣腳步聲響...
    開封第一講書人閱讀 31,983評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留殴泰,地道東北人于宙。 一個月前我還...
    沈念sama閱讀 46,351評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像艰匙,于是被迫代替她去往敵國和親限煞。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,509評論 2 348

推薦閱讀更多精彩內(nèi)容

  • 本課重點 特征可視化 DeepDream 風(fēng)格遷移 1 特征可視化 之前一直把CNN當(dāng)做黑盒子處理员凝,那么其工作原理...
    HRain閱讀 3,012評論 0 11
  • 當(dāng)今的社會健霹,節(jié)奏變換太快旺上,每個人要想不被淘汰,都不覺的加快了前進(jìn)的步伐糖埋,深怕落人身后宣吱;快節(jié)奏意味著在每天的時...
    尋找世界閱讀 245評論 0 0
  • 于萬千人海中,一眼過去不偏不倚疤坝,看到了你兆解,從此飛蛾撲火般淪陷,不死不休跑揉。小舞曾這樣愛過一個人锅睛。 2014年冬天,向...
    舞笙離閱讀 172評論 0 0
  • 2019.7.16 星期二 天氣多云 今天仍然是高溫天氣历谍,大寶做了兩張手抄報现拒,沒出門。中午寶爸回來給他理發(fā)...
    五年六班陳樂奇閱讀 73評論 0 1