实习的公司有对增值税发票进行OCR识别的需求。OCR部分实现起来不难(有现成的SDK可以调用),但是实际情况中,用户提供的照片中的发票往往会有一些偏斜,而公司提供的OCR SDK并不能检测偏斜的字符,因此需要先进行图像预处理,摆正发票(效果类似于Office Lens)。要实现的效果如下图:
算法的具体步骤如下:
转灰度,降噪
边缘检测
轮廓提取
寻找凸包,拟合多边形
找到最大的正方形
重新执行步骤3,提升精度
找到长方形四条边,即为纸张的外围四边形
透视变换,提取四边形
纸张四边形检测与提取的教程网上比较少,而且也不够详细,这是我写这篇博文的动力。接下来我会一步步详细分析这个算法:
1、转灰度,降噪
第一步就是对图像进行预处理。为了应用Canny算法要先将图片转为灰度图。由于要进行边缘检测所以肯定要预先降噪,降噪算法方面尝试了Gaussian滤波与MeanShift滤波。MeanShift滤波的效果比Gaussian滤波要好,可以把桌面的纹理,发票内的字符等冗余信息都涂抹掉,但是由于MeanShift聚类效率实在是低,因此还是采用了Gaussian滤波。
// MeanShift滤波,降噪(速度太慢!)
//Imgproc.pyrMeanShiftFiltering(img, img, 30, 10);
// 彩色转灰度
Imgproc.cvtColor(img, img, Imgproc.COLOR_BGR2GRAY);
// 高斯滤波,降噪
Imgproc.GaussianBlur(img, img, new Size(3,3), 2, 2);
2、边缘检测
接下来进行边缘检测。这是整个算法非常关键的一步,阈值选的好不好直接关系到后续的轮廓线是否正确,以及能否检测出四边形。
采用Canny算法检测边缘,Canny算法的原理这里不再赘述,网上有很多优质的资源可以帮助你理解这个伟大的边缘检测算法。阈值选取方面,要尽量选取低阈值!!!因为如果阈值选取太高,会导致发票的外围四边形未闭合,导致无法正确寻找轮廓线。低阈值虽然会产生很多噪点,但是由于后续还要进行轮廓线检测和多边形拟合,所以噪点会在后续步骤被忽略。
Canny算法过后,要再执行一次膨胀操作,确保发票边缘已经连接。
// Canny边缘检测
Imgproc.Canny(img, img, 20, 60, 3, false);
// 膨胀,连接边缘
Imgproc.dilate(img, img, new Mat(), new Point(-1,-1), 3, 1, new Scalar(1));
3、轮廓提取
对边缘检测的结果图再进行轮廓提取,使用的是OpenCV内置的findContours函数,该函数的原理详见OpenCV Reference Manual。实际应用中采用了RETR_EXTERNAL参数,只提取外部的轮廓。
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
Imgproc.findContours(img, contours, hierarchy, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
4、寻找凸包,拟合多边形
检测出的轮廓看起来依旧很乱,该怎么办呢?首先对于每个轮廓,求出它的凸包,并使用多边形拟合凸包边框。接下来筛选出面积大于某个阈值的,而且四个角都约等于九十度的凸四边形。找出的凸四边形就是候选的外围四边形。
这段代码中会有很多类型转换。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);
}
}
}
5、找到最大的正方形