目标检测系列——SSD(上)

一、SSD原理

  本文记录一阶段目标检测模型SSD的学习笔记及代码复现过程。首先个人感觉SSD和YOLO相比稍微简单一些,理论比较直白,不过实现起来依然比较繁琐。本文大体以李沐老师的Gluon教学视频7-9为基础,在Anchors sampling和Training Targets两个点上加上了一些自己的挖掘。

  根据李沐老师的说法,SSD的算法可以看做Faster-RCNN的一个简化,具体说来就是直接把RPN中那个负责Region Proposal的二分类Softmax扩展了一下,区分出背景的同时将前景细分成物体的类别,即扩展成了n+1分类,同时bbox regressor一步回归到位。这样理论上就足够得到box和类别信息了。不过为了提高准确性,SSD在此基础上又加上多尺度预测,具体来说就是将主干网络输出的特征通过downsampling Conv降低一半再预测cls和box,以此类推。

  看到这里,一个必须要想清楚的问题是:尺寸较小的feature map负责提高大目标还是小目标的精确度?

  这个问题其实本质是对anchor概念的理解。

1.1 Anchors

  关于Anchors的概念,等到写Faster-RCNN时再详细记录。这里先简单理解,就是在特征图的每个像素点上采样一组预定义尺寸和形状的先验框,然后希望这些所有尺度的先验框能够覆盖图像中所有可能的boundingbox位置。

  当然,anchors的巧妙之处远不止这些。比如anchor的引入使我们得以避开了编码boudingbox规则这一难题,见[1]。

Fig. 1. Anchors的尺寸随grid的尺寸成比例变化

  上图回答了1.1的问题。每一组anchors都是以feature map中的像素为中心定义的,故anchors的尺寸和像素之间的距离——grid的尺寸成正比,即和feature map的尺寸成反比。所以大的feature map负责检测小物体,小的feature map负责检测大物体。下面的Anchors Sampling代码实现部分也有类似的可视化结果。

  这里有个小问题:上面说的小的feature map负责检测大物体是因为anchor占feature的比例很大,这个说法应该是在我们默认深度神经网络feature map越小,receptive field越大这一前提下?我们假设小的feature map的感受野已经足够覆盖原图大部分区域?

二、各部件实现:

  由于SSD原来相对简单,这一部分以理论和代码块实现相结合的形式展开记录。基本的组件有如下几块:

  1. Anchors Sampling.
  2. Class Predictor.
  3. Bbox Predictor.
  4. Downsample Block.
  5. 整合得到完整的SSD模型.
  6. 数据集加载(有时间会记录rec文件生成方法).
  7. Training Targets.
  8. 优化:Focal Loss与Negative example mining.

2.1 Anchors Sampling

  原始图像送入主干网络之后,得到一个feature map,如 1×32×20×20。现在我们需要在该feature的每一个像素点(x, y)上sample出多个预先定义的anchor。这一点和RPN是一致的。不过有个细节,SSD的anchor不像Faster-RCNN那样,指定了一组size和一组宽高比,最后的anchor数就是size的个数n乘以ratio的个数m;SSD简化了下,将anchor总数改成 n+m-1

  规则见下图:简单的说就是宽高比不变时,只使用第一个ratio(通常为1),得到几个不同大小的正方形;而size不变时,只使用第一个size,得到几个相同size不同宽高比的anchor,这两种情况会有一个anchor取了两次(第一个size和第一个ratio对应anchor),去掉一个即可。

Fig. 2. SSD中Anchor的采样规则和Faster-RCNN中稍有不同

  虽然我们可以像课程中那样,用contrib.ndarray中的MultiBoxPrior来sample anchors, 并且实际模型训练中肯定也会使用这个函数以获取最佳优化效果和backward能力,但是在此之前有必要先用Python简单实现下,以加深理解。简单起见,这里就直接用Numpy和list,不用NDArray了。因为NDArray对于list的支持不是很友好。

  中间的探索过程比较长,就不列了,刚兴趣的同学可以参考此notebook。下面直接上代码:

import numpy as np
import time
import matplotlib.pyplot as plt
from mxnet import nd, autograd
from mxnet.contrib.ndarray import MultiBoxPrior

def sample_anchor(area, ratio, center_coor, input_shape):
    """
    实现:给定anchor的面积和宽高比,以及中心坐标,求出anchor的box
    area:anchor的绝对面积(size_ratio * size_input)
    ratio:单个宽高比
    center_coor:tuple: (x, y)
    input_shape: tuple: (h, w, c)
    输出:单个长度为4的list
    """
    x, y = center_coor
    H, W = input_shape[:2]
    
    dh = np.sqrt(area/ratio)
    dw = dh * ratio
    # 返回对输入feature尺寸归一化之后的anchor坐标
    anchor = [(x-dw/2)/W, (y-dh/2)/H, (x+dw/2)/W, (y+dh/2)/H]  
    return anchor

