环境

  • OpenCV 2.4.13
  • VS 2015
  • 本文完整代码开源在Github

刚开始让我做这个,我也没有什么好的思路,但是随着对OpenCV的文档逐渐熟悉,也查阅了很多资料,对于这个问题,终于有了一个相当不错的解决方法。

常规方法

最初的搜索,会找到一种非常常规的方法:先对图像进行二值化,然后使用霍夫变换,检测出其中的直线,并在直线中,找到围成一个矩形的区域,将这块区域提取出来就好了。

按照这个方法,我最初使用的是Canny + HoughLinesP,检测出的直线效果也看得过去。但是由于我的输入图像是照片,每个照片的明亮不统一,Canny效果并不出众。于是乎我又加了腐蚀和高斯模糊,Canny我也参考Matlab等加了自适应阈值,直线稍微多了些,但也并不好看。

最初的代码是这样的,很小白。

void extractTable(string &argv) {
    Mat src = imread(argv), dst;
    if (!src.data) {
        cout << "not picture" << endl;
    }
    Mat canny,gray,sobel, edge,erod, blur, color_dst;

    double src_height=src.cols, src_width=src.rows;
    imshow("source", src);

    //先转为灰度
    cvtColor(src, gray, COLOR_BGR2GRAY);

    //腐蚀(黑***域变大)
    int erodeSize = src_height / 200;
    if (erodeSize % 2 == 0)
        erodeSize++;
    Mat element = getStructuringElement(MORPH_RECT, Size(erodeSize, erodeSize));
    erode(gray, erod, element);

    //高斯模糊化
    int blurSize = src_height / 200;
    if (blurSize % 2 == 0)
        blurSize++;
    GaussianBlur(erod, blur, Size(blurSize, blurSize), 0, 0);
    namedWindow("GaussianBlur", WINDOW_NORMAL);
    imshow("GaussianBlur", blur);

    //自适应阈值Cannyz算法
    double low = 0.0, high = 0.0;
    AdaptiveFindThreshold(src, &low, &high);
    Canny(blur, canny, low, high);

    imshow("gray", gray);
    imshow("canny", canny);

    //检测直线,并将直线放回原图
    vector<Vec4i> lines;
    HoughLinesP(canny, lines, 1, CV_PI / 180, 100, 100, 15);
    cout << "线条数" << lines.size() << endl;
    for (size_t i = 0; i < lines.size(); i++)
    {
        line(src, Point(lines[i][0], lines[i][1]),
            Point(lines[i][2], lines[i][3]), Scalar(0, i * 10 & 255, (255 - i * 10) & 255), 3, 8);
    }

    imshow("Detected Lines", src);
    waitKey(0);
    destroyAllWindows();
}

就这样在不断的修改和寻觅了两天后,终于让我发现了一个让人心悦诚服的算法

进阶方法

这个算法灵活运用了腐蚀和膨胀,让表格乖乖的现形了,由于照片不可外传,所以就用论文中的截图做例子吧。值得一提的是,对于照片,这段代码也十分适用。

1、处理图像,灰度化,二值化

在灰度图的基础上运用了adaptiveThreshold来达成自动阈值的二值化,这个算法在提取直线和文字的时候比Canny更好用,其中~gray的意思是反色,使二值化后的图片是黑底白字,才能正确的提取直线。

    cvtColor(rsz, gray, CV_BGR2GRAY);
    Mat bw;
    adaptiveThreshold(~gray, bw, 255, CV_ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY, 15, -2);

2、巧妙利用OpenCV里面的形态学函数,腐蚀erode膨胀dilate

    //使用二值化后的图像来获取表格横纵的线
    Mat horizontal = thresh.clone();
    Mat vertical = thresh.clone();

    int scale = 20; //这个值越大,检测到的直线越多
    int horizontalsize = horizontal.cols / scale;

    // 为了获取横向的表格线,设置腐蚀和膨胀的操作区域为一个比较大的横向直条
    Mat horizontalStructure = getStructuringElement(MORPH_RECT, Size(horizontalsize, 1));

    // 先腐蚀再膨胀
    erode(horizontal, horizontal, horizontalStructure, Point(-1, -1));
    dilate(horizontal, horizontal, horizontalStructure, Point(-1, -1));
    imshow("horizontal", horizontal);

竖直方向上线条获取的步骤同上,唯一的区别在于腐蚀膨胀的区域为一个宽为1,高为缩放后的图片高度的一个竖长形直条

    int verticalsize = vertical.rows / scale;
    Mat verticalStructure = getStructuringElement(MORPH_RECT, Size(1, verticalsize));
    erode(vertical, vertical, verticalStructure, Point(-1, -1));
    dilate(vertical, vertical, verticalStructure, Point(-1, -1));
    imshow("vertical", vertical);

3、交叉横纵线条,定位点

    Mat mask = horizontal + vertical;
    imshow("mask", mask);

通过bitwise_and函数获得横纵线条的交点,通过交点再做后面的表格提取操作

    Mat joints;
    bitwise_and(horizontal, vertical, joints);
    imshow("joints", joints);

4、判断区域是否为表格

在mask那张图上通过findContours 找到轮廓,判断轮廓形状和大小是否为表格。

approxPolyDP 函数用来逼近区域成为一个形状,true值表示产生的区域为闭合区域。

boundingRect 为将这片区域转化为矩形,此矩形包含输入的形状。

    vector<Vec4i> hierarchy;
    std::vector<std::vector<cv::Point> > contours;

    cv::findContours(mask, contours, hierarchy, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_SIMPLE, Point(0, 0));

    vector<vector<Point> > contours_poly(contours.size());
    vector<Rect> boundRect(contours.size());
    vector<Mat> rois;

    for (size_t i = 0; i < contours.size(); i++)
    {
        //获取区域的面积,如果小于某个值就忽略,代表是杂线不是表格
        double area = contourArea(contours[i]);
        if (area < 100)
            continue;

        approxPolyDP(Mat(contours[i]), contours_poly[i], 3, true);
        boundRect[i] = boundingRect(Mat(contours_poly[i]));

        // find the number of joints that each table has
        Mat roi = joints(boundRect[i]);

        vector<vector<Point> > joints_contours;
        findContours(roi, joints_contours, RETR_CCOMP, CHAIN_APPROX_SIMPLE);

        //从表格的特性看,如果这片区域的点数小于4,那就代表没有一个完整的表格,忽略掉
        if (joints_contours.size() <= 4)
            continue;

        //保存这片区域
        rois.push_back(src(boundRect[i]).clone());
        //将矩形画在原图上
        rectangle(src, boundRect[i].tl(), boundRect[i].br(), Scalar(0, 255, 0), 1, 8, 0);
    }
    //这里可以对保存的区域进行各种操作,如显示,保存等。也作为OCR等等复杂功能的输入。
    for (size_t i = 0; i < rois.size(); ++i)
    {
        std::stringstream ss;
        ss << "roi" << i << "";
        imshow(ss.str(), rois[i]);
        waitKey();
    }
    imshow("contours", src);

拆分的结果

显示表格区域在原图

问题

虽然这部分代码可以相对完整的识别图片中的表格,但是依然有几个问题是不容易解决的:

  • 照片倾斜问题
  • 照片背景复杂,干扰识别
  • 表格只有上下两条线时的识别判断条件
  • ……

后续重点会放在图片的透视变换,先提取纸张,再识别表格。