OpenCV Java 實現票據、紙張的四邊形邊緣檢測與提取、擺正

參考連接:http://blog.csdn.net/zxw_xzr/article/details/77358815java

實習的公司有對增值稅發票進行OCR識別的需求。OCR部分實現起來不難(有現成的SDK能夠調用),可是實際狀況中,用戶提供的照片中的發票每每會有一些偏斜,而公司提供的OCR SDK並不能檢測偏斜的字符,所以須要先進行圖像預處理,擺正發票(效果相似於Office Lens)。要實現的效果以下圖:算法

<figure class="half"> <img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180119223707849-1048250768.jpg" width="38%"> <img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180119223717349-862796075.jpg" width="47%"> </figure>app

算法的具體步驟以下:函數

  1. 轉灰度,降噪
  2. 邊緣檢測
  3. 輪廓提取
  4. 尋找凸包,擬合多邊形
  5. 找到最大的正方形
  6. 從新執行步驟3,提高精度
  7. 找到長方形四條邊,即爲紙張的外圍四邊形
  8. 透視變換,提取四邊形

紙張四邊形檢測與提取的教程網上比較少,並且也不夠詳細,這是我寫這篇博文的動力。接下來我會一步步詳細分析這個算法:url

一、轉灰度,降噪

第一步就是對圖像進行預處理。爲了應用Canny算法要先將圖片轉爲灰度圖。因爲要進行邊緣檢測因此確定要預先降噪,降噪算法方面嘗試了Gaussian濾波與MeanShift濾波。MeanShift濾波的效果比Gaussian濾波要好,能夠把桌面的紋理,發票內的字符等冗餘信息都塗抹掉,可是因爲MeanShift聚類效率實在是低,所以仍是採用了Gaussian濾波。spa

// MeanShift濾波,降噪(速度太慢!)
//Imgproc.pyrMeanShiftFiltering(img, img, 30, 10);

// 彩色轉灰度
Imgproc.cvtColor(img, img, Imgproc.COLOR_BGR2GRAY);

// 高斯濾波,降噪
Imgproc.GaussianBlur(img, img, new Size(3,3), 2, 2);

<img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180120004932756-1638405114.jpg" width="38%">.net

二、邊緣檢測

接下來進行邊緣檢測。這是整個算法很是關鍵的一步,閾值選的好很差直接關係到後續的輪廓線是否正確,以及可否檢測出四邊形。 採用Canny算法檢測邊緣,Canny算法的原理這裏再也不贅述,網上有不少優質的資源能夠幫助你理解這個偉大的邊緣檢測算法。閾值選取方面,要儘可能選取低閾值!!!由於若是閾值選取過高,會致使發票的外圍四邊形未閉合,致使沒法正確尋找輪廓線。低閾值雖然會產生不少噪點,可是因爲後續還要進行輪廓線檢測和多邊形擬合,因此噪點會在後續步驟被忽略。 Canny算法事後,要再執行一次膨脹操做,確保發票邊緣已經鏈接。code

// Canny邊緣檢測
Imgproc.Canny(img, img, 20, 60, 3, false);

// 膨脹,鏈接邊緣
Imgproc.dilate(img, img, new Mat(), new Point(-1,-1), 3, 1, new Scalar(1));

<img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180119232258099-240335564.jpg" width="38%">orm

三、輪廓提取

對邊緣檢測的結果圖再進行輪廓提取,使用的是OpenCV內置的findContours函數,該函數的原理詳見OpenCV Reference Manual。實際應用中採用了RETR_EXTERNAL參數,只提取外部的輪廓。blog

List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
Imgproc.findContours(img, contours, hierarchy, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);

<img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180119233558490-448515937.jpg" width="38%">

四、尋找凸包,擬合多邊形

檢測出的輪廓看起來依舊很亂,該怎麼辦呢?首先對於每一個輪廓,求出它的凸包,並使用多邊形擬合凸包邊框。接下來篩選出面積大於某個閾值的,並且四個角都約等於九十度的凸四邊形。找出的凸四邊形就是候選的外圍四邊形。 這段代碼中會有不少類型轉換。OpenCV Java中有MatOfInt,MatOfPoint,MatOfPoint2f等等許多類型,Imgproc中函數的參數類型也五花八門,所以調用函數的時候要格外注意。 以後的代碼中,調用的本身實現的函數都會貼在代碼的最上方,拷貝代碼的時候要注意不要拷錯了哦。

// 根據三個點計算中間那個點的夾角   pt1 pt0 pt2
private static double getAngle(Point pt1, Point pt2, Point pt0)
{
    double dx1 = pt1.x - pt0.x;
    double dy1 = pt1.y - pt0.y;
    double dx2 = pt2.x - pt0.x;
    double dy2 = pt2.y - pt0.y;
    return (dx1*dx2 + dy1*dy2)/Math.sqrt((dx1*dx1 + dy1*dy1)*(dx2*dx2 + dy2*dy2) + 1e-10);
}



