十二、計算機視覺中的深度學習:從零開始訓練卷積網絡


文章代碼來源:《deep learning on keras》,非常好的一本書,大家如果英語好,推薦直接閱讀該書,如果時間不夠,可以看看此系列文章,文章為我自己翻譯的內容加上自己的一些思考,水平有限,多有不足,請多指正,翻譯版權所有,若有轉載,請先聯系本人。
個人方向為數值計算,日后會向深度學習和計算問題的融合方面靠近,若有相近專業人士,歡迎聯系。


系列文章:
一、搭建屬于你的第一個神經網絡
二、訓練完的網絡去哪里找
三、【keras實戰】波士頓房價預測
四、keras的function API
五、keras callbacks使用
六、機器學習基礎Ⅰ:機器學習的四個標簽
七、機器學習基礎Ⅱ:評估機器學習模型
八、機器學習基礎Ⅲ:數據預處理、特征工程和特征學習
九、機器學習基礎Ⅳ:過擬合和欠擬合
十、機器學習基礎Ⅴ:機器學習的一般流程十一、計算機視覺中的深度學習:卷積神經網絡介紹
十二、計算機視覺中的深度學習:從零開始訓練卷積網絡
十三、計算機視覺中的深度學習:使用預訓練網絡
十四、計算機視覺中的神經網絡:可視化卷積網絡所學到的東西


用很少的數據來訓練圖像分類模型在實踐中很常見,如果你是計算機視覺背景專業出身的。
有少量樣本意味著有幾百到幾萬張圖片。作為實例教學,我們將會集中注意力于貓狗分類,數據集中含有2000張貓,2000張狗。我們將會使用2000張用來訓練,1000張用來驗證,最后1000張用來測試。
在這部分,我們將回顧一個基本的解決這個問題的方法:從零訓練一個新的模型。我們從直接在我們的2000個訓練樣本上訓練一個小卷積網絡開始,沒有任何正則化,建立一個能達到的最低水平。我們的分類準確率達到了71%。這那一點,我們的主要問題在過擬合上。我們將會介紹數據增加,一種在計算機視覺中有效預防過擬合的方法。通過利用數據增加,我們把我們的網絡的準確率提升到了82%。
在接下來的部分,我們將會回顧兩個重要的應用在深度學習小樣本的方法:在預訓練的網絡上做特征提取(這將幫助我們達到90%到96%的準確率)以及調好參數的預訓練網絡(可以將準確率提升到97%)。一起來說,這三個方法——從零訓練小模型,使用預訓練模型來做特征提取,調節預處理模型的參數——將會組成你以后解決計算機視覺問題中的小數據集時的工具包。

小數據問題的深度學習關聯

你有的時候會聽到深度學習只有當有很多數據的時候才起作用。這在一定程度上是一個有效的點:一個深度學習的基本特征是它能找到訓練數據本身的有意思的特征,不需要任何人工特征工程,這也只能在有很多訓練樣本的時候是可行的。這對于輸入樣本有比較高的維數時尤為正確,比如說圖像。
然而,構成很多樣本的都是相關的。不可能通過十來個樣本就訓練一個網絡去解決復雜的問題,但是對于比較小的,正則化好的模型,數百個樣本也足夠了。因為卷積網絡學習局部,具有平移不變性的特征,具有很高的數據效率。在一個很小的圖像數據集上從零開始訓練一個卷積網絡,仍將產生合理的結果,盡管缺少數據,無需任何自定義的特征工程。你將在這一部分看到。
但是,深度學習模型是自然能高度重新設計的:你能將一個模型用到不同的數據集上,只需要一丁點的改動即可。特別的,很多訓練好的模型都能下載了,能夠用來引導小數據的情況。

下載數據

The cats vs. dogs dataset在keras里面沒有,但是在Kaggle里面的2013下載到。
下載后的數據如下所示:

Samples from the cats vs. dogs dataset. Sizes were not modified: the samples are heterogenous in size, appearance, etc.

