OpenCV-10-轮廓

1 摘要

尽管如Canny边缘检测器等算法能够用于寻找图像中分割不同区域的边缘像素,但是这些算法并未将这些边缘像素看作为一个整体,从而揭露更多的信息。通常下一步需要将这些边缘像素组装成为轮廓,OpneCV中实现该功能的函数是cv::findCountours()。在本章开始我们将先介绍一些在使用该函数之前需要知道的基本知识,然后再介绍该函数的使用方法,最后介绍通过计算出的轮廓能够实现的更复杂的计算机视觉任务。

2 轮廓查找

轮廓是一系列点点合集,它以某种方式表示图像中的一条曲线。在不同环境下其表示方式也不同,在OpenCV中使用标准模版库的向量容器vector<>表示,向量中的每个元素都包含曲线中下一个点的位置信息。尽管2维点坐标组成的序列(vector<cv::Point>, vector<cv::Point2f>)是最常见的表示方法,但是还有其他方法可以表示轮廓。如在Freeman Chain中,每个点表示的就是相对于前一个点在某个特定方向上的位移,在后文遇到这些方法的时候还会详细介绍。现在只需要知道轮廓几乎总是以标准模版库的向量容器表示的,但是其中的元素不一定是最常用的cv::Point

函数cv::findCountours()可以处理由Canny边缘检测器得到的结果,也可以处理通过阈值函数cv::threshold()cv::adaptiveThreshold()得到的二值图,对于后者情况而言得到的轮廓将是1值和非0值的边界,处理边缘图像和二值图像是有一定差异的,详细信息将在下文介绍。

2.1 轮廓层次

在介绍如何提取轮廓之前还是有必须先了解轮廓是什么,以及轮廓组之间的是如何关联的。尤其值得关注的是轮廓树(Contour Tree)的概念,它对理解函数cv::findCountours()很重要。在下图中,左侧是一副测试图像,它由几块通过A-E表示的颜色的区域和白色背景组成。右上角是通过函数cv::findCountours()找到的轮廓,这些轮廓通过cX或者hX的标签表示。其中C表示的是轮廓Countour(这里先理解为颜色区域的外轮廓),它表示边缘围成的区域外部是白色。而H表示Hole(这里先理解为颜色区域的内轮廓),它表示边缘围城的区域外部是暗色部分。

包含的概念在很多应用中都很重要,因此OpenCV可以支持输出输出如上图右下角表示的轮廓树,该结构包含了轮廓之间的包含关系。在该轮廓树中,c0轮廓为根节点,它直接包含的轮廓h00和h01是它的子节点,然后依次表示出测试图中的所有轮廓。当然轮廓层次的组织方式处理轮廓树外还有其他的方式,将在下文介绍。

表示树结构的方式有很多,在OpenCV中是使用由向量元素组成的向量来表示,每个向量元素的类型为cv::Vec4i。每个向量元素的子元素都有特殊的含义,都表示与当前索引对应的轮廓有某种关系的轮廓的索引。如果子元素对应的特殊关系的轮廓不存在,则该子元素的值为-1。例如在上图的轮廓树结构中,根节点即索引值为0的节点是没有父节点的,即没有包含该轮廓的轮廓,因此该向量元素表示父节点的子元素将被设置为1。

向量不同索引位置的子元素表示的映射关系如下。

子元素的索引 该位置的值指向的轮廓索引和当前轮廓的关系
0 同层下一个轮廓
1 同层上一个轮廓
2 下一层的第一个轮廓
3 上层轮廓,即父节点轮廓

此时再看上图中右下角的轮廓树,节点之间的连线都表示在轮廓树输出向量中,向量内每个元素的对应索引位置子元素值指向的节点。

需要注意的是使用函数cv::findCountours()处理通过函数cv::canny()等边缘检测函数得到的结果,与处理如上图中的二值测试图像不同的是,轮廓查找函数并不能将边缘图像中的白色的曲线识别为轮廓,而是识别成狭小的色块,因此在得到每条外部轮廓时几乎总能同时得到一条内部轮廓,你可以将它看作是白色到黑色区域到过度,标识着边缘的外部边界。

2.2 提取轮廓

OpenCV中提供的构建轮廓的函数原型如下,需要注意构建轮廓的同时允许生成轮廓结构,轮廓结构的表示方式不限于轮廓树。

// image:待提取轮廓的图像,8位单通道图像
// contours:检测到的轮廓,包含STL向量元素的STL向量,其中每个向量元素包含一个轮廓的所有点,
//           具体点的组织方式和method相关,下文介绍
// hierarchy:轮廓的层级信息,具体含义和mode相关,下文介绍
// mode:轮廓层级构建的方法,下文介绍
// method:轮廓表达的方法,下文介绍
// offset:检测到的轮廓没个点上施加的位移量
void cv::findContours(cv::InputOutputArray image,
                      cv::OutputArrayOfArrays contours, cv::OutputArray hierarchy,
                      int mode, int method, cv::Point offset = cv::Point());

void cv::findContours(cv::InputOutputArray image,
                      cv::OutputArrayOfArrays contours,
                      int mode, int method, cv::Point offset = cv::Point());

输入图像image必须是单通道,数据类型为8U,它会被处理成二值图像,即所有非零像素含义都相同。函数运行后会修改该图像的数据,因此如果你还需要使用该图像,请拷贝一份图像作为函数参数。

参数hierarchy为检测到的轮廓构建出的层级关系,他是一个STL的向量,其中的每个元素都是vec4i数据,hierarchy[i]表示与contours[i]表示的轮廓直接连接的轮廓信息,每个vec4i数据的子元素都表示了对应关系连接到的轮廓的索引。每个子元素表示的映射关系在上文的表中已经描述。

参数mode指定了轮廓提取的方式,可选的值有如下4种。当指定为cv::RETR_EXTERNAL表示只提取最外层的轮廓,因此对于测试图像而言只有一个最外层轮廓,因此在下图的轮廓层级关系中该轮廓也没有任何相连的轮廓。

