39从传统算法到深度学习:目标检测入门实战 --卷积神经网络基础

卷积神经网络

通过上一节实验我们学习了神经网络的基本概念和结构,我们了解到神经网络的输入层中的每个节点都与下一层的每个节点相连接,我们称这种连接方式为全连接(Fully-connected),但是这种全连接方式存在一些明显的缺陷。首先如果使用全连接网络处理图片的话,需要将图像矩阵转换为一列向量,这样就破坏了图像的空间信息。其次假设我们将一张尺寸为240×240×3 的三通道图片作为全连接网络的输入,则在输入层总共需要 172800个权重,如此多的参数需要很大的计算量和时间处理,并且大量的参数还会导致过拟合(模型在训练集上表现好,在测试集上表现差,泛化能力差)。在计算机视觉中广泛应用的卷积神经网络可以用来克服上述问题,在卷积神经网络中我们采取局部连接节点的方式代替全连接的方式,通常一个卷积神经网络由输入层、卷积层,激活层、池化层和全连接层构成。
卷积和卷积核
在开始学习卷积神经网络前,我们需要了解卷积和卷积核(Kernel)的相关内容。通常情况下,深度学习中所谓的卷积实际上是互相关操作(在后面的内容中我将用卷积来称呼互相关操作),如下图,两个矩阵的卷积即是将两个矩阵中对应位置的元素相乘再求和,则这两个矩阵的卷积结果是10×1+56×2+34×3+12×4+94×5+16×6+0×7+100×8+11×9=1737。

image.png

现在让我们来了解下图片的卷积是如何操作的,在图片上进行卷积需要用到卷积核(kernel),卷积核实际上就是一个矩阵,我们让这个矩阵在图像上从左向右、从上向下滑动,在滑动的过程中矩阵所覆盖的区域内的像素值与矩阵内元素按位相乘再求和,这些求和结果组成一个新的矩阵我们称之为特征图(Feature map)。
这个过程类似我们学习过的滑动窗口,假设我们使用一个3×3 的卷积核(下图中间的矩阵,矩阵中每个值都为 1/9)对一张7×7 尺寸的图片进行卷积,那么首先将卷积核的左上角顶点与图片的左上角顶点重叠,下图左边矩阵上的红色区域为重叠区域,然后按位计算红色矩形区域的元素与卷积核中的元素的乘积再将所有乘积结果求和,就得到了特征图上的第一个值为 67,然后按照从左向右、从上向下的顺序依次移动 1 个像素的距离(每次移动的像素个数称为步长(Stride),也可以移动多个像素)然后再计算重叠部分的卷积直到卷积核到达图片的左下角(下图左边矩阵的左下角虚线框),这样我们就获得了一个5×5 的特征图。需要注意的是卷积核的尺寸必须是奇数,例如1×1、3×3、5×5 等。
image.png

使用上述卷积方法得到的特征图尺寸会缩小,同时会丢失图片的边缘信息,因为卷积核移动到图片的边缘就结束了。为了解决这个问题,我们可以使用填充(Padding)方法,填充就是在图片外围填充像素值为 0 的像素点(见下图最左边矩阵),然后通过卷积计算得到的特征图尺寸就和输入图片的尺寸一样了。填充不仅可以在卷积过程中保留图像边缘信息,还可以对不同尺寸的图片进行填充,统一图片尺寸。
image.png