不出意料的,在2013的Kaggle競賽中,使用convnets的贏得了比賽。達到了95%的準確率,接下來我們得到的會很接近這個準確率,我們實際使用的樣本還不足原本競賽給出數據的10%,競賽包含25000個貓狗圖,大小為543MB(壓縮后)。在下載和解壓后,我們將會生成一個新的數據集,包含三個子集:一個貓狗各有1000個樣本的訓練集,各有500個樣本的驗證集,和各有500個樣本的測試集。
接下來就是幾行做這個的代碼:

import os, shutil
# The path to the directory where the original
# dataset was uncompressed
original_dataset_dir = '/Users/fchollet/Downloads/kaggle_original_data'
# The directory where we will
# store our smaller dataset
base_dir = '/Users/fchollet/Downloads/cats_and_dogs_small'
os.mkdir(base_dir)
# Directories for our training,
# validation and test splits
train_dir = os.path.join(base_dir, 'train')
os.mkdir(train_dir)
validation_dir = os.path.join(base_dir, 'validation')
os.mkdir(validation_dir)
test_dir = os.path.join(base_dir, 'test')
os.mkdir(test_dir)
# Directory with our training cat pictures
train_cats_dir = os.path.join(train_dir, 'cats')
os.mkdir(train_cats_dir)
# Directory with our training dog pictures
train_dogs_dir = os.path.join(train_dir, 'dogs')
os.mkdir(train_dogs_dir)
# Directory with our validation cat pictures
validation_cats_dir = os.path.join(validation_dir, 'cats')
os.mkdir(validation_cats_dir)
# Directory with our validation dog pictures
validation_dogs_dir = os.path.join(validation_dir, 'dogs')
os.mkdir(validation_dogs_dir)
# Directory with our validation cat pictures
test_cats_dir = os.path.join(test_dir, 'cats')
os.mkdir(test_cats_dir)
# Directory with our validation dog pictures
test_dogs_dir = os.path.join(test_dir, 'dogs')
os.mkdir(test_dogs_dir)
# Copy first 1000 cat images to train_cats_dir
fnames = ['cat.{}.jpg'.format(i) for i in range(1000)]
for fname in fnames:
 src = os.path.join(original_dataset_dir, fname)
 dst = os.path.join(train_cats_dir, fname)
 shutil.copyfile(src, dst)
# Copy next 500 cat images to validation_cats_dir
fnames = ['cat.{}.jpg'.format(i) for i in range(1000, 1500)]
for fname in fnames:
 src = os.path.join(original_dataset_dir, fname)
 dst = os.path.join(validation_cats_dir, fname)
 shutil.copyfile(src, dst)
# Copy next 500 cat images to test_cats_dir
fnames = ['cat.{}.jpg'.format(i) for i in range(1500, 2000)]
for fname in fnames:
 src = os.path.join(original_dataset_dir, fname)
 dst = os.path.join(test_cats_dir, fname)
shutil.copyfile(src, dst)
# Copy first 1000 dog images to train_dogs_dir
fnames = ['dog.{}.jpg'.format(i) for i in range(1000)]
for fname in fnames:
 src = os.path.join(original_dataset_dir, fname)
 dst = os.path.join(train_dogs_dir, fname)
 shutil.copyfile(src, dst)
# Copy next 500 dog images to validation_dogs_dir
fnames = ['dog.{}.jpg'.format(i) for i in range(1000, 1500)]
for fname in fnames:
 src = os.path.join(original_dataset_dir, fname)
 dst = os.path.join(validation_dogs_dir, fname)
 shutil.copyfile(src, dst)
# Copy next 500 dog images to test_dogs_dir
fnames = ['dog.{}.jpg'.format(i) for i in range(1500, 2000)]
for fname in fnames:
 src = os.path.join(original_dataset_dir, fname)
 dst = os.path.join(test_dogs_dir, fname)
 shutil.copyfile(src, dst)

首先給出原始數據和新數據集的存放位置。
再在新數據集目錄下分別寫訓練集、驗證集、測試集的位置。
再分別在三個集合下面建立貓和狗的文件夾。
最后,把原數據集里面的前1000個數據放進新訓練集,接下來的500個放進驗證集,再接下來五百個放進測試集。
最后使用程序數一數我們放對了嗎?