当指定为cv::RETR_LIST时表示提取所有轮廓并以列表的方式组织,如在下图中将上图测试图像中的轮廓所有层都压缩为单一层,并且轮廓依序连接,参数hierarchy中的每个元素的vec4i数据的第1个和第2个子元素被用于表示相互连接的节点。需要注意在新版本的OpenCV中不推荐使用该方法,因为contours参数包含的数据是通过向量组织的,本身就可以看作是一个列表。

当指定为cv::RETR_CCOMP时,轮廓将被分为两层,其中所有外轮廓依序排列在上层,内轮廓在下层。外轮廓包含的第一个直属轮廓使用vec4i数据的第3和第4个子元素表示,同层的内轮廓或外轮廓之间使用vec4i数据的第1和第2个子元素表示。

当指定为cv::RETR_TREE时表示使用轮廓树组织所有的轮廓,此时最外层的轮廓在第一层,向下包含其内部的内轮廓或者外轮廓,并使用vec4i数据的第3和第4个子元素表示连接关系。某个外轮廓包含的直属内轮廓之间使用vec4i数据的第1和第2个子元素表示,如下图中的1号和2号内轮廓都时0号外轮廓的直属内轮廓。

参数method决定了参数contours返回的轮廓点是如何组织的,其可选值如下。当设置为cv::CHAIN_APPROX_NONE时会返回轮廓的所有点,设置为该选项时将会得到大量的顶点。当设置为cv::CHAIN_APPROX_SIMPLE时只包含线段的端点,在大多数情况下这种方式都能减少返回的顶点树,在轮廓为矩形的极端场景下,设置改选项后只会返回矩阵的四个顶点。当设置为cv::CHAIN_APPROX_TC89_L1或者cv::CHAIN_APPROX_TC89_KC05时表示使用对应的Teh-Chin链逼近算法。如果感兴趣算法的具体实现可以阅读论文《On the Dectation of Dominant Points on Digital Curve》,由于该算法的实现不受参数影响因此这里不详细介绍。该算法更复杂并且计算量更大,但是对于通用的曲线它可以有效的降低返回的顶点数量。

参数offset用于平移最终计算得到的顶点坐标,当你在一副图像的局部提取到轮廓后,想将结果转换到整幅图片的坐标系中,或者相反情况下你想将全局坐标转换到局部坐标系下时,可以使用该参数。

2.3 绘制轮廓

当得到轮廓数据后可能最想做的事情就是绘制出这些轮廓,OpenCV中绘制轮廓的函数原型如下。

// image:轮廓绘制的背景图片
// contours:轮廓数据,包含多组轮廓点的STL向量的STL向量
// contourIdx:需要绘制的轮廓索引,设置为-1时绘制所有轮廓,但是还是会受参数maxLevel的限制
// color:轮廓绘制的颜色
// thickness:轮廓绘制的线宽,设置为-1时会填充轮廓
// lineType:线段链接类型,4邻域或者8邻域或者抗拒值cv:AA
// hierarchy:轮廓层级信息,通过函数findContours获取
// maxLevel:绘制的最大轮廓层级,下文介绍
// offset:轮廓点的偏移量
void cv::drawContours(cv::InputOutputArray image,
                      cv::InputArrayOfArrays contours, int contourIdx,
                      const cv::Scalar& color, int thickness = 1, int lineType = 8,
                      cv::InputArray hierarchy = noArray(), int maxLevel = INT_MAX,
                      cv::Point offset = cv::Point())

参数hierarchy提供了轮廓的层级信息,而maxLevel可以指定绘制的轮廓层级,即从contourIdx指定的层向下再绘制maxLevel层。当你使用cv::RETR_TREE表示轮廓层级信息时使用这两个参数组合能够绘制出你想要的轮廓。另外在使用cv::RETR_CCOMP组织轮廓层级时,使用这两个参数组合也能很容易的绘制出外轮廓而忽略内轮廓。

示例程序TrackBarContour通过一个滑动条设置一个简单阈值,然后从阈值处理后的图像中提取轮廓,其核心代码如下。

// 原图灰度图
cv::Mat g_gray;
// 阈值处理后的二值图,用于查询轮廓
cv::Mat g_binary;
// 阈值处理使用的阈值
int g_thresh = 100;

// 滚动条的事件回调函数
void on_trackbar(int, void *) {
    // 生成二值图
    cv::threshold(g_gray, g_binary, g_thresh, 255, cv::THRESH_BINARY);
    cv::imshow("Binary", g_binary);

    // 获取轮廓
    std::vector<std::vector<cv::Point>> contours;
    cv::findContours(g_binary, contours, cv::noArray(), cv::RETR_LIST, 
                     cv::CHAIN_APPROX_SIMPLE);
    
    // 清空二值图
    g_binary = cv::Scalar::all(0);
    // 绘制轮廓
    cv::drawContours(g_binary, contours, -1, cv::Scalar::all(255));
    cv::imshow("Contours", g_binary);
}

int main(int argc, const char * argv[]) {
    // 读取原始图像
    g_gray = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
    cv::imshow("Original", g_gray);
    
    // 创建UI控件
    cv::namedWindow("Contours", 1);
    cv::createTrackbar("Threshold", "Contours", &g_thresh, 255, on_trackbar);
    // 手动调用滑动条触发函数
    on_trackbar(g_thresh, nullptr);
    // 挂起程序,等待用户输入事件
    cv::waitKey();
    
    return 0;
}

该程序使用默认阈值运行后显示的原图、二值图和轮廓图分别如下。

示例程序ContourPer首先查找了图像中的所有轮廓,并计算每个轮廓的面积并根据面积排序,通过用户键盘输入事件控制每次只绘制一条轮廓。在该示例程序中可以通过修改查找轮廓函数cv::findContours()的参数mode控制轮廓层级的组织方式,轮廓绘制函数cv::drawContours()的参数maxLevel控制轮廓绘制的层级来更好理解这两个参数的组合效果。程序的核心代码如下。

// 用于比较两个轮廓的结构体
struct AreaCmp {
public:
    // 构造函数
    AreaCmp(const std::vector<float>& _areas) : areas(&_areas) {}
    // 重载std::sort()函数需要使用到的运算符
    bool operator()(int a, int b) const {
        return (*areas)[a] > (*areas)[b];
    }
private:
    // 保存所有轮廓的区域
    const std::vector<float>* areas;
};