卷积层
卷积层是卷积神经网络的最重要组成部分,卷积层就是由不同数量和尺寸的卷积核构成的,其作用是用于图像的局部特征提取。在卷积神经网络中我们经常会遇到一个概念称为深度(Depth),深度与图像的通道类似,我们使用一个卷积核对图像进行卷积操作后会得到一个二维特征图,这个特征图和输入图像一样具有高和宽,使用多个尺寸相同的卷积核对输入图像进行卷积时我们将得到多个特征图,将这些特征图堆叠起来将得到一个三维特征图,这三个维度分别对应宽、高和深度,深度值就等于卷积核的个数。
如下图,我们使用 5 个尺寸相同的卷积核对图像进行卷积,我们将得到 5 个特征图,将这 5 个特征图堆叠起来就是一个具有宽、高和深度的三维矩阵,这个矩阵的深度就是 5。提到深度我们还需要了解一个概念称为滤波器(Filter),滤波器是由多个卷积核堆叠而成,其深度是其内卷积核的数量,当卷积核的个数为 1 时可以认为滤波器等同于卷积核。当给网络输入一张 RGB 图片时,由于图片有三个通道,需要用三个卷积核对图片进行卷积,这三个卷积核就构成一个滤波器。
image.png

在卷积神经网络中我们采取局部连接节点的方式代替全连接的方式,如下图,右边两个圆表示神经元节点,每个节点只与图片上的部分区域的像素值(下图中间的矩阵表示局部像素点)连接,这些区域之外的其他像素值都不会影响与这个区域相连的节点,这些区域称为对应节点的感受野(Receptive field)。如果输入网络的图像尺寸是16×16×3(图像的宽、高是 16,通道数是 3),假设感受野的尺寸是3×3 那么每个与这个区域连接的节点将接受3××3×3=27 个权重(图像有三个通道)。假设我们输入的尺寸是 5×5×100 以及感受野是 3×3,则与之相连的节点所接受的权重个数是3×3×100=900 。
image.png

激活层
激活层在每一个卷积层后,其作用是引入了非线性因素为节点建立一个输出边界,判断各区域特征强弱来筛选有用特征。例如通过卷积后的一块区域没能达到激活阈值,则激活函数将输出 0,表示这块区域提取的特征无关紧要。在卷积神经网络中比较常用的是 ReLU 函数,在本节实验我们并不需要了解 ReLU 函数的公式,因为现有的开源框架中已经内置了一些激活函数,我们只需要调用就行了。
池化层
池化(Pooling)最直观的作用就是压缩输入的尺寸(当卷积核的步长大于 1 时也可以压缩输入尺寸),池化层通常放在激活层之后。池化方法有两种,最大池化(Max pooling)和平均池化(Average pooling)。最大池化就是选定域内最大值来表示该区域,下图中我们在4×4 的矩阵中选定2×2 区域进行池化,选出这个区域内最大值 46 来表示该区域,然后向左移动 2 个步长,在新的区域中选择最大值 105 来表示该区域,依次类推我们将原来的4×4 矩阵压缩到2×2 尺寸。同理平均池化就是选定区域内的平均值表示该区域。
image.png

全连接层
全连接层就是前一层的激活值与这一层所有的节点相连,在上一节实验中我们已经详细讲解了全连接神经网络。在卷积神经网络中,全连接层总是放在网络的末尾。
Dropout
在全连接层后我们通常会进行 Dropout 操作,Dropout 是一种预防过拟合提高模型准确率的方法。其原理是在训练过程中以一定概率随机丢弃部分节点从而提高模型的泛化能力(见下图)。
image.png

使用卷积神经网络分类数字

至此我们已经了解了卷积神经网络的基本原理和结构,下面我们将通过代码来构建一个卷积神经网络并训练它,最后我们将使用训练好的模型实现数字的检测和识别。接下来使用 Tensorflow 中的 Keras 来构建一个卷积神经网络,Keras 是 Tensorflow 中的高级神经网络 API,它能够帮我们快速构建神经网络模型,我们从 tensorflow.keras 中导入将要用到的模块,这些模块我们将在后面的代码说明其作用。

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Conv2D
from tensorflow.keras.layers import MaxPooling2D
from tensorflow.keras.layers import Activation
from tensorflow.keras.layers import Flatten
from tensorflow.keras.layers import Dense
from tensorflow.keras.layers import Dropout
from tensorflow.keras import backend as K