def sample_one_pixel(sizes, ratios, center_coor, input_shape):
    """
    实现:给定sizes和ratios两个list以及输入图像宽高,在单个坐标点上sample anchors.
    sizes: list of size_ratios, 为了便于计算,首先转换为list_of_absolute_areas
    输出:lists of lists, n+m-1个对输入尺寸归一化过的输出anchor,每个anchor为四维list 
    """
    H, W = input_shape[:2]
    areas = [size**2 * H*W for size in sizes] #获取绝对areas
    anchors = []
    for a in areas:
        anchors.append(sample_anchor(a, ratios[0], center_coor, input_shape))
    for r in ratios[1:]:
        anchors.append(sample_anchor(areas[0], r, center_coor, input_shape))
    return np.array(anchors)  # .reshape(-1, 4)  # 这里发现lists of lists 直接变array不需要reshape。但是NDArray不支持类似的模糊转换

def sample_one_feature(feature, sizes, ratios):
    input_shape = feature.shape
    H, W = input_shape[:2]
    n, m = len(sizes), len(ratios)
    anchors = np.zeros((H, W, n+m-1, 4))
    for h in range(H):
        for w in range(W):
            # 这里实现需要的组织方式
            anchors[h,w,:,:]=sample_one_pixel(sizes, ratios, (w,h), input_shape)
    return anchors

def draw_one_pixel(feature, center_coor, anchors):
    colors = ['blue', 'green', 'red', 'black', 'magenta']
    x, y = center_coor
    feature_display = np.array([feature[:,:,0]]*3).transpose((1,2,0)) 
    plt.imshow(np.ones_like(feature_display)*255)
    anchors_this_pixel = anchors[y,x,:,:]
    for i, anchor in enumerate(anchors_this_pixel):
        plt.gca().add_patch(plt.Rectangle((anchor[0]*n, anchor[1]*n), 
                                          (anchor[2]-anchor[0])*n, (anchor[3]-anchor[1])*n, fill=False, color=colors[i]))
        plt.grid()
    plt.show()

if __name__ == "__main__":
    sizes = [.5, .25, .1]
    ratios = [1, 2., .5]
    plt.subplot(121)
    n = 10
    feature = np.arange(n*n*6).reshape(n, n, -1)
    print(feature.shape)
    anchors = sample_one_feature(feature, sizes, ratios)
    print(anchors.shape)
    draw_one_pixel(feature, (4, 4), anchors)
    
    plt.subplot(122)
    n = 40
    feature = np.arange(n*n*6).reshape(n, n, -1)
    print(feature.shape)
    anchors = sample_one_feature(feature, sizes, ratios)
    print(anchors.shape)
    draw_one_pixel(feature, (16, 16), anchors)
Fig. 3. 测试代码输出

  注意:上面的可视化再次显示出,anchor的尺寸是随着grid的尺寸成比例变化的。即,feature map越小,gird越少,anchor覆盖面积越大。进一步,小feature map是为了检测大目标,而大的feature map是为了检测小目标。

  现在再来熟悉下Mxnet中C++实现的MultiBoxPrior接口:


Fig. 4. MultiBoxPrior函数实现Anchors Sampling

可见MultiBoxPrior的输出已经被reshape过了,维度为(batchSize, total_num_anchors, 4)

  另外这里有一个重要的问题待后续解决:sizes如何预定义。YOLO中的Anchor直接通过cluster获得,SSD呢?

2.2 预测物体类别——class_predictor

  SSD的一个重要特点就是,其在Faster-RCNN RPN的基础上,将二分类的Softmax直接扩展为n+1分类,其中n代表类别,1代表是否包含物体。这里cls_predictor就是将每个scale的feature送入一个Conv layer,该Conv要保证输出的特征通道数为num_anchors*(num_classes+1),即每个通道对应一个anchor对 某个类别的置信度。具体见下面描述:

Fig. 5. 使用卷积代替FC作为分类头

  • 注意,这里存放class_probabilities的形式和YOLO稍有不同,YOLO是直接将每个grid cell用FC回归到指定长度向量,向量每个位置代表啥都是很清楚的。SSD由于不使用FC回归,而是用"same" conv,因此得到的不是一个长向量,而是一个维度为[batchSize, (numClasses+1) * numAnchors, h_feature, w_feature]的特征图。其中的含义如下:每一个h_feature x w_feature大小的通道存放了所有像素点的某一个anchor对某个类的预测概率。所以所有的class probabilities总共有h_feature x w_feature x (numClasses+1) * numAnchors个数值。