int main(int argc, const char * argv[]) {
    // 加载图片
    cv::Mat img = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
    // 得到阈值图像
    cv::Mat img_edge;
    cv::threshold(img, img_edge, 128, 255, cv::THRESH_BINARY);
    cv::imshow("Image after threshold", img_edge);
    
    // 查找轮廓
    std::vector<std::vector<cv::Point>> contours;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(img_edge, contours, hierarchy, cv::RETR_LIST, 
                     cv::CHAIN_APPROX_SIMPLE);

    // 根据轮廓的面积降序排序
    std::vector<int> sortIdx(contours.size());
    std::vector<float> areas(contours.size());
    for (int n = 0; n < (int)contours.size(); n++) {
        sortIdx[n] = n;
        areas[n] = cv::contourArea(contours[n], false);
    }
    std::sort(sortIdx.begin(), sortIdx.end(), AreaCmp(areas));
    
    // 绘制单条轮廓
    cv::Mat img_color;
    for (int n = 0; n < (int)sortIdx.size(); n++) {
        int idx = sortIdx[n];
        cv::cvtColor(img, img_color, cv::COLOR_GRAY2BGR);
        // Try different values of max_level, and see what happens
        cv::drawContours(img_color, contours, idx,
                         cv::Scalar(0,0,255), 2, 8, hierarchy, 0);
        cv::imshow(argv[0], img_color);
        int key = cv::waitKey();
        // 如果输入ESC键,则退出循环
        if ((key & 255) == 27) {
            break;
        }
    }

    return 0;
}

示例程序的运行结果如下图,分别是原图、阈值图和绘制的轮廓图。

2.4 快速连通区域分析

与轮廓区域紧密相关的另一个方法是联通区域分析(Connected Component Analysis)。使用某些方法特别是阈值法分割图像后,可以使用联通区域分析方法处理和分离生成图像中的区域。OpenCV提供的连通区域分析法的输入是一张二值图像,输出带标记的像素图,其中同一个连通区域内的非零向量会分配到相同的唯一标记。例如在本章开始的例子给出的附图中共存在5个连通区域,其中最大的一个区域包含两个孔,次大的两个区域各包含一个孔,最小的两个区域不包含孔。连通区域分析发在背景分割算法中常作为后处理滤镜,用于移除小的噪声块(即大轮廓中的微小闭合轮廓可以被认为是噪声块,它们可以都被处理成为背景)。另外在一些已知待提取前景区域的算法如OCR中,连通区域分析算法中也常被使用。

当然除了使用OpenCV提供的连通区域分析函数外,可以使用函数cv::findContours()并将轮廓组织方式设置为设置cv::RETR_CCOMP提取轮廓(一条外轮廓扣除掉内部的轮廓包围区域后就是一个连通区域),然后在得到的连通区域上循环调用函数cv::drawContours()并设置填充颜色为连通区域的标记,并将线宽设置为-1。这种方式效率更低,主要包含以下几个原因。

  • 函数cv::findContours()需要为每个轮廓创建一个标准库向量,而一副图片中包含的轮廓可以是几百条、甚至几千条。
  • 当想要填充一个非凸区域时,函数cv::drawContorus()的效率也很低,需要根据轮廓构建并排序围绕该区域 的所有细小线段。
  • 收集连通区域的一些如面积和围绕矩形等基本信息也需要额外的,有时甚至是昂贵的函数调用。

OpenCV提供的连通区域分析函数能够快速的帮助我们快速的实现上述冗长复杂的逻辑,其函数原型如下。

// 返回值:连通区域的数量
// image:待分析的二值图像,单通道,数据类型为8U
// labels:连通区域分析的结果
// connectivity:连通性判定的方法,4邻域或者8邻域
// ltype:分析结果矩阵的元素基本数据类型,可以是CV_32S或者CV_16U
int cv::connectedComponents(cv::InputArrayn image, cv::OutputArray labels,
                            int connectivity = 8, int ltype = CV_32S);

// stats:统计结果,N✖️5矩阵,N和连通区域数量相同,5列分别表示包围连通区域的最小矩形
//        的(顶点坐标x,y,宽,高,面积)
// centroids:质心统计结果,N✖️2矩阵,基本数据类型为CV_64F,2列分别表示质心坐标x,y
//            如果不需要返回质心,传入cv::noArray()
int cv::connectedComponentsWithStats(cv::InputArrayn image, cv::OutputArray labels,
                                     cv::OutputArray stats,
                                     cv::OutputArray centroids,
                                     int connectivity = 8, int ltype = CV_32S);

该函数内部不会调用函数cv::findContours()cv::drawContorus(),而是使用一种高效算法直接分析连通区域,该算法发表在论文《Two Strategies to Speed Up Connected Component Labeling Algorithms》中。

示例ConnectedComponents绘制了带标记的连通区域,并移除了其中面积较小的元素,其核心代码如下。

int main(int argc, const char * argv[]) {
    // 加载原始图片
    cv::Mat img = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
    cv::imshow("Source Image", img);
    
    // 生成阈值图
    cv::Mat img_edge;
    cv::threshold(img, img_edge, 128, 255, cv::THRESH_BINARY);
    cv::imshow("Image after threshold", img_edge);
    
    // 分析连通区域
    cv::Mat labels, stats, centroids;
    int nccomps = cv::connectedComponentsWithStats(img_edge, labels, 
                                                   stats, centroids);
    std::cout << "Total Connected Components Detected: " << nccomps << std::endl;

    // 为每个连通区域分配一个随机颜色,labels中的标记对应为颜色表内的索引
    std::vector<cv::Vec3b> colors(nccomps + 1);
    // label为0的连通区域是背景区域(即在待分析图像中就是黑色部分),设置为黑色
    colors[0] = cv::Vec3b(0,0,0);
    for (int i = 1; i <= nccomps; i++) {
        // 面积如果小于100,则设置为黑色
        if (stats.at<int>(i-1, cv::CC_STAT_AREA) < 100) {
            colors[i] = cv::Vec3b(0,0,0);
        } else {
            colors[i] = cv::Vec3b(rand()%256, rand()%256, rand()%256);
        }
    }

    // 绘制连通区域分析结果图像
    cv::Mat img_color = cv::Mat::zeros(img.size(), CV_8UC3);
    for (int y = 0; y < img_color.rows; y++) {
        for (int x = 0; x < img_color.cols; x++) {
            int label = labels.at<int>(y, x);
            CV_Assert(0 <= label && label <= nccomps);
            img_color.at<cv::Vec3b>(y, x) = colors[label];
        }
    }

    // 展示连通区域分析结果
    cv::imshow("Labeled map", img_color);

    return 0;
}