// 找出輪廓對應凸包的四邊形擬合
List<MatOfPoint> squares = new ArrayList<>();
List<MatOfPoint> hulls = new ArrayList<>();
MatOfInt hull = new MatOfInt();
MatOfPoint2f approx = new MatOfPoint2f();
approx.convertTo(approx, CvType.CV_32F);

for (MatOfPoint contour: contours) {
    // 邊框的凸包
    Imgproc.convexHull(contour, hull);

    // 用凸包計算出新的輪廓點
    Point[] contourPoints = contour.toArray();
    int[] indices = hull.toArray();
    List<Point> newPoints = new ArrayList<>();
    for (int index : indices) {
        newPoints.add(contourPoints[index]);
    }
    MatOfPoint2f contourHull = new MatOfPoint2f();
    contourHull.fromList(newPoints);

    // 多邊形擬合凸包邊框(此時的擬合的精度較低)
    Imgproc.approxPolyDP(contourHull, approx, Imgproc.arcLength(contourHull, true)*0.02, true);

    // 篩選出面積大於某一閾值的,且四邊形的各個角度都接近直角的凸四邊形
    MatOfPoint approxf1 = new MatOfPoint();
    approx.convertTo(approxf1, CvType.CV_32S);
    if (approx.rows() == 4 && Math.abs(Imgproc.contourArea(approx)) > 40000 &&
            Imgproc.isContourConvex(approxf1)) {
        double maxCosine = 0;
        for (int j = 2; j < 5; j++) {
            double cosine = Math.abs(getAngle(approxf1.toArray()[j%4], approxf1.toArray()[j-2], approxf1.toArray()[j-1]));
            maxCosine = Math.max(maxCosine, cosine);
        }
        // 角度大概72度
        if (maxCosine < 0.3) {
            MatOfPoint tmp = new MatOfPoint();
            contourHull.convertTo(tmp, CvType.CV_32S);
            squares.add(approxf1);
            hulls.add(tmp);
        }
    }
}

<img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180119234524646-394158919.jpg" width="38%">

五、找到最大的正方形

從上圖能夠看出咱們找到了兩個大四邊形(若是看不清的話能夠放大觀看)。對比原圖能夠發現,外圍的四邊形是咱們想要的發票邊緣,而內部的四邊形則是發票內的表格邊框。所以咱們要找到最大的正方形來看成發票邊緣。實現方式很簡單,找到最大的width和height就行。

// 找到最大的正方形輪廓
private static int findLargestSquare(List<MatOfPoint> squares) {
    if (squares.size() == 0)
        return -1;
    int max_width = 0;
    int max_height = 0;
    int max_square_idx = 0;
    int currentIndex = 0;
    for (MatOfPoint square : squares) {
        Rect rectangle = Imgproc.boundingRect(square);
        if (rectangle.width >= max_width && rectangle.height >= max_height) {
            max_width = rectangle.width;
            max_height = rectangle.height;
            max_square_idx = currentIndex;
        }
        currentIndex++;
    }
    return max_square_idx;
}



// 找出外接矩形最大的四邊形
int index = findLargestSquare(squares);
MatOfPoint largest_square = squares.get(index);
if (largest_square.rows() == 0 || largest_square.cols() == 0)
    return result;

六、從新執行步驟3,提高精度

接下來,對於該四邊形,從新進行凸包與多邊形擬合,用來提高精度。

// 找到這個最大的四邊形對應的凸邊框,再次進行多邊形擬合,這次精度較高,擬合的結果多是大於4條邊的多邊形
MatOfPoint contourHull = hulls.get(index);
MatOfPoint2f tmp = new MatOfPoint2f();
contourHull.convertTo(tmp, CvType.CV_32F);
Imgproc.approxPolyDP(tmp, approx, 3, true);
List<Point> newPointList = new ArrayList<>();
double maxL = Imgproc.arcLength(approx, true) * 0.02;

七、找到長方形四條邊,即爲紙張的外圍四邊形

以後的步驟就很簡單了,首先排除多邊形中距離很是近的點,而後找到距離大於某個閾值的四個點,便爲長方形的四個頂點。最後鏈接四個頂點,提取四邊形邊框的步驟就完成了。

// 點到點的距離
private static double getSpacePointToPoint(Point p1, Point p2) {
    double a = p1.x - p2.x;
    double b = p1.y - p2.y;
    return Math.sqrt(a * a + b * b);
}

// 兩直線的交點
private static Point computeIntersect(double[] a, double[] b) {
    if (a.length != 4 || b.length != 4)
        throw new ClassFormatError();
    double x1 = a[0], y1 = a[1], x2 = a[2], y2 = a[3], x3 = b[0], y3 = b[1], x4 = b[2], y4 = b[3];
    double d = ((x1 - x2) * (y3 - y4)) - ((y1 - y2) * (x3 - x4));
    if (d != 0) {
        Point pt = new Point();
        pt.x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d;
        pt.y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d;
        return pt;
    }
    else
        return new Point(-1, -1);
}