首先我们创建一个名为 CNN 的类,这个类将包含前面我们提到的所有层。这个网络的结构是模仿 1998 年 Y. Lecun 等人改进的 LeNet 网络,感兴趣的同学可以阅读原文。然后我们用 @staticmethod 声明一个静态的 frame 方法,在该方法内我们将一层一层构建网络,这个方法需要提供 4 个参数 widthheightchannelclasses 分别对应输入图像的宽、高、通道数以及要分类的类别数。
第 4 行我们使用从 tensorflow.keras.models 中导入的 Sequential 模块创建一个序列模型 model,这个模型可以让我们一层一层的堆叠我们的网络。第 5 行我们按照 (height, width, channel) 的顺序初始化输入矩阵维度 img_shape。第 7 行我们判断 k.image_data_formate 返回默认图像数据格式如果是 "channels_first" 则将 img_shape 修改为 (channel, height, width),这里 k 是从 tensorflow.keras 中导入的 beckend 模块,Keras 并不处理张量和卷积等低级计算和操作,相反其依赖后端引擎来完成这个操作,通过该模块我们可以获得后端引擎的一些信息。

第 10 行通过 model 的 add 方法为我们的网络添加相应的层。我们使用从 tensorflow.keras.layers 中导入的 Conv2D 添加卷积层,Conv2D 会接收 4 个参数用于构建卷积层。其参数的意义如下所示:
第一个参数 20 表示使用 20 个滤波器对图片进行卷积。
第二个参数 (5, 5) 表示每个滤波器的尺寸为5×5。
第三个参数 padding="same" 表示为输入图像添加填充使得卷积后的特征图尺寸与输入的尺寸相同。
第四个参数 input_shape=img_shape 表示输入图像的尺寸。

第 11 行使用从 tensorflow.keras.layers 中导入的 Activation 将激活函数作用于特征图,这里我们提供 "relu" 参数表示使用 ReLU 函数。第 12 行使用从 tensorflow.keras.layers 中导入的 MaxPooling2D 添加池化层,这里使用最大池化的方法,这里需要提供 2 个参数,pooling_size=(2,2) 表示池化的尺寸是2×2,strides=(2,2) 表示池化的步长为 2。
第 14 行再添加一层卷积层,这一次使用 50 个滤波器,每个滤波器的尺寸为5×5,同样使用填充方法使输入和输出具有相同尺寸。然后在第 15 行添加一个 ReLU 激活层,最后在第 16 行添加一个最大池化层,池化的尺寸仍然是2×2,步长为 2。
接下来在第 18 行使用从 tensorflow.keras.layers 中导入的 Flatten 将输入的张量展开为一个向量。第 19 行使用 tensorflow.keras.layers 中导入的 Dense 添加全连接层,这里我们提供一个 500 参数表示该全连接层共有 500 个节点。第 20 行添加一个 ReLU 激活层。第 21 行使用从 tensorflow.keras.layers 中导入的 Dropout 方法添加 Dropout 层并将随机丢弃部分节点的概率设为 0.5。
第 23 行我们添加一个全连接层,其节点数等于分类任务的类别数。然后在第 24 行添加一个 softmax 函数进行分类。最后返回我们构建好的模型 model。

class CNN:
    @staticmethod
    def frame(width, height, channel, classes):
        model = Sequential()
        img_shape = (height, width, channel)

        if K.image_data_format() == "channels_first":
            img_shape = (channel, height, width)
            
        model.add(Conv2D(20, (5, 5), padding="same", input_shape=img_shape))
        model.add(Activation("relu"))
        model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

        model.add(Conv2D(50, (5, 5), padding="same"))
        model.add(Activation("relu"))
        model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

        model.add(Flatten())
        model.add(Dense(500))
        model.add(Activation("relu"))
        model.add(Dropout(0.5))

        model.add(Dense(classes))
        model.add(Activation("softmax"))
        
        return model

至此我们的神经网络已经构建完成,接下来我们将使用 Mnist 数据集训练我们构建的网络。首先通过下面命令下载数据集,整个数据集共有 7 万张手写数字图片和其对应的标签组成,数据集分为训练集(6 万张图片和每张图片对应的标签)和测试集(1 万张图片和每张图片对应的标签),每张图片的尺寸为 28×28×1。