该示例程序的运行结果如下图,从坐至右分别是原始图像,阈值处理后的图像,连通区域分析后的着色图像。

3 深入轮廓

图像的轮廓数据分析出来后,我们可能对其中的部分轮廓感兴趣。想要简化它们,或者计算它们的近似几何形状,将其与模版图形进行匹配,以及做一些其他操作。本小节会介绍一些和图像轮廓相关的复杂计算机视觉任务,并介绍一些OpenCV提供的函数,这些函数有的直接实现了这些图像处理任务,其他的可以简化些图像处理任务。

3.1 近似多边形

通常在处理某条轮廓的时候会计算其对应的包含更少顶点的近似多边形,OpenCV提供两种方式实现该任务,其中函数cv::approxPolyDP()原型如下。该函数是Douglas-Peucker(DP)逼近算法的实现。与之对应的常用算法还有Rosenfeld-Johnson和Teh-Chin算法。这两种算法中,Teh-Chin算法在OpenCV中不能用于缩减顶点,但是可以在提取轮廓时使用,详情可以参考前文函数cv::findContours()的介绍。

// curve:待处理的轮廓,2维坐标集合,可以使用N✖️1的双通道矩阵对象,或者包含cv:Point
//        元素的标准向量
// approxCurve:近似多边形的轮廓,可以使用矩阵或者向量,但是需要和参数curve一致
// epsilon:原始轮廓到近似多边形轮廓允许的最大位移
// closed:是否需要闭合轮廓,即从轮廓的最后一个顶点链接到第一个顶点形成闭合曲线
void cv::approxPolyDP(cv::InputArray curve, cv::OutputArray approxCurve,
                      double epsilon, bool closed);

更好的理解Douglas-Peucker算法可以加深对函数cv::approxPolyDP()的理解,也能帮助我们选择合适的epsilon参数。该算法原理如下图所示,对于b图中给定的轮廓,选择最远的两个点连接相连的到c图中的一条直线,然后再从原来的轮廓点里选择离该线最远点并更新轮廓得到d图,随后算法继续迭代得到e图,最后当原始轮廓中的所有顶点到近似多边形的某条边的距离都小于参数epsilon值的时候算法停止迭代,输出最终得到的近似多边形。

因此epsilon建议设置为轮廓的轴长,或者轮廓外包矩形周长或者其他能够表示轮廓整体大小的一个分量。

3.2 特征计算

在处理轮廓时通常需要计算其各种特征,如边长,轮廓矩(Contour Moments)等,轮廓矩可以用于概括轮廓的大致形状特征。OpenCV提供了一些列函数用于计算这些特征,它们不仅适用于表示曲线的点集,如计算边长,也可以用于无任何含义的点集,如计算最小包含矩形。

3.2.1 轮廓长度

OpenCV计算轮廓长度的函数原型如下,需要注意该函数只对表示轮廓的点集才有意义。

// 返回值:轮廓的长度
// points:待分析的轮廓,N✖️1的双通道矩阵,或者STL向量
// closed:轮廓是否闭合,即是否需要将轮廓的首尾顶点相连
double cv::arcLength(cv::InputArray points, bool closed);
3.2.2 最小直立包围矩形

计算点集的最小直立包围矩形(即矩形的边一定水平或者竖直)函数原型如下,该函数适用于表示任何含义的顶点集合。

// 返回值:点集的最小包围矩形
// points:顶点几何,N✖️1的双通道矩阵,或者STL向量
cv::Rect cv::boundingRect(cv::InputArray points);
3.2.3 最小旋转包围矩形

OpenCV还支持查找包含点集的最小旋转矩形,即其边不需要一定水平和垂直。如在下图中左侧是寻找到的最小直立包围矩形,而右侧是寻找到到最小旋转包围矩形,该矩形到面积更小。图中cv::RotatedRect是专用于表示旋转矩形的数据结构。

本系列文章最初将数据结构时已经介绍过cv::RotatedRect,这里列出该类的定义如下,帮助我们回忆该数据结构。

class cv::RotatedRect {
    // 矩形中心点,也是矩形的旋转点
    cv::Point2f center;
    // 矩形的大小,相对于旋转中心的宽和高
    cv::Size2f size;
    // 矩形旋转的角度
    float angle;
};

计算最小旋转矩形的函数原型如下,该函数适用于表示任何含义的顶点集合。

// 返回值:技术得到的最小旋转矩形
// points:待分析的点集,可以是N✖️1的双通道矩阵,或者是STL向量
cv::RotatedRect cv::minAreaRect(cv::InputArray points);
3.2.4 最小包围圆

计算最小包围圆的函数原型如下,该函数适用于表示任何含义的顶点集合。

// points:待分析的点集,可以是N✖️1的双通道矩阵,或者是STL向量
// center:计算得到的圆心
// radius:计算得到的半径
void cv::minEnclosingCircle(cv::InputArray points,
                            cv::Point2f & center, float & radius);
3.2.5 最佳包围椭圆

需要注意这里的的最佳包围椭圆和前文的集中最小包围框不同,最佳包围椭圆不需要必须包含所有的点集。计算该椭圆的函数原型如下,该函数适用于表示任何含义的顶点集合。

// 返回值:表示椭圆的旋转矩形
// points:待分析的点集,可以是N✖️1的双通道矩阵,或者是STL向量
cv::RotatedRect cv::fitEllipse(cv::InputArray points);