>>> print('total training cat images:', len(os.listdir(train_cats_dir)))
total training cat images: 1000
>>> print('total training dog images:', len(os.listdir(train_dogs_dir)))
total training dog images: 1000
>>> print('total validation cat images:', len(os.listdir(validation_cats_dir)))
total validation cat images: 500
>>> print('total validation dog images:', len(os.listdir(validation_dogs_dir)))
total validation dog images: 500
>>> print('total test cat images:', len(os.listdir(test_cats_dir)))
total test cat images: 500
>>> print('total test dog images:', len(os.listdir(test_dogs_dir)))
total test dog images: 500

這樣一來,我們就得到了所需小數據集。

構建我們的神經網絡

我們已經在MNIST中構建了一個小的卷積神經網絡,所以你應該對這個很熟。我們將會重復使用相同的生成框架:我們的卷積網絡就是一些卷積層和最大池化層的堆疊。
然而,由于我們在解決大點的圖像和更加復雜的問題,我們要讓我們的網絡相應的也更大:將會有更多的卷積層和最大池化層的組合。這將擴大網絡的容量,并減少特征映射的大小,使得他們在拉伸層不會過大。這里,由于我們輸入的大小從150\times 150開始(隨便選的一個),我們最終得到了7\times 7的特征映射。
注意特征映射的深度從32提升到了128,同時特征映射的大小在下降(從148\times 1487\times 7)
由于我們在攻擊一個二分類問題,我們的網絡最終只需要一個單元。

from keras import layers
from keras import models
model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu',
 input_shape=(150, 150, 3)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Flatten())
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))

看一下結構

>>> model.summary()
Layer (type) Output Shape Param #
================================================================
conv2d_1 (Conv2D) (None, 148, 148, 32) 896
________________________________________________________________
maxpooling2d_1 (MaxPooling2D) (None, 74, 74, 32) 0
________________________________________________________________
conv2d_2 (Conv2D) (None, 72, 72, 64) 18496
________________________________________________________________
maxpooling2d_2 (MaxPooling2D) (None, 36, 36, 64) 0
________________________________________________________________
conv2d_3 (Conv2D) (None, 34, 34, 128) 73856
________________________________________________________________
maxpooling2d_3 (MaxPooling2D) (None, 17, 17, 128) 0
________________________________________________________________
conv2d_4 (Conv2D) (None, 15, 15, 128) 147584
________________________________________________________________
maxpooling2d_4 (MaxPooling2D) (None, 7, 7, 128) 0
________________________________________________________________
flatten_1 (Flatten) (None, 6272) 0
________________________________________________________________
dense_1 (Dense) (None, 512) 3211776
________________________________________________________________
dense_2 (Dense) (None, 1) 513
================================================================
Total params: 3,453,121
Trainable params: 3,453,121
Non-trainable params: 0

在最后compilation的步驟,我們會像往常一樣,使用RMSprop優化器,因為我們使用一個sigmoid單元在我們的模型最后,我們將使用二進交叉熵作為損失函數,記住,你要是不知道怎么選這些東西了,可以翻一翻之前列的表。

from keras import optimizers
model.compile(loss='binary_crossentropy',
 optimizer=optimizers.RMSprop(lr=1e-4),
 metrics=['acc'])

數據預處理

你現在已經知道,數據在喂進網絡之前需要預處理成浮點數張量。目前我們的數據來自于JPEG文件,所以其處理步驟大致為:

  • 讀圖片文件
  • 將JPEG解碼為RBG
  • 將它們轉化為浮點張量
  • 將像素點的值歸一化

這看起來有點冗雜,但所幸,keras能夠自動做完上述步驟。keras有一個圖像處理幫助工具,位于keras.preprocessing.image。特別的,其包括ImageDataGenerator類,能夠快速設置Pyhon的生成器,從而快速將磁盤上圖片文件加入預處理張量批次。這就是我們將要使用的。