// 找到高精度擬合時獲得的頂點中 距離小於低精度擬合獲得的四個頂點maxL的頂點,排除部分頂點的干擾
for (Point p : approx.toArray()) {
    if (!(getSpacePointToPoint(p, largest_square.toList().get(0)) > maxL &&
            getSpacePointToPoint(p, largest_square.toList().get(1)) > maxL &&
            getSpacePointToPoint(p, largest_square.toList().get(2)) > maxL &&
            getSpacePointToPoint(p, largest_square.toList().get(3)) > maxL)) {
        newPointList.add(p);
    }
}

// 找到剩餘頂點連線中,邊長大於 2 * maxL的四條邊做爲四邊形物體的四條邊
List<double[]> lines = new ArrayList<>();
for (int i = 0; i < newPointList.size(); i++) {
    Point p1 = newPointList.get(i);
    Point p2 = newPointList.get((i+1) % newPointList.size());
    if (getSpacePointToPoint(p1, p2) > 2 * maxL) {
        lines.add(new double[]{p1.x, p1.y, p2.x, p2.y});
    }
}

// 計算出這四條邊中 相鄰兩條邊的交點,即物體的四個頂點
List<Point> corners = new ArrayList<>();
for (int i = 0; i < lines.size(); i++) {
    Point corner = computeIntersect(lines.get(i),lines.get((i+1) % lines.size()));
    corners.add(corner);
}

<img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180120002312209-217380157.jpg" width="38%">

八、透視變換,提取四邊形

終於到最後一步了,最後一步的關鍵就是Perspective Transform。新建一個Mat,將其四個頂點與原圖片剛剛檢測出的長方形的四個頂點進行透視變換,就能獲得最後的結果啦。透視變換的數學原理能夠看這裏,介紹的比較詳細。

// 對多個點按順時針排序
private static void sortCorners(List<Point> corners) {
    if (corners.size() == 0) return;
    Point p1 = corners.get(0);
    int index = 0;
    for (int i = 1; i < corners.size(); i++) {
        Point point = corners.get(i);
        if (p1.x > point.x) {
            p1 = point;
            index = i;
        }
    }

    corners.set(index, corners.get(0));
    corners.set(0, p1);

    Point lp = corners.get(0);
    for (int i = 1; i < corners.size(); i++) {
        for (int j = i + 1; j < corners.size(); j++) {
            Point point1 = corners.get(i);
            Point point2 = corners.get(j);
            if ((point1.y-lp.y*1.0)/(point1.x-lp.x)>(point2.y-lp.y*1.0)/(point2.x-lp.x)) {
                Point temp = point1.clone();
                corners.set(i, corners.get(j));
                corners.set(j, temp);
            }
        }
    }
}


// 對頂點順時針排序
sortCorners(corners);

// 計算目標圖像的尺寸
Point p0 = corners.get(0);
Point p1 = corners.get(1);
Point p2 = corners.get(2);
Point p3 = corners.get(3);
double space0 = getSpacePointToPoint(p0, p1);
double space1 = getSpacePointToPoint(p1, p2);
double space2 = getSpacePointToPoint(p2, p3);
double space3 = getSpacePointToPoint(p3, p0);

double imgWidth = space1 > space3 ? space1 : space3;
double imgHeight = space0 > space2 ? space0 : space2;

// 若是提取出的圖片寬小於高,則旋轉90度
if (imgWidth < imgHeight) {
    double temp = imgWidth;
    imgWidth = imgHeight;
    imgHeight = temp;
    Point tempPoint = p0.clone();
    p0 = p1.clone();
    p1 = p2.clone();
    p2 = p3.clone();
    p3 = tempPoint.clone();
}

Mat quad = Mat.zeros((int)imgHeight * 2, (int)imgWidth * 2, CvType.CV_8UC3);

MatOfPoint2f cornerMat = new MatOfPoint2f(p0, p1, p2, p3);
MatOfPoint2f quadMat = new MatOfPoint2f(new Point(imgWidth*0.4, imgHeight*1.6),
        new Point(imgWidth*0.4, imgHeight*0.4),
        new Point(imgWidth*1.6, imgHeight*0.4),
        new Point(imgWidth*1.6, imgHeight*1.6));

// 提取圖像
Mat transmtx = Imgproc.getPerspectiveTransform(cornerMat, quadMat);
Imgproc.warpPerspective(result, quad, transmtx, quad.size());
return quad;

<img src="http://images2017.cnblogs.com/blog/1320057/201801/1320057-20180119223717349-862796075.jpg" width="47%">

以上就是我算法的所有步驟了。實現、調參下來感受這個算法普適性並不強,讀者可能須要對個人代碼加以修改才能知足具體的業務需求,個人代碼是在網上其餘博主的實現(C++,參考連接)基礎上加以修改,權當拋磚引玉,有任何意見與建議能夠在評論區和我交流。 另外,網上見到有些實現沒有采用多邊形擬合,而是使用Hough變換來實現該功能,然而本身實現下來效果並很差,若是有人有這方面經驗的話還但願不吝賜教。

相關文章
相關標籤/搜索