计算最佳包围椭圆使用到了最小平方拟合函数(least-squares function),这里不对该函数详细介绍。函数的返回值是cv::RotatedRect的实例,它表示椭圆的最小矩形。例如在下图中从左至右分别是点集的最小包围圆,最佳包围椭圆,表示最佳包围椭圆的旋转矩形。

3.2.6 最佳拟合曲线

在很多时候,得到的轮廓是一条近似直线的点集,或者说是对直线的带噪声的采样。基于很多原因我们可能想要拟合出这条直线,因此也出现了很多拟合方法。OpenCV中通过使得成本函数(Cost Function)取得最小值来完成该任务,该函数定义如下。

其中θ表示的是定义直线的一系列参数,而xi表示的是第i个点,ri表示的是该点到由参数集合θ定义到直线之间的距离,而p(ri)则是定义单个点的距离成本,计算单个点距离成本的方式有很多。其中OpenCV提供的选项cv::DIST_L2就是了解基础统计学读者最属性的最小平方拟合法。当需要更精确的拟合结果时,如需要很好的处理异常数据点时,可以使用更复杂的成本计算方法。下表列出了OpenCV支持的成本计算方法及其数学公式,其中给出的C值为建议的参数。

拟合最佳去想的函数原型如下,该函数适用于表示任何含义的顶点集合。

// points:待分析的点集,可以是N✖️1的双通道/三通道矩阵,或者是STL向量
// line:拟合得到的直线端点,处理2D点集时使用Vec4f,处理3D点集时使用Vec6f,
//。     前半部分表示直线的方向,后半部分是直线上的一个点
// distType:成本计算方法,见上表
// param:成本计算公式中需要用到的参数C,见上表,设置为0时将会使用上表中的建议值
// reps:拟合曲线的点精度,常用1e-2
// aeps:拟合曲线的角度精度,常用1e-2
void cv::fitLine(cv::InputArray points, cv::OutputArray line,
                 int distType, double param, double reps, double aeps);
3.2.7 轮廓突包

突包指的是特殊的突出多边形,如下图C图多边形,其中任意三个相邻顶点组成的两个向量的内角都必须小于180度。而轮廓突包指的是从轮廓中提取的这种多边形,多边形的所以顶点都应该来自于表示轮廓的点集。如下图中的B图是从A图人像中提取的轮廓,而C图是根据B图计算的轮廓突包。

计算轮廓突包的原因可能有很多,其中一个就是当判断一个点是否位于复杂多边形内部时,先判断其是否位于从该复杂多边形提取的轮廓突包内部,这样能极大的加快程序整体效率。计算轮廓突包的函数原型如下,改函数只对表示轮廓的点集有意义。

// points:待分析的点集,可以是N✖️1的双通道矩阵,或者是STL向量
// hull:计算出的轮廓突包
// clockwise:输出顶点的方向,fase时为逆时针,ture为顺时针
// returnPoints:返回顶点坐标,还是在point中的索引
//              当参数hull类型为向量时,该参数被忽略,因为根据向量的数据类型为int或者是
//              cv::Point能够判断出你想要返回的是索引还是点坐标,当参数hull类型为
//              cv::Mat时,该参数必须正确设置
void cv::convexHull(cv::InputArray points, cv::OutputArray hull,
                    bool clockwise = false, bool returnPoints = true);

3.3 几何学测试

在处理围绕矩形以及其他表示轮廓整体形状的多边形时,通常需要执行一些如多边形重叠或者快速围绕矩形重叠检测,OpenCV提供了一些函数来处理这些任务。大多数和矩形相关的几何学测试功能都是通过矩形的数据结构类来提供的,如cv::Rect提供函数contains()用于测试某个点是否在矩形内部。

包含两个矩形的最小矩形可以通过代码rect1 | rect2计算,两个矩形的重叠矩形可以通过rect1 & rect2计算。但是对于旋转矩形cv::RotatedRect而言,OpenCV并没有提供相应的成员函数,但是OpenCV额外提供了一些函数来处理任意多边形。

3.3.1 测试点是否位于多边形内

测试点是否位于多边形内的函数原型如下。

// 返回值:点距离多边形边的最短距离,点位于多边形外时返回值为正,刚好位于多边形边上时返回值为0,
//       在多边形内部返回值为负
// contour:表示多边形的轮廓,二维点组成的N✖️1双通道矩阵或者是向量
// pt:测试点
// measureDist:是否需要精确返回距离,当其为ture时会返回精确的距离,否则返回1表示在多边形外,
//              0表示在多边形边上,-1表示在多边形内
double cv::pointPolygonTest(cv::InputArray contour, cv::Point2f pt,
                            bool measureDist);
3.3.2 测试轮廓是否为凸多边形

确定一个多边形是否为凸多边形是一个很常见的操作,这样做的原因可能有很多,但是最典型的一个原因就是OpenCV中的一些算法只能用于凸多边形,或者有些算法在处理凸多边形时可以极大简化算法逻辑,提升算法效率。处理该任务的函数原型如下,需要注意算法默认传入的多边形是闭合的,并且多边形的边不能相交。

// 返回值:待测试的多边形是否为凸多边形
// contour:表示多边形的轮廓,二维点组成的N✖️1双通道矩阵或者是向量
bool cv::isContourConvex(cv::InputArray contour);

4 匹配轮廓与图像

目前你应该已经了解了轮廓是什么以及如何使用OpneCV定义的轮廓对象,接下来将会介绍一些在实际工作中轮廓的使用示例。最常见的计算机视觉任务就是比较两个轮廓,或者是使用计算出的轮廓匹配模版。

4.1 矩

比较两个轮廓的最简单方式就是计算轮廓矩(Contour Moments),轮廓矩是表示一个轮廓、一副图像或者一个点集(为了方便下文统称对象)的某种高层次特征,其定义公式如下。

在上述公式中,矩mpq是对象中所有“像素值”的总和,每个像素的值都是其像素强度和系数xp和yq的乘积。在计算矩m00时,如果处理的是二值图像,则m00计算的就是图像中非零像素的个数,如果处理的是轮廓,则它计算的就是轮廓的长度,如果处理的是点集,则它计算的是点点数量。需要注意在处理轮廓时,如果先对轮廓进行光栅化处理,如使用函数cv::drawContours()绘制轮廓,然后再计算轮廓的矩,则得到的长度和直接计算轮廓矩得到的长度并不完全相同,当然在分辨率无限的情况下它们是相同的。

