实习的公司有对增值税发票进行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、找到最大的正方形
从上图可以看出我们找到了两个大四边形(如果看不清的话可以放大观看)。对比原图可以发现,外围的四边形是我们想要的发票边缘,而内部的四边形则是发票内的表格边框。因此我们要找到最大的正方形来当作发票边缘。实现方式很简单,找到最大的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;
6、重新执行步骤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;
7、找到长方形四条边,即为纸张的外围四边形
之后的步骤就很简单了,首先排除多边形中距离非常近的点,然后找到距离大于某个阈值的四个点,便为长方形的四个顶点。最后连接四个顶点,提取四边形边框的步骤就完成了。
// 点到点的距离
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);
}
8、透视变换,提取四边形
终于到最后一步了,最后一步的关键就是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;
以上就是我算法的全部步骤了。实现、调参下来感觉这个算法普适性并不强,读者可能需要对我的代码加以修改才能满足具体的业务需求,我的代码是在网上其他博主的实现(C++,参考链接)基础上加以修改,权当抛砖引玉,有任何意见与建议可以在评论区和我交流。
另外,网上见到有些实现没有采用多边形拟合,而是使用Hough变换来实现该功能,然而自己实现下来效果并不好,如果有人有这方面经验的话还希望不吝赐教。