!wget https://labfile.oss.aliyuncs.com/courses/3096/train-images.idx3-ubyte
!wget https://labfile.oss.aliyuncs.com/courses/3096/train-labels.idx1-ubyte
!wget https://labfile.oss.aliyuncs.com/courses/3096/test-images.idx3-ubyte
!wget https://labfile.oss.aliyuncs.com/courses/3096/test-labels.idx1-ubyte

我们首先导入需要用到的模块,具体每个模块的作用我们将在后面代码中介绍。

from tensorflow.keras.optimizers import SGD
from sklearn.preprocessing import LabelBinarizer
from sklearn.metrics import classification_report
import numpy as np
import matplotlib.pyplot as plt
from IPython import display
%matplotlib inline

接着我们创建一个 load_mnist 函数用于读取和处理数据集。该函数有三个输入参数,其参数意义如下所示:
images_path 是保存图片文件的地址。
labels_path 是保存标签文件的地址。
amount 是输入函数的图片总数,例如训练集总共有 60000 张图片要被读取,则 amount 就等于 60000。

第 2,3 行表示根据提供的 images_path 地址打开文件并使用 np.fromfile 将二进制文件读取为无符号整数。第 4 行剔除 images 中的前 16 个元素(这 16 个元素存储了一些属性信息,不属于图片像素值),然后使用 reshape 方法重塑数组为 (amount, 28, 28, 1),最后将这些无符号整数转换为浮点型。同理第 6,7,8 行获取图片对应标签。最后函数返回处理后的图片和标签。

def load_mnist(images_path, labels_path, amount):  
    with open(images_path, 'rb') as imgpath:
        images = np.fromfile(imgpath, dtype=np.uint8)
        images = images[16:].reshape((amount, 28, 28, 1)).astype("float32")
        
    with open(labels_path, 'rb') as lbpath:
        labels = np.fromfile(lbpath, dtype=np.uint8)
        labels = labels[8:].reshape((amount)).astype("float32")
        
    return images, labels

接下来使用 load_mnist 函数分别载入训练集和测试集。

print("Loading MNIST...")
trainData, trainLabels = load_mnist("train-images.idx3-ubyte", "train-labels.idx1-ubyte", 60000)
testData, testLabels = load_mnist("test-images.idx3-ubyte", "test-labels.idx1-ubyte", 10000)

然后我们判断 k.image_data_formate 返回默认图像数据格式如果是 "channels_first" 则将 trainData 和 testData 重塑为 (图片数量, 1, 28, 28)。

if K.image_data_format() == "channels_first":
    trainData = trainData.reshape((trainData.shape[0], 1, 28, 28))
    testData = testData.reshape((testData.shape[0], 1, 28, 28))

下面我们对图像进行归一化处理以加快训练过程,我们将所有像素值除以最大像素值 255,这样所有像素值都会在 [0, 1] 之间。

trainData = trainData / 255.0
testData = testData / 255.0

然后我们使用从 sklearn 的 preprocessing 模块中导入的 LabelBinarizer 对训练集和测试集的标签进行编码,使用 LabelBinarizer 中的 fit_trainsform 方法分别将训练集和测试集的标签二值化。

la = LabelBinarizer()
trainLabels = la.fit_transform(trainLabels)
testLabels = la.transform(testLabels)

我们使用从 tensorflow.keras.optimizers 中导入的 SGD 优化我们的误差函数,下面第 1 行我们初始化随机梯度下降法并将学习率设为 0.01。第 2 行实例化我们前面构建好的卷积神经网络,frame 的输入参数 width 表示图片的宽,height 表示图片的高,channel 表示图片的通道数,classes 表示数据集的图片会被分为 10 个类别(数字是 0 到 9)。第 3 行使用 compile 配置编译我们的训练模型,这里我们将输入 3 个参数,loss 表示我们选择的目标函数,这里将其设置为 "categorical_crossentropy",optimizer 是设置优化方法,这里我们将其设置为前面初始化的随机梯度下降法。metrics 用于设置模型评估标准,这里我们将其设置为 ["accuracy"]。