如果理解了m00,则很方便理解对于二值图像而言,m10/m00和m01/m00分别计算了非零像素的平均x值以及平均y值。术语矩与统计学相关,而更高阶的矩与统计学分布有关,如面积、均值和方差等。在这个背景下你可以将非二值图像的矩看作是二值图像矩的特殊形式,其每个像素包含多个值。

计算矩等函数原型如下。

// 返回值:待处理对象的矩
// points:待处理对象,可以是二维点集合(N✖️1或者1✖️N矩阵,或者STL向量),也可以是图像(M✖️N矩阵)
// binaryImage:是否为二值图像,选择false时像素强度会被看作是像素点“质量”
cv::Moments cv::moments(cv::InputArray points, bool binaryImage = false);

该函数在处理二维点集点时候不会将参数points包含点数据看做是离散点点,而是轮廓点顶点,这以为着如果你想只处理这些点时,需要使用这些点创建一张图像,再将其作为该函数点输入。参数binaryImage设置为YES时,所有非零值都将被识别为1,这对于处理应用阈值操作后的图像很有帮助,因为在这些图像中非零值可能会被设置为255或者其他值。cv::Moments是OpenCV表示矩的数据结构,其定义如下。

class Moments {
public:
double m00;                       // 0阶矩(x1)
double m10, m01;                  // 1阶矩(x2)
double m20, m11, m02;             // 2阶矩(x3)
double m30, m21, m12, m03;        // 3阶矩(x4)
double mu20, mu11, mu02;          // 2阶中心矩(central moments)(x3)下文介绍
double mu30, mu21, mu12, mu03;    // 3阶中心矩(x4)
double nu20, nu11, nu02;          // 2阶标准化中心矩(Hu invariant moments)(x3)下文介绍
double nu30, nu21, nu12, nu03;    // 3阶标准化中心矩(x4)

// 构造函数
Moments();
Moments(
double m00,
double m10, double m01,
double m20, double m11, double m02,
double m30, double m21, double m12, double m03
);

// 将老版本的CvMoments转换为新的C++对象
Moments( const CvMoments& moments );
// 重载运算符,将C++对象转换为老版本的老版本的CvMoments
operator CvMoments() const;
}

函数cv::moments()调用一次会计算到三阶(p + q <= 3)矩,并且会同时计算中心矩和标准化中心矩。

4.2 深入理解矩

矩能够描述轮廓的基本特征,也能作为参考比较两个轮廓。但是在实际场景中普通的矩,即在类Moments的定义中大部分以m和数字组合的属性并不是最好的比较标准。即对于两个相同的但是相互之间有位移,或者是有缩放,或者是发时旋转的两个对象而言,其普通矩计算结果并不相同。

4.2.1 中心矩平移不变性

对于形状相同但是发生位移的两个轮廓和图像,其m00矩的计算结果是相同的,但是高阶普通矩就不相同了。考虑m10矩计算的是对象包含像素的x坐标分量平均值,很明显如果对象发生位移这个值将会随之变化。显然这不是我们想要的结果,我们想要的是比较不同位置的对象能够得到相同对值,即判断标准具有平移不变性(Invariant Under Translation),计算结果不随对象的相对位置改变而改变。

为了处理平移的场景需要使用到中心矩(Central Moments),其计算公式如下。

显然中心矩mu00(矩数据结构属性以mu开头,等同于上述公式的μ)等于普通矩m00,因为任何数的0次幂都等于1,另外中心矩阵mu10和mu01都等于0。由于中心矩的计算都是相对均值的变化,因此对象的平移不会改变中心矩的计算结果。

另外mu00等用m00,mu10=mu01=0,nu00=1,nu10=nu01=0,由于它们是固定值或者和其他值相等,因此在Moments的定义中为了节省内存空间不包含这些属性。

4.2.2 标准化中心矩缩放不变性

使用相机拍摄同一个物体时,相机的远近会导致获取的图像中目标对象的大小不一致,因此通常我们需要一个不受缩放影响的比较标准,而标准化中心矩(Normalized Central Moments)就满足这个条件,它具有缩放不变性。在调用函数cv::moments()会同时计算普通矩,中心矩和标准化中心矩。标准化中心矩计算时会使用中心距除以目标整体大小的一个指数幂,其计算公式如下。

4.2.3 Hu不变矩旋转不变性

Hu不变矩(Hu invariant moments)是标准化中心矩的线性组合,Hu不变矩处理同个目标对象的缩放、旋转、镜像(除h1矩外)对象时都能得到相同的计算结果。Hu不变矩的定义如下。

为了更形象的体验hu不变矩的实际意义,我们分别计算下图的5个不同对象的多阶hu不变矩。

计算结果如下表。

对象 h1 h2 h3 h4 h5 h6 h7
A 2.837e–1 1.961e–3 1.484e–2 2.265e–4 –4.152e–7 1.003e–5 –7.941e–9
I 4.578e–1 1.820e–1 0.000 0.000 0.000 0.000 0.000
O 3.791e–1 2.623e–4 4.501e–7 5.858e–7 1.529e–13 7.775e–9 –2.591e–13
M 2.465e–1 4.775e–4 7.263e–5 2.617e–6 –3.607e–11 –5.718e–8 –7.218e–24
F 3.186e–1 2.914e–2 9.397e–3 8.221e–4 3.872e–8 2.019e–5 2.285e–6

从上表中可以明显看出随着hu矩的阶数不断增加,得到的值越小。这并不奇怪,因为根据前文提到的hu矩定义,高阶Hu矩阵是一系列标准因子的高阶幂,由于这些标准因子的值都小于1,因此指数越高其计算得到的值越小。

值得关注的是对象I,它具有180度旋转和镜像对称性,其3到7阶hu矩值都等于0,对象O也具有类似的对称性,尽管其所有hu矩都不为0,但是其中两个已经接近于0了。