from mxnet.gluon import nn
import mxnet as mx

def cls_predictor(num_anchors, num_classes):
    return nn.Conv2D(num_anchors*(num_classes+1), 3, 1, 1)

2.3 预测边界框——box_predictor

  同理,将每个scale的feature送入一个新的Conv layer,该Conv只需要保证输出channel数为num_anchors * 4.

Fig. 6. 使用卷积代替FC实现回归
def box_predictor(num_anchors):
    return nn.Conv2D(num_anchors*4, 3, 1, 1)

2.4 减半(下采样)模块

  接下来定义减半模块。这里直接通过两个'same'卷积后接一个MaxPooling实现. 这种连续多个conv组成的小block中有一个小细节: 往往最后一个Conv负责输出指定的通道数, 也就是说前面几个Conv其实通道数是可以改变的。这时候一个选项就是把前面第一个卷即块先通过1x1 Conv减少通道数,然后接下来的Conv都使用这个小通道数, 操作到最后一个Conv时再把通道数升上来, 这样相比一开始的Conv就带着目标通道数进行一系列连续运算, 参数量可以减少一些. 这个就是Residual Block Bottleneck的想法. 不过这里先按教程中的做法来搭建.

Fig. 7. 两个ConvBlock实现空间维度减半
def downsample_block(channels):
    block = nn.HybridSequential()
    for _ in range(2):
        block.add(
            nn.Conv2D(channels, 3, 1, 1),
            nn.BatchNorm(),
            nn.Activation('relu')
        )
    block.add(nn.MaxPool2D(strides=2))
    return block


  • 定义好了三种基本网络块, 现在有两个很自然的问题:
    这三个模块之间是如何连接的?
    ②不同scale上的两个Conv head的输出应该如何组合到一起, 得到最后的, 要送入loss函数的形式?

  • 问题答案如下:
      主干网络和若干downsample block是首尾相连的,然后每个scale上的Conv heads(box_predictor和cls_predictor)均与主干网络(或downsample)在当前尺度下的输出特征图相连接,这些Conv heads之间没有连接。在模型前向传播过程中,每个scale经过Conv heads之后,会得到两个代表anchors的class probability和box的tensor,我们需要将每个scale的这两个tensor分别(按某种维度)Concat到一起,最终得到两个较大的tensor,分别代表多尺度anchors的class probability和box信息,最后我们将这两个合并之后的tensor送入loss函数。

  SSD中会在多个尺度上进行预测。由于每个尺度上的输入高宽和锚框的选取不一样,导致其形状各不相同。下面例子中我们构造两个尺度的输入,其中第二个为第一个的高宽减半。然后构造两个类别预测层,其分别对每个输入像素构造5个和3个锚框。

Fig. 8. 不同尺度cls_preds的维度信息. 进行Concat时, 往往需要找到相同shape的维度

  可见, 预测的输出格式为(批量大小,通道数,高,宽)。可以看到除了批量大小外,其他维度大小均不一样。因此Concat时可以利用的维度就只有第一个维度. 故我们需要考虑如何将它们变形成统一的格式, 进而将多尺度的输出合并起来,让后续的处理变得简单。

  我们首先将通道,即预测结果,放到最后(这里是因为通常分类任务习惯将class probability放到最后一维,这里不放也没关系)其实这里我不知道为啥要调整下把通道放最后一维(可能是为了适应其他接口?),根据2.2部分下面的分析,除了第一维代表batchSize之外,其他所有维度乘起来代表的就是总共的class probabilities。因为不同尺度下批量大小保持不变,所以将结果转成二维的(批量大小,高 × 宽 × 通道数)格式,方便之后的拼接。

  然后将多个尺度的preds按最后一个维度拼接就可以了.

def flatten_pred(pred):
    return pred.transpose(axes=(0, 2, 3, 1)).flatten()

def concat_preds(preds_multi_scales, F=mx.ndarray):
    return F.concat(*[flatten_pred(pred) for pred in preds_multi_scales], dim=1)

## check
concat_preds([y1, y2]).shape
[Out]: (2, 25300)

2.5 完整模型

2.5.1 定义主干网络

  这里先用一个简单的主干网络与上述部件结合起来, 搭建一个完整的ToySSD模型. 后面的实验中可以再尝试不同的主干网络, 如ResNet50.

  直接用连续三个下采样模块构成主干网络, 实现对输入图像进行8倍下采样:

def body_block(channel_list=[16, 32, 64]):
    net = nn.HybridSequential()
    for channels in channel_list:
        net.add(downsample_block(channels))
    return net
2.5.2 一个"容器"模型

  在2.4中我们已经讨论过模型各个部件的关系了。为了代码简洁,我们定义一个额外的函数将所有组件放到一个Block(或者直接放到一个list中)。注意:虽然这个Block是一个单独的HybridBlock对象,但是和之前定义的完整网络不一样,该对象只是一个容器将所有组件存放起来,看做list即可(之所以可以当list使用,是利用HybridSequential可以直接unpack的特点


  对比SSD和前面练习的分类网络, 一个有趣的地方是, 分类任务通常可以一气呵成地定义一个完整模型, 然后前向传播直接pred = model(X); 而SSD并不是一个传统的前后相连的网络模型, 而是由几个相对独立的部件构成, 理论上我们只需要先把不同的组件放到同一个容器中保存, 然后前向传播时在各个尺度分别输出预测结果。 换句话说, 其实我们并没有"定义"模型, 只是定义了部件, 前向传播过程需要我们具体定义这些部件功能

  理论上这个用于存储各部件的容器是很宽泛的, 比如我们可以用list或者tuple, 只要到时候能够unpack取出各个组件进行前向传播就可以了. 不过一个实际问题在于, 如果用这种方式, 返回的代表模型的"容器"只能在unpack之后对每个部件分别初始化, 显然会使代码显得比较繁冗. 故我当时放弃了下面这种写法, 尽管这种写法更能体现出SSD这种松散的结构特点:


Fig. 9. SSD的模型本质上是一个保存组件的容器

  经过上面分析, 我们可以把各个部件放到同一个HybridSequential对象中, 尽管这些部件之间的连接看起来是序贯连接(依次放入了model.add函数作为参数), 实际上这个model只是一个容器而已. 其优势在于: ①可以像一般容器一样unpack; ②初始化时只需要调用model.initialize()即可初始化所有部件.

def ssd_model(num_anchors, num_classes):
    downsamplers, cls_predictors, box_predictors = nn.HybridSequential(), nn.HybridSequential(), nn.HybridSequential()
    for _ in range(3):
        downsamplers.add(downsample_block(128))
    for _ in range(5):
        cls_predictors.add(cls_predictor(num_anchors, num_classes))
        box_predictors.add(box_predictor(num_anchors))
    
    model = nn.HybridSequential()
    model.add(body_block([16, 32, 64]), cls_predictors, box_predictors, downsamplers)
    return model

  这种网络组织的方式非常有趣, 但是仔细一想又很自然, 因为HybridSequential对象本身就支持索引, 有一些list的特性, 故可以unpack出各个组件也是容易理解的。我们可以通过下面的验证进一步确定这一点:

t_model = ssd_model(5, 2)
print(len(t_model))
[Out]: 4
2.5.3 定义前向传播

  由于反向传播不需要我们考虑, 模型的前向传播就成为整个网络最重要的部分。我们需要将前面定义的部件都整合起来。

  该前向传播函数需要接收的输入: ①上面的"容器"模型; ②输入图像矩阵; ③sizes和ratios, 用于逐个scale中sample anchors, 而sample出的anchor用于和当前batch的labels比较IOU, 进而生成当前batch的training targets(sample anchor应该在前向传播中完成, training_targets的获取可以放在后面就行)。

   该前向传播函数的输出: ①所有scale的anchors②所有scale的cls_preds③所有scale的box_preds。

  根据接口的输入输出容易定义出如下ssd_forward函数:

from mxnet.contrib.ndarray import MultiBoxPrior
def ssd_forward(model, x, sizes, ratios, F=mx.ndarray, verbose=True):
    # !!注意别写成 backbone, cls_predictors, box_predictors, downsamplers = ssd_model(5, 2)了...
    backbone, cls_predictors, box_predictors, downsamplers = model
    x = backbone(x)
    anchors, cls_preds, box_preds = [], [], []
    #anchors, cls_preds, box_preds = [None]*5, [None]*5, [None]*5
    for i in range(5):
        # MultiBoxPrior: (batch_size, num_total_anchors_this_scale, 4)
        anchors.append(MultiBoxPrior(x, sizes[i], ratios[i]))
        cls_preds.append(cls_predictors[i](x))
        box_preds.append(box_predictors[i](x))
        if verbose:
            print('Predict scale {}, {} with {} anchors.'.format(i, x.shape, anchors[-1].shape[1]))
        if i < 3:
            x = downsamplers[i](x)
        elif i == 3:
            x = nn.GlobalMaxPool2D()(x)
        ## i=4时用的是GAP之后的feature,到这里就不用管了,所以没有else
        
    # 注意concat anchors时,将各个尺度的anchors按num_anchors那个维度拼接。得到的结果是(batchSize, num_total_anchors, 4)
    return (F.concat(*anchors, dim=1), concat_preds(cls_preds, F), concat_preds(box_preds, F))
  • 注意: 这个函数中我设置了一个参数F, 是希望将nd的操作扩展为Symble和NDArray公有的操作, 从而在模型定义完, 调试无误之后使用model.hybridize()接口
Fig. 10. 测试前向传播函数, 注意观察输出的维度
  • 进一步, 将ssd_model和ssd_forward封装到一个类中:
class ToySSD(nn.HybridBlock):
    def __init__(self, num_classes, **kwags):
        super(ToySSD, self).__init__(**kwags)
        # 思考:sizes是怎么得到的??
        self.sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619], 
                      [0.71, 0.79], [0.88, 0.961]]
        self.ratios = [[1, 2, 0.5]] * 5
        self.num_classes = num_classes
        num_anchors = len(self.sizes[0]) + len(self.ratios[0]) - 1
        with self.name_scope():
            self.model = ssd_model(num_anchors, num_classes)
            
    def hybrid_forward(self, F, x):
        anchors, cls_preds, box_preds = ssd_forward(self.model, x, self.sizes, self.ratios, F, verbose=False)
        # 这里还需要额外的一步:将cls_preds整理成最后一个维度代表probability的形式。这样维度变成:
        # (batchSize, num_total_anchors, class_prob_each_anchor)
        cls_preds = cls_preds.reshape(0, -1, self.num_classes+1)
        return anchors, cls_preds, box_preds

  • 这段代码中值得注意的是, 最后需要额外的一步, 将cls_preds整理成(batchSize, num_total_anchors, class_proba_each_anchor)的形式。这是分类任务中一个必要的步骤: 将代表class_probability的tensor单独放到一个维度(通常是最后一维), 以配合cls_loss = SoftmaxCrossEntropyLoss(axis=-1)一起使用。语义分割中这个问题会更明显, 因为目标是对每个像素进行分类, 而FCN最后一个转置卷积层的输出维度是(batchSize, numClasses, height, width), 即原本代表通道的那一维(第1维)存放类别信息, 因此loss用的是SoftmaxCrossEntropyLoss(axis=1)。

  • 总而言之, 计算机视觉任务中, 网络输出的predict_tensor可能是高度浓缩的, 因此搞清楚哪个维度代表了什么含义, 哪一维应该拿出来求交叉熵, 是很重要的。