opt = SGD(lr=0.01)
model = CNN.frame(width=28, height=28, channel=1, classes=10)
model.compile(loss="categorical_crossentropy", optimizer=opt, metrics=["accuracy"])

下面第 2 行我们为 fit 方法提供几个参数训练我们的网络,trainData 和 trainLabels 是我们的训练集和对应的标签,我们将元组 (testData, testLabels) 作为参数传递给 validation_data,表示我们将用测试集和其对应标签评估模型,需要注意的是模型不会在这个测试集上进行训练。batch_size = 128 表示每次我们训练 128 张图片。
这里我们简单介绍下 Mini Batch 的概念,Mini Batch 是将数据集分成若干份,然后逐个训练每个小部分,这个方法可以解决设备因内存小、无法一次训练整个数据集的问题。epochs = 5 表示训练过程在整个训练集被训练 5 次时停止。verbose = 1 表示将用进度条显示训练进度。第 3 行我们使用 save 方法保存训练好的模型并将其命名为 cnn_weights.hdf5。

print("Training CNN...")
H = model.fit(trainData, trainLabels, validation_data=(testData, testLabels), batch_size=128,epochs=5, verbose=1)
model.save("cnn_weights.hdf5")

训练完成后我们就可以使用模型进行分类了,第 2 行我们使用 predict 方法在测试集上进行测试,batch_size=128 表示将 Mini Batch 设置为 128。结果 predictions 是一个维数是(60000,10)的 NumPy 数组,10000 表示测试集有 10000 个图片,第二维的 10 表示每张图片所对应从 0 到 9 这 10 个数所对应的概率。然后第 3 行我们使用从 sklearn 的 metrics 模块中导入的 classification_report 方法显示评估的结果。该方法的第一个参数 testLabels.argmax(axis=1) 就是测试集中每张图片所对应的真实标签。predictions.argmax(axis=1) 是模型预测每张图片对应的标签概率。target_names=[str(x) for x in la.classes_] 表示在屏幕输出中显示标签每个数字的标签。

print("Evaluating ...")
predictions = model.predict(testData, batch_size=128)
print(classification_report(testLabels.argmax(axis=1), predictions.argmax(axis=1), target_names=[str(x) for x in la.classes_]))

如果脚本执行没有问题的话,应该能看到类似下图的输出结果。通过下图我们可以看到模型在测试集上的准确率达到了 99%。


image.png

下面我们将使用这个模型来检测和识别出图片中的数字。我们先通过下面两条命令下载需要用到的图片。

!wget https://labfile.oss.aliyuncs.com/courses/3096/digit.zip

通过下面的命令解压文件将得到 4 张图片,我们将用这 4 张图片进行手写数字检测和识别,下图为下载的图片。

!unzip digit.zip
image.png

导入需要用到的模块,在后面的代码中我们将讲解每个模块的作用。

import cv2
from tensorflow.keras.models import load_model

下面我们创建一个 paths 变量用于保存 4 张图片路径,然后我们使用 cv2.imread 读取列表中第一张图片。

paths = ["1.jpg", "2.jpeg", "3.jpeg", "4.jpg"]
image = cv2.imread(paths[0])

接着首先使用 cv2.cvtColor 方法将彩色图片转换为单通道灰度图(我们模型只接受单通道图片)。然后使用 cv2.GaussianBlur 对图片进行平滑预处理。最后使用 cv2.Canny 方法对图片进行边缘检测,找出图片中的边缘信息。

gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edged = cv2.Canny(blurred, 30, 150)