注意:理解Python中的生成器(generators)

Python的生成器是一個對象,像一個迭代器一樣工作,即一個對象可以使用for/in操作符。生成器使用yield操作符來建成。
這里有一個用生成器生成整數的例子:

def generator():
 i = 0
 while True:
 i += 1
 yield i
for item in generator():
 print(item)
 if item > 4:
 break

1
2
3
4
5

使用圖像數據生成器來從目錄中讀取圖片

from keras.preprocessing.image import ImageDataGenerator
# All images will be rescaled by 1./255
train_datagen = ImageDataGenerator(rescale=1./255)
test_datagen = ImageDataGenerator(rescale=1./255)
train_generator = train_datagen.flow_from_directory(
 # This is the target directory
 train_dir,
 # All images will be resized to 150x150
 target_size=(150, 150),
 batch_size=20,
 # Since we use binary_crossentropy loss, we need binary labels
 class_mode='binary')
validation_generator = test_datagen.flow_from_directory(
 validation_dir,
 target_size=(150, 150),
 batch_size=20,
 class_mode='binary')

講解一下代碼:
首先定義train_datagen這個生成器,重置像素點大小的命令寫在括號中,然后validation_generator實際得到的是一個張量,張量形狀為(20,150,150,3),而生成這個所用的就是生成器的flow_from_directory屬性,第一個參數填文件目錄,第二個參數填將圖片重置的大小,第三個參數填每一批次取得個數,最后一個參數填標簽類別。

展示數據和標簽

>>> for data_batch, labels_batch in train_generator:
>>> print('data batch shape:', data_batch.shape)
>>> print('labels batch shape:', labels_batch.shape)
>>> break
data batch shape: (20, 150, 150, 3)
labels batch shape: (20,)

讓我們開始用生成器來擬合我們的模型。我們使用fit_generator方法來進行,這個我們的fit是等價的。先放代碼:

history = model.fit_generator(
 train_generator,
 steps_per_epoch=100,
 epochs=30,
 validation_data=validation_generator,
 validation_steps=50)

第一個參數是我們生成器,第二個參數是每一批需要進行的步數,由于我們生成器每次生成20個數據,所以需要100步才能遍歷完2000個數據,驗證集的類似知道為什么是50.
每次訓練完以后保存模型是個好習慣:

model.save('cats_and_dogs_small_1.h5')

接下來畫出訓練和驗證的損失值和成功率:

import matplotlib.pyplot as plt
acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']
epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
Training and validation accuracy (training values as dots, validation values as solid lines)

Training and validation loss (training values as dots, validation values as solid lines)

這組圖表明過擬合了,我們的訓練準確率隨時間線性增加,直到最后接近100%,但我們的驗證集停滯在了70-72%。我們驗證損失在5批次達到最小值以后就停滯了,盡管訓練損失持續降低,直到最后接近0.
因為我們只用了很少的訓練數據2000個,過擬合是我們最關心的。你已經知道一系列方法去防止過擬合,比如dropout和權重衰減(L2正則化)。我們現在要介紹一種新的,特別針對于計算機視覺的,常常被用在深度學習模型中處理數據的:數據增加。

使用數據增加

過擬合是由于樣本太少造成的,導致我們無法訓練模型去泛化新數據。給定無限的數據,我們的模型就會暴露在各種可能的數據分布情況中:我們從不會過擬合。數據增加采用了從存在的訓練樣本中生成更多訓練數據的方法,通過一系列隨機的變換到可辨識的其它圖像,來增加樣本數量。目的是在訓練的時候,我們的模型不會重復看到同一張圖片兩次。這幫助模型暴露在更多數據面前,從而有更好的泛化性。
在keras里面,我們可以通過ImageDataGenerator來生成一系列隨機變換。讓我們從一個例子開始:

datagen = ImageDataGenerator(
 rotation_range=40,
 width_shift_range=0.2,
 height_shift_range=0.2,
 shear_range=0.2,
 zoom_range=0.2,
 horizontal_flip=True,
 fill_mode='nearest')