三、总结与遗留

  这篇博客记录了SSD的学习笔记, 除了李沐老师讲的知识之外, 加上了一些自己对contrib库函数的探索, 以及学习过程中的思考。总结下, 这篇博客主要涉及一下几个有趣的点, 以及几个遗留问题:

3.1 几个有趣的点:

  • 在Anchor Sampling阶段, 每个尺度的特征图都直接映射到原图大小(Fig. 1), 特征图上每个像素点Sample出的anchor尺寸和grid尺寸成正比, 和特征图大小成反比, 即大特征图用于检测小目标, 小特征图用于检测大目标

  • SSD和FasterRCNN采集Anchor的数目不一样多, SSD是n+m-1个。

  • SSD直接将各个尺度的特征图通过两个Conv head回归到分别代表box_preds和num_classes+1分类的cls_preds上。

  • "容器"模型的思想: 模型并不一定要是严格的连接关系, 甚至有时候并没有严格定义的模型, 只是为了初始化方便将几个部件放到同一个HybridSequential中。只要前向传播能unpack出各个部件, 实现预期输出即可。

  • Concat时往往先找公共维度, 如果很多维度尺寸不一样, 可以考虑乘到一起, 再利用公共维度进行拼接。

  • 定义模型时, 如果用到NDArray的接口, 想办法用F代替, 这样或许可以用model.hybridize()。

  • 搞清楚输出tensor的哪一维要拿出来求交叉熵, 为什么是这一维!!

3.2 遗留问题:

  • SSD的sizes如何选择?

  • 每个scale的cls_pred以及box_pred为啥要先用same卷积再flatten?可以用fc head吗?
    答:如果用fc的话可能要求每个scale feature map维度固定。

  • 关于每个scale的cls_predictior输出维度的含义。为什么flatten前要将代表通道的维度放到最后面?

[1]: DeepLearning for CV with Python. ImageNet Bundle.
[2]: 动手学深度学习

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

推荐阅读更多精彩内容