我们使用 cv2.findContours 在边缘信息图像 edged 中找出其中轮廓信息并且只返回外轮廓,cnts 里面的每个元素都是一个轮廓。第 2 行我们先用 cv2.boundingRect 方法找到每个轮廓的最小外接矩形,然后将每个轮廓和其最小外接矩形的左上角顶点的横坐标以轮廓,左上角顶点横坐标的形式放在一个元组中,最后使用 sorted 方法将元组轮廓,左上角顶点横坐标按照左上角顶点横坐标的大小排列顺序。

cnts, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = sorted([(c, cv2.boundingRect(c)[0]) for c in cnts], key = lambda x: x[1])

下面我们使用从 tensorflow.keras.models 中导入的 load_model 加载前面已经保存的模型 cnn_weights.hdf5。

model = load_model("cnn_weights.hdf5")

下面我们创建两个列表,image_data 用于存储从原图中截取的每个数字图像,roi_c 存储前面我们提到的每个轮廓的最小外接矩形的坐标和宽高。接下来我们使用 for 循环遍历 cnts 中的每个轮廓。在循环内我们首先用 cv2.boundingRect 找到每个轮廓的最小外接矩形的左上角顶点坐标 x 和 y,以及矩形的宽 w 和高 h。
下面第 7 行使用 if 语句来剔除宽小于 10、高小于 20 的外接矩形。下面第 8 行我们用第 5 行获得的坐标 x、y 以及宽和高 w、h 从灰度图 gray 中截取出每个数字(这里将坐标值和宽高都加减 10 是为了避免最小外接矩形不能完整的将数字包含在其内)。第 10 行我们用 cv2.resize 将截取的图片的尺寸缩放为28×28,因为我们的网络输入尺寸是28×28。第 11 行使用 cv2.threshold 将 roi 中所有小于 100 的像素值设置为 255, 其他的像素值设置为 0。第 12 行将所有像素值进行归一化处理。最后将处理后的图片和外接矩形的 4 个量存储到 image_data 和 roi_c 中。

image_data = []
roi_c = []

for (c, _) in cnts:
    (x, y, w, h) = cv2.boundingRect(c)

    if w >= 10 and h >= 20:
        roi = gray[y-10:y + h+10, x-10:x + w+10]
        
        roi = cv2.resize(roi, (28, 28), interpolation = cv2.INTER_AREA)
        T, thresh = cv2.threshold(roi, 100, 255, cv2.THRESH_BINARY_INV)
        thresh = thresh.astype("float32")/255.0
        
        image_data.append(thresh)
        roi_c.append((x,y,w,h))

我们的模型对输入的维度要求是n×28×28×1,而 image_data 的维度是n×28×28,所以我们需要用 np.expand_dim 添加第 4 个维度使得 image_data 的维度与模型输入维度相同。然后第 2 行我们使用 model.predict 方法对输入图片进行分类并使用 argmax 输出其所属类的标签。

image_data = np.expand_dims(image_data, axis=3)
result = model.predict(image_data).argmax(axis=1)

最后我们使用 cv2.rectangle 标记出图片中的每个数字并用 cv2.putText 将其对应的标签添加到数字上方。

for i, r in enumerate(roi_c):
    (x, y, w, h) = r
    cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 1)
    cv2.putText(image, str(result[i]), (x - 10, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2)

接下来我们通过下面的代码来显示检测结果。

plt.figure(figsize = (10,10))
image = image[:,:,::-1]
plt.imshow(image)

执行整个脚本,如果没有意外的话我们能看到类似下图的结果。读取 paths 列表中的其他图片只需将 cv2.imread(path[0]) 中的 0 修改为 1、2 或 3。虽然有些数字被错误的分类,但是大部分的数字还是被正确的识别了。如果想要提高模型的性能可以尝试增加训练次数(我们的模型只训练了 5 次),增加卷积层(我们的模型只有两层卷积层),在每个激活层之后应用批标准化(Batch Normalization)和 Dropout 都能提高模型的性能(我们的模型没有使用批标准化且只使用了一次 Dropout,添加批标准化和 Dropout 可以明显提高我们的模型表现,大家可以自行尝试上述方法)。


image.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容