计算Hu矩有单独的函数,它需要使用普通矩的计算结果作为输入,即函数cv::moments()的计算结果作为输入,其函数原型如下。

// moments:对象的普通矩,即函数cv::moments()的计算结果
// hu:计算得到的Hu矩,C风格的数组,共包含1-7阶矩,共7个元素
void cv::HuMoments(const cv::Moments& moments, double * hu);

4.3 使用Hu矩进行匹配

计算对象的矩的目的显然是需要比较两个程度等相似度,OpenCV专门提供了函数直接根据指定的方法比较两个对象的相似程度,其函数原型如下。

// object1:第一个待比较的对象,可以是二维点集,也可以是元素类型为cv:U8C1的矩阵
// object1:第二个待比较的对象,可以是二维点集,也可以是元素类型为cv:U8C1的矩阵
// method:比较方法下文介绍
// parameter:保留参数,暂时不会使用
double cv::MatchShapes(cv::InputArray object1, cv::InputArray object2,
                       int method, double parameter = 0);

该函数内部会计算对象的矩,然后再进行比较,并将计算结果返回。参数method的可选值及对应的函数返回值的计算公式如下。其中A和B分别对应函数的输入对象object1object2

4.4 使用形状场景方法比较形状

使用矩来比较形状是一个经典的技术,最早可以追溯到上个世纪80年代,OpenCV同样提供更好的现代算法用于形状比较。在OpenCV3中有单独的模块Shape负责处理这些任务,其中最典型的就是形状场景算法(Shape Context)。该模块尚在开发中,因此这里只简略介绍其高层抽象结构以及部分非常有用的函数和数据结构。

4.4.1 形状模块的基本结构

形状模块的核心是形状距离提取器类cv::ShapeDistanceExtractor,该抽象类型适用于任何用于比较两个或者更多形状并返回能够量化这两种形状差异的距离度量的函数子(Functor)。这里使用距离这个词来衡量不相似性是因为在很多场景下,距离和差异性具有相同的属性,如对于两个完全相同的对象而言,它们的距离为0。

在继续介绍该类之前,先要熟悉另外两个基本的数据结构,其中ShapeTransformer的定义如下。

class ShapeTransformer : public Algorithm {
public:
  virtual void estimateTransformation(
    cv::InputArray      transformingShape,
    cv::InputArray      targetShape,
    vector<cv::DMatch>& matches
  ) = 0;

  virtual float applyTransformation(
    cv::InputArray      input,
    cv::OutputArray     output      = noArray()
  ) = 0;

  virtual void warpImage(
    cv::InputArray      transformingImage,
    cv::OutputArray     output,
    int                 flags       = INTER_LINEAR,
    int                 borderMode  = BORDER_CONSTANT,
    const cv::Scalar&   borderValue = cv::Scalar()
  ) const = 0;
};

ShapeTransformer广泛用于表示一类将一个点集重映射到另外一个点集的算法,更一般化的场景就是将一副图片映射成为一张新的图片。本系列文章前面章节讲到的仿射和投影变换也能够通过定义形状转换器实现。其中的一个重要例子就是薄板样条转换器(Thine Plate Spline Transform),该转换器得名于金属薄板物理模型,并且它从根本上解决了金属薄板上的部分控制点移动到其他位置而产生的映射问题。当控制点移动后金属薄板会随之变性,这样其内部稠密点的映射变换就是算法要解决的问题。事实证明这是一个广泛适用的数据结构,在图像对其和形状匹配中也有很多应用。在OpenCV中该算法被定义为函数子cv::ThinPlateSplineShapeTransformer

HistogramCostExtractor的定义如下。

class HistogramCostExtractor : public Algorithm {
public:
  virtual void  buildCostMatrix(
    cv::InputArray      descriptors1,
    cv::InputArray      descriptors2,
    cv::OutputArray     costMatrix
  )                                                 = 0;

  virtual void  setNDummies( int nDummies )         = 0;
  virtual int   getNDummies() const                 = 0;

  virtual void  setDefaultCost( float defaultCost ) = 0;
  virtual float getDefaultCost() const              = 0;
};

直方图成本提取器推广可以得到在前面章节技术地球移动距离(Earth Mover Distance, EMD)时使用到的数据结构,在计算EMD距离时我们计算的是从一个直方图分组移动“数据”到另外一个分组的成本。有时这个成本是常量或者是与移动距离线性相关,但是有时成本与移动的”数据量“相关。计算EMD距离有单独的函数可以调用,而基类cv::HistogramCostExractor和其派生类可以用于处理更一般化的问题。下表列出了该类的派生类及其处理的任务类型。

直方图成本提取器的派生类 成本含义
cv::NormHistogramCostExtractor 使用L2或者其他范数计算成本
cv::ChiHistogramCostExtractor 使用卡方距离(Chi-Square Distance)计算成本
cv::EMDHistogramCostExtractor 和EMD距离使用L2范数计算的成本相同
cv::EMDL1HistogramCostExtractor 和EMD距离使用L1范数计算的成本相同

对于上表中的每个类型的成本提取器,OpenCV都提供了一个类似createX()的函数用于生成对应的实例,例如cv::createChiHistogramCostExtractor()

4.4.2 形状场景距离提取器

本小节的开头就介绍过形状模块的核心是形状距离提取器类cv::ShapeDistanceExtractor,现在介绍它的一些派生类。首先介绍的是形状场景距离提取器ShapeContextDistanceExtractor,它的内部实现使用了一个形状转换器和一个直方图成本提取器,其定义如下。

namespace cv {
// 形状场景距离提取器的定义
  class ShapeContextDistanceExtractor : public ShapeDistanceExtractor {
    public:
    ...
    virtual float computeDistance(InputArray contour1, InputArray contour2) = 0;
  };

// 构建形状场景距离提取器的函数
  Ptr<ShapeContextDistanceExtractor> createShapeContextDistanceExtractor(
    int nAngularBins = 12,
    int nRadialBins = 4,
    float innerRadius = 0.2f,
    float outerRadius = 2,
    int iterations = 3,
    const Ptr<HistogramCostExtractor> &comparer = 
              createChiHistogramCostExtractor(),
    const Ptr<ShapeTransformer> &transformer = 
              createThinPlateSplineShapeTransformer()
  );
}

