本篇文章主要就边框识别部分说一下开发过程及实现原理,通过阅读本篇文章,你将具备以下技能:
了解 NDK 开发的基本步骤,能使用 Java、C++/C 混合开发简单的应用
了解 OpenCV 库的作用及其用法,能使用 OpenCV 做图像处理
了解基于 OpenCV 的边框识别实现
OpenCV 的全称是 Open Source Computer Vision Library,是一个使用 C++ 编写的跨平台的计算机视觉库,能对输入的图片进行处理,包括常见的高斯模糊,提取灰度图片,提取轮廓等等,可以应用于增强现实,人脸识别,运动跟踪,物体识别,图像分区等。
在 Android 平台需要使用 JNI 技术来调用 C++ 的库,事实上,OpenCV 的官网已经提供了编写好的 Android 库:OpenCv4Android,我们可以按照提示导入该库,就可用以使用 Java 代码来调用了。但是该库包含了 OpenCV 所有的模块,造成了该库体积非常大,其中很多并不是我们需要的。所以我的做法是只使用该库提供的编译好的 C++ 库,挑选自己需要用到的模块,引入其动态或者静态库,编写 C++ 代码调用 OpenCV 的这些模块完成主要功能,最后使用 JNI 技术编写 Java 接口供 Android 程序调用。
导入 OpenCV 库
下载好 OpenCv4Android 后解压目录如下所示:
OpenCV-2.4.13-android-sdk|_ doc |_ samples|_ sdk | |_ etc | |_ java | |_ native | |_ 3rdparty | |_ jni | |_ libs | |_ armeabi | |_ armeabi-v7a | |_ x86 ||_ LICENSE |_ README.android
sdk/java 目录提供了 OpenCv 的 Java API,导入到项目中,并且将 native/libs 下面的 native 库导入之后就可以使用 OpenCV 的 Java API 了。native/jni 目录下提供了编译用的 cmake 文件以及头文件。
在 SmartCropper 中只使用到了 opencv_core 与 opencv_imgproc 模块, 所以只需要导入这两个模块的头文件与动态库/静态库就行了。
目录如下所示:
smartcropperlib├── opencv│ ├── include│ │ └── opencv2│ │ ├── core│ │ ├── imgproc│ │ ├── opencv.hpp│ │ └── opencv_modules.hpp│ └── lib│ ├── armeabi│ │ ├── libopencv_core.a│ │ └── libopencv_imgproc.a│ ├── armeabi-v7a│ ├── mips│ └── x86├── CMakeLists.txt└── build.gradle└── src └── main ├── cpp │ ├── Scanner.cpp │ ├── android_utils.cpp │ ├── include │ │ ├── Scanner.h │ │ └── android_utils.h │ └── smart_cropper.cpp ├── java └── res
编写 cmake 文件:
include_directories(opencv/include src/main/cpp/include) add_library(opencv_imgproc STATIC IMPORTED) add_library(opencv_core STATIC IMPORTED) set_target_properties(opencv_imgproc PROPERTIES IMPORTED_LOCATION${PROJECT_SOURCE_DIR}/opencv/lib/${ANDROID_ABI}/libopencv_imgproc.a) set_target_properties(opencv_core PROPERTIES IMPORTED_LOCATION${PROJECT_SOURCE_DIR}/opencv/lib/${ANDROID_ABI}/libopencv_core.a) add_library( smart_cropper SHARED src/main/cpp/Scanner.cpp src/main/cpp/smart_cropper.cpp src/main/cpp/android_utils.cpp) find_library( log-lib log) find_library(jnigraphics-lib jnigraphics) target_link_libraries( smart_cropper opencv_imgproc opencv_core ${log-lib} ${jnigraphics-lib})
主要注意点如下:
include_directories
添加头文件查找路径,包括引入库的和自己写的add_library
添加动态库或静态库,其中本地的动态库名称,位置可以由set_target_properties
设置find_library
通过名称查找并引入库,可以引入 NDK 中的库,比如日志模块target_link_libraries
添加参加编译的库名称,也可以是绝对路径,注意被依赖的模块写在后面
修改 build.gradle 文件:
android { //... defaultConfig { //... externalNativeBuild { cmake { cppFlags "-std=c++11 -frtti -fexceptions -lz" abiFilters 'armeabi' } } } externalNativeBuild { cmake { path "CMakeLists.txt" } } //...}
这里指定了 C++ 的版本为11,开启 RTTI,启用异常处理,这样就完成了导入 OpenCV 代码库的配置。
边框识别
SmartCropper 类中提供了图片的边框识别与裁剪:
public class SmartCropper { /** * 输入图片扫描边框顶点 * @param srcBmp 扫描图片 * @return 返回顶点数组,以 左上,右上,右下,左下排序 */ public static Point[] scan(Bitmap srcBmp) { //... } /** * 裁剪图片 * @param srcBmp 待裁剪图片 * @param cropPoints 裁剪区域顶点,顶点坐标以图片大小为准 * @return 返回裁剪后的图片 */ public static Bitmap crop(Bitmap srcBmp, Point[] cropPoints) { //... } private static native void nativeScan(Bitmap srcBitmap, Point[] outPoints); private static native void nativeCrop(Bitmap srcBitmap, Point[] points, Bitmap outBitmap); static { System.loadLibrary("smart_cropper"); } }
主要逻辑位于 native 层,先看 nativeScan 方法对应的 C++ 代码:
static void native_scan(JNIEnv *env, jclass type, jobject srcBitmap, jobjectArray outPoint_) { if (env -> GetArrayLength(outPoint_) != 4) { return; } Mat srcBitmapMat; bitmap_to_mat(env, srcBitmap, srcBitmapMat); Mat bgrData(srcBitmapMat.rows, srcBitmapMat.cols, CV_8UC3); cvtColor(srcBitmapMat, bgrData, CV_RGBA2BGR); scanner::Scanner docScanner(bgrData); std::vector scanPoints = docScanner.scanPoint(); if (scanPoints.size() == 4) { for (int i = 0; i < 4; ++i) { env -> SetObjectArrayElement(outPoint_, i, createJavaPoint(env, scanPoints[i])); } } }
先将传入的 Bitmap 对象转化成 OpenCV 提供的 Mat 对象,你可以理解成一个多维矩阵,保存了位图的信息,在 OpenCV 中 Mat 即为图片 ,所有对图片的操作即为操作 Mat 对象,然后将 RGBA 格式的图片转化成 BGR 格式,这种转化对于 OpenCV 来说十分方便,cvtColor(srcBitmapMat, bgrData, CV_RGBA2BGR); 当然还提供了其他的色彩空间转化。接着调用 scanner::Scanner 对象的 scanPoint 函数获取识别好的四个顶点。
可以看到主要逻辑位于 docScanner.scanPoint() 中,我们先看一下 bitmap_to_ma 是如何将 bitmap 转化成 Mat 对象的:
void bitmap_to_mat(JNIEnv *env, jobject &srcBitmap, Mat &srcMat) { void *srcPixels = 0; AndroidBitmapInfo srcBitmapInfo; try { AndroidBitmap_getInfo(env, srcBitmap, &srcBitmapInfo); AndroidBitmap_lockPixels(env, srcBitmap, &srcPixels); uint32_t srcHeight = srcBitmapInfo.height; uint32_t srcWidth = srcBitmapInfo.width; srcMat.create(srcHeight, srcWidth, CV_8UC4); if (srcBitmapInfo.format == ANDROID_BITMAP_FORMAT_RGBA_8888) { Mat tmp(srcHeight, srcWidth, CV_8UC4, srcPixels); tmp.copyTo(srcMat); } else { Mat tmp = Mat(srcHeight, srcWidth, CV_8UC2, srcPixels); cvtColor(tmp, srcMat, COLOR_BGR5652RGBA); } AndroidBitmap_unlockPixels(env, srcBitmap); return; } catch (cv::Exception &e) { AndroidBitmap_unlockPixels(env, srcBitmap); jclass je = env->FindClass("java/lang/Exception"); env -> ThrowNew(je, e.what()); return; } catch (...) { AndroidBitmap_unlockPixels(env, srcBitmap); jclass je = env->FindClass("java/lang/Exception"); env -> ThrowNew(je, "unknown"); return; } }
AndroidBitmapInfo 类,AndroidBitmap_getInfo 方法等位于 NDK 中,使得我们可以在 native 层方便的操作 Java 层的 Bitmap 对象,该库是我们在 CMakeLists 文件中通过 jnigraphics 引入的。
AndroidBitmap_getInfo 获取了 Bitmap 的信息,包括图片宽高,图片格式,然后通过 AndroidBitmap_lockPixels 获取像素数组,接着通过不同的图片格式创建不同的 Mat 容器存放像素数组。最后统一转换成 RGBA 格式返回。
回到之前说的主要函数:docScanner.scanPoint()
vector Scanner::scanPoint() { //缩小图片尺寸 Mat image = resizeImage(); //预处理图片 Mat scanImage = preprocessImage(image); vector<vector> contours; //提取边框 findContours(scanImage, contours, RETR_EXTERNAL, CHAIN_APPROX_NONE); //按面积排序 std::sort(contours.begin(), contours.end(), sortByArea); vector result; if (contours.size() > 0) { vector contour = contours[0]; double arc = arcLength(contour, true); vector outDP; //多变形逼近 approxPolyDP(Mat(contour), outDP, 0.02*arc, true); //筛选去除相近的点 vector selectedPoints = selectPoints(outDP, 1); if (selectedPoints.size() != 4) { //如果筛选出来之后不是四边形,那么使用最小矩形包裹 RotatedRect rect = minAreaRect(contour); Point2f p[4]; rect.points(p); result.push_back(p[0]); result.push_back(p[1]); result.push_back(p[2]); result.push_back(p[3]); } else { result = selectedPoints; } for(Point &p : result) { p.x *= resizeScale; p.y *= resizeScale; } } // 按左上,右上,右下,左下排序 return sortPointClockwise(result); }
1. 缩小图片尺寸:
Mat Scanner::resizeImage() { int width = srcBitmap.cols; int height = srcBitmap.rows; int maxSize = width > height? width : height; if (maxSize > resizeThreshold) { resizeScale = 1.0f * maxSize / resizeThreshold; width = static_cast(width / resizeScale); height = static_cast(height / resizeScale); Size size(width, height); Mat resizedBitmap(size, CV_8UC3); resize(srcBitmap, resizedBitmap, size); return resizedBitmap; } return srcBitmap; }
缩小图片尺寸对 OpenCV 来说非常简单,创建一个目标大小的 Size 对象, 创建一个目标大小的 Mat 对象,最后调用 resize 就 OK 了。
2. 预处理图片
Mat Scanner::preprocessImage(Mat& image) { Mat grayMat; cvtColor(image, grayMat, CV_BGR2GRAY); Mat blurMat; GaussianBlur(grayMat, blurMat, Size(5,5), 0); Mat cannyMat; Canny(blurMat, cannyMat, 0, 5); return cannyMat; }
使用 cvtColor 将图片转换成灰度图片;使用 GaussianBlur 对图片做高斯模糊,减少噪点;使用 Canny 做边缘检测,此时图片会变成黑底,白色细线
描图片内容边界的图片,像下面这样:
后面的处理就是基于这种图片的。
3. 提取图片边框:
vector<vector> contours; //提取边框 findContours(scanImage, contours, RETR_EXTERNAL, CHAIN_APPROX_NONE); //按面积排序 std::sort(contours.begin(), contours.end(), sortByArea); vector result; if (contours.size() > 0) { vector contour = contours[0]; double arc = arcLength(contour, true); vector outDP; //多变形逼近 approxPolyDP(Mat(contour), outDP, 0.02*arc, true); //筛选去除相近的点 vector selectedPoints = selectPoints(outDP, 1); if (selectedPoints.size() != 4) { //如果筛选出来之后不是四边形,那么使用最小矩形包裹 RotatedRect rect = minAreaRect(contour); Point2f p[4]; rect.points(p); result.push_back(p[0]); result.push_back(p[1]); result.push_back(p[2]); result.push_back(p[3]); } else { result = selectedPoints; } for(Point &p : result) { p.x *= resizeScale; p.y *= resizeScale; } }
OpenCV 的 findContours 方法能提取出所有线段,以数组的方式返回。然后调用 std::sort 按面积排序,注意最后一个参数 sortByArea 是一个函数指针,用于指定排序的规则:
static bool sortByArea(const vector &v1, const vector &v2) { double v1Area = fabs(contourArea(Mat(v1))); double v2Area = fabs(contourArea(Mat(v2))); return v1Area > v2Area; }
使用 contourArea 可以很方便的计算闭合图像的面积。
找出最大面积的边界之后使用 approxPolyDP 多边形逼近来减少线段数量,期望是四边形,也就是 4 条线段。然后调用 selectPoints 去除一些误判的相近的点:
vector Scanner::selectPoints(vector points, int selectTimes) { if (points.size() > 4) { double arc = arcLength(points, true); vector::iterator itor = points.begin(); while (itor != points.end()) { if (points.size() == 4) { return points; } Point& p = *itor; if (itor != points.begin()) { Point& lastP = *(itor - 1); double pointLength = sqrt(pow((p.x-lastP.x),2) + pow((p.y-lastP.y),2)); if(pointLength < arc * 0.01 * selectTimes && points.size() > 4) { itor = points.erase(itor); continue; } } itor++; } if (points.size() > 4) { return selectPoints(points, selectTimes + 1); } } return points; }
这里使用了递归,返回值预期是大小为4的数组。
如果筛选出来的数组大小不是 4,就使用 OpenCV 的 minAreaRect 获取最小外接局限作为妥协值。
4. 将顶点按左上,右上,右下,左下排序
vector Scanner::sortPointClockwise(vector points) { if (points.size() != 4) { return points; } Point unFoundPoint; vector result = {unFoundPoint, unFoundPoint, unFoundPoint, unFoundPoint}; long minDistance = -1; for(Point &point : points) { long distance = point.x * point.x + point.y * point.y; if(minDistance == -1 || distance < minDistance) { result[0] = point; minDistance = distance; } } if (result[0] != unFoundPoint) { Point &leftTop = result[0]; points.erase(std::remove(points.begin(), points.end(), leftTop)); if ((pointSideLine(leftTop, points[0], points[1]) * pointSideLine(leftTop, points[0], points[2])) < 0) { result[2] = points[0]; } else if ((pointSideLine(leftTop, points[1], points[0]) * pointSideLine(leftTop, points[1], points[2])) < 0) { result[2] = points[1]; } else if ((pointSideLine(leftTop, points[2], points[0]) * pointSideLine(leftTop, points[2], points[1])) < 0) { result[2] = points[2]; } } if (result[0] != unFoundPoint && result[2] != unFoundPoint) { Point &leftTop = result[0]; Point &rightBottom = result[2]; points.erase(std::remove(points.begin(), points.end(), rightBottom)); if (pointSideLine(leftTop, rightBottom, points[0]) > 0) { result[1] = points[0]; result[3] = points[1]; } else { result[1] = points[1]; result[3] = points[0]; } } if (result[0] != unFoundPoint && result[1] != unFoundPoint && result[2] != unFoundPoint && result[3] != unFoundPoint) { return result; } return points; }
已知四个顶点形成的四边形为凸四边形(入参做判断),默认以距离顶点(0,0)最近的点作为左上,然后找一个点与该点相连,如果此时另外两个点分别位于这条线的两侧,那么这条线就位对角线,该点为右下。位于这条线上方的为右上,下发的为左下。
以上就是边框识别的所有内容,
边框裁剪
裁剪相对比较简单一些,下面是通过4个顶点作透视变换裁剪出想要的图片:
static void native_crop(JNIEnv *env, jclass type, jobject srcBitmap, jobjectArray points_, jobject outBitmap) { std::vector points = pointsToNative(env, points_); if (points.size() != 4) { return; } Point leftTop = points[0]; Point rightTop = points[1]; Point rightBottom = points[2]; Point leftBottom = points[3]; Mat srcBitmapMat; bitmap_to_mat(env, srcBitmap, srcBitmapMat); AndroidBitmapInfo outBitmapInfo; AndroidBitmap_getInfo(env, outBitmap, &outBitmapInfo); Mat dstBitmapMat; int newHeight = outBitmapInfo.height; int newWidth = outBitmapInfo.width; dstBitmapMat = Mat::zeros(newHeight, newWidth, srcBitmapMat.type()); vector srcTriangle; vector dstTriangle; srcTriangle.push_back(Point2f(leftTop.x, leftTop.y)); srcTriangle.push_back(Point2f(rightTop.x, rightTop.y)); srcTriangle.push_back(Point2f(leftBottom.x, leftBottom.y)); srcTriangle.push_back(Point2f(rightBottom.x, rightBottom.y)); dstTriangle.push_back(Point2f(0, 0)); dstTriangle.push_back(Point2f(newWidth, 0)); dstTriangle.push_back(Point2f(0, newHeight)); dstTriangle.push_back(Point2f(newWidth, newHeight)); Mat transform = getPerspectiveTransform(srcTriangle, dstTriangle); warpPerspective(srcBitmapMat, dstBitmapMat, transform, dstBitmapMat.size()); mat_to_bitmap(env, dstBitmapMat, outBitmap); }
还是使用 bitmap_to_mat 读出图片信息,分别使用 srcTriangle、dstTriangle 保存待裁剪的区域顶点与裁剪顶点,可以看到裁剪的4个顶点分别对应图片的4个顶点,通过 getPerspectiveTransform 获得变换矩阵,然后运用变换 warpPerspective 得到裁剪好的 Mat 对象, 最后将 Mat 对象转换回 Bitmap 对象。
关于 UI 实现部分就不详细介绍了,全部内容位于 CropImageView 中。最后再说几句,使用 OpenCV 做边框识别还是有很多局限性,容易受背景颜色,其他边框的干扰,如果待识别物体与背景的颜色很相似,那么可能就识别不出来,或者背景有复杂的线也会干扰识别。