這里只列出了一小部分選項,想要了解更多,請看keras文檔。
讓我們很快看一遍我們寫了什么:

  • rotation_range是一個角度值(0-180),是隨機轉動圖片的角度范圍。
  • width_shift和height_shift是隨機改變圖片對應維度的比例。
  • shear_range是隨機剪切的比例
  • zoom_range是在圖片內隨機縮放的比例
  • horizontal_flip是隨機將圖片水平翻轉,當沒有水平對稱假設時。
  • fill_mode在新出來像素以后,我們選擇填充的策略。

讓我們看一看圖像增加:

# This is module with image preprocessing utilities
from keras.preprocessing import image
fnames = [os.path.join(train_cats_dir, fname) for fname in os.listdir(train_cats_dir)]
# We pick one image to "augment"
img_path = fnames[3]
# Read the image and resize it
img = image.load_img(img_path, target_size=(150, 150))
# Convert it to a Numpy array with shape (150, 150, 3)
x = image.img_to_array(img)
# Reshape it to (1, 150, 150, 3)
x = x.reshape((1,) + x.shape)
# The .flow() command below generates batches of randomly transformed images.
# It will loop indefinitely, so we need to `break` the loop at some point!
i = 0
for batch in datagen.flow(x, batch_size=1):
 plt.figure(i)
 imgplot = plt.imshow(image.array_to_img(batch[0]))
 i += 1
 if i % 4 == 0:
 break
plt.show()
Generation of cat pictures via random data augmentation

雖然我們可以保證訓練過程中,模型不會看到相同的兩張圖,但是畢竟我們只是對原圖混合了一下,并沒有增加什么新的信息,所以無法完全避免過擬合,為了進一步抗擊過擬合,我們加入了dropout層:

model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu',
 input_shape=(150, 150, 3)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Flatten())
model.add(layers.Dropout(0.5))
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
model.compile(loss='binary_crossentropy',
 optimizer=optimizers.RMSprop(lr=1e-4),
 metrics=['acc'])

接下來讓我們使用數據增強和dropout來訓練網絡:

train_datagen = ImageDataGenerator(
 rescale=1./255,
 rotation_range=40,
 width_shift_range=0.2,
 height_shift_range=0.2,
 shear_range=0.2,
 zoom_range=0.2,
 horizontal_flip=True,)
# Note that the validation data should not be augmented!
test_datagen = ImageDataGenerator(rescale=1./255)
train_generator = train_datagen.flow_from_directory(
 # This is the target directory
 train_dir,
 # All images will be resized to 150x150
 target_size=(150, 150),
 batch_size=32,
 # Since we use binary_crossentropy loss, we need binary labels
 class_mode='binary')
validation_generator = test_datagen.flow_from_directory(
 validation_dir,
 target_size=(150, 150),
 batch_size=32,
 class_mode='binary')
history = model.fit_generator(
 train_generator,
 steps_per_epoch=100,
 epochs=100,
 validation_data=validation_generator,
 validation_steps=50)

保存我們的模型:

model.save('cats_and_dogs_small_2.h5')

讓我們再畫出訓練和驗證的結果看看:


Training and validation accuracy (training values as dots, validation values as solid lines)

Training and validation loss (training values as dots, validation values as solid lines)

多虧了數據增強和dropout,我們不再過擬合了:訓練曲線和驗證曲線十分相近。我們現在能夠達到82%的準確率,比未正則化的模型要提高了15%。
通過利用正則化方法,或者更進一步:調參數,我們能達到更好的準確率,近乎86-87%。然而,這證明從零開始訓練我們的卷積網絡已經難以更好了,因為我們只有很少的數據來處理。下一步我們提高準確率的方法是利用預訓練的網絡,這將在接下來兩部分進行講解。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,983評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,772評論 3 422
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,947評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,201評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,960評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,350評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,406評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,549評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,104評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,914評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,089評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,647評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,340評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,753評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,007評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,834評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,106評論 2 375

推薦閱讀更多精彩內容