实质上形状背景距离算法计算了多个待比较形状的某种特征表示,其中每个特征都是形状边界点的子集计算,对于该子集中的每个采样点,该算法都会以该点为观察点创建一个能够在极坐标系中反应轮廓特征的直方图。所有的直方图大小相同,都为nAngularBins✖️nRadialBins。从形状contour1中的点pi和形状contour2中的点qj计算出的直方图使用经典的卡方距离比较(Chi-squared Distance)。然后算法计算形状contour1中采样子集p和形状contour2中的采样子集q中点点最优关联,使得整体卡方距离最小。该算法并不是最快的,甚至计算成本矩阵的复杂度为N✖️N✖️nAngularBins✖️nRadialBins(对于每个p中的点q都需要计算所有的点从而找到最小的卡方距离,而每次寻找咖啡距离会遍历直方图所有元素),其中N是形状边界点采样子集的数量。但是这个算法仍然能够给出不错的结果。

示例程序SCDE使用形状背景提取器比较了两个形状,其核心代码如下。

/// 提取图片轮廓,并随机采样顶点
/// - Parameters:
///   - image: 待采样的图片
///   - n: 采样顶点数
static std::vector<cv::Point> sampleContour(const cv::Mat& image, int n = 300) {
    // 查找所有的轮廓
    std::vector<std::vector<cv::Point>> contours;
    cv::findContours(image, contours, cv::RETR_LIST, cv::CHAIN_APPROX_NONE);
    
    // 这里提取出第一条轮廓的所有顶点,由于准备的图片只能提取出一张轮廓,
    // 因此得到的就是想要寻找的轮廓
    std::vector<cv::Point> all_points;
    for (size_t j = 0; j < contours[0].size(); j++) {
        all_points.push_back(contours[0][j]);
    }

    // 如果单条轮廓的顶点数量小于n,则重复该条轮廓已有的顶点,直至轮廓数量等于N
    int dummy = 0;
    for (int add = (int)all_points.size(); add < n; add++) {
        all_points.push_back(all_points[dummy++]);
    }

    // 使用随机顺序排列所有的顶点
    unsigned seed = 
        (unsigned)std::chrono::system_clock::now().time_since_epoch().count();
    std::shuffle(all_points.begin(), all_points.end(),
                 std::default_random_engine (seed));
    
    // 随机采样轮廓的n个顶点
    std::vector<cv::Point> sampled;
    for (int i = 0; i < n; i++) {
        sampled.push_back(all_points[I]);
    }
    return sampled;
}

int main(int argc, const char * argv[]) {
    // 读取待比较的两个图片
    cv::Mat img1 = imread(argv[1], cv::IMREAD_GRAYSCALE);
    cv::Mat img2 = imread(argv[2], cv::IMREAD_GRAYSCALE);
    // 分别计算两个图片的随机采样轮廓顶点
    std::vector<cv::Point> c1 = sampleContour(img1);
    std::vector<cv::Point> c2 = sampleContour(img2);
    
    // 比较两个形状的距离
    cv::Ptr<cv::ShapeContextDistanceExtractor> mysc = 
        cv::createShapeContextDistanceExtractor();
    // 可能是由于XCode使用的编译器是Clang + LLVM,使得程序运行时报动态库符号绑定
    // 错误:Symbol not found: ___emutls_get_address
    float dis = mysc->computeDistance(c1, c2);
    std::cout << "shape context distance between "
              << argv[1] << " and " << argv[2] << " is: " << dis << std::endl;
    
    // 显示两个形状
    cv::imshow("SHAPE #1", img1);
    cv::imshow("SHAPE #2", img2);
    
    // 挂起程序等待用户输入
    cv::waitKey();

    return 0;
}

更复杂的使用示例请参考OpenCV3的官方文档中的示例程序…samples/cpp/shape_example.cpp。

4.4.3 Hausdorff距离提取器

和形状背景距离提取器一样,Hausdorff距离提取器也继承自类ShapeDistanceExtractor,它同样可以用于比较形状的差异。其定义如下。

class CV_EXPORTS_W HausdorffDistanceExtractor : public ShapeDistanceExtractor {
public:
    CV_WRAP virtual void setDistanceFlag(int distanceFlag) = 0;
    CV_WRAP virtual int getDistanceFlag() const = 0;

    CV_WRAP virtual void setRankProportion(float rankProportion) = 0;
    CV_WRAP virtual float getRankProportion() const = 0;
};

算法计算公式如下(推测第一个公式的h(BA)误写为h(Ba))。首先获取了图片中的所有点,对于每个点,找到最近与它最近点的距离,这些距离里面的最大值定义为直接Hausdorff距离(Directed Hausdorff Distance),使用符号h表示。两个直接Hausdorff距离中的大值就是Hausdorff距离(Hoausdorff Distance),使用符号H表示。需要注意直接Hausdorff距离是非对称的,即交换括号内AB顺序会影响计算结果,而Hausdorff距离是对称的。公式中的|| X ||表示某种范数,常用的是欧式距离。本质上该算法计算的是两个形状中距离最远的一组点。

构建Hausdorff距离提取器的函数原型如下。

// distanceFlag:距离计算方式
cv::Ptr<cv::HausdorffDistanceExtractor> cv::createHausdorffDistanceExtractor(
    int distanceFlag = cv::NORM_L2, float rankProp = 0.6);

5 小结

本章是围绕轮廓和二维空间点集展开的,它们可以使用包含如cv::Vec2f等点对象的STL向量表示,也可以使用N✖️1的双通道矩阵或者N✖️2的单通道矩阵表示。轮廓也可以表示为二维点集,OpenCV提供了一系列函数来提取和处理轮廓。

轮廓在表示图片的分区时非常有用,OpenCV也提供了很多工具函数来比较轮廓对象以及计算它们的一些特征属性,如轮廓凸包,矩以及任意点和轮廓的关系。最后OpenCV提供了很多方式匹配轮廓和形状,文中介绍了经典的基于矩的比较方法,也介绍了基于形状距离提取器的新特性。

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