1. 程式人生 > >Android 端基於 OpenCV 的邊框識別功能

Android 端基於 OpenCV 的邊框識別功能

智慧圖片裁剪庫:SmartCropper,選擇照片之後會自動識別出邊框的位置,適用於身份證,名片,文件等照片的裁剪。本篇文章主要就邊框識別部分說一下開發過程及實現原理,通過閱讀本篇文章,你將具備以下技能:

  1. 瞭解 NDK 開發的基本步驟,能使用 Java、C++/C 混合開發簡單的應用
  2. 瞭解 OpenCV 庫的作用及其用法,能使用 OpenCV 做影象處理
  3. 瞭解基於 OpenCV 的邊框識別實現

OpenCV 的全稱是 Open Source Computer Vision Library,是一個使用 C++ 編寫的跨平臺的計算機視覺庫,能對輸入的圖片進行處理,包括常見的高斯模糊,提取灰度圖片,提取輪廓等等,可以應用於增強現實,人臉識別,運動跟蹤,物體識別,影象分割槽等。

在 Android 平臺需要使用 JNI 技術來呼叫 C++ 的庫,事實上,OpenCV 的官網已經提供了編寫好的 Android 庫:OpenCv4Android,我們可以按照提示匯入該庫,就可用以使用 Java 程式碼來呼叫了。但是該庫包含了 OpenCV 所有的模組,造成了該庫體積非常大,其中很多並不是我們需要的。所以我的做法是隻使用該庫提供的編譯好的 C++ 庫,挑選自己需要用到的模組,引入其動態或者靜態庫,編寫 C++ 程式碼呼叫 OpenCV 的這些模組完成主要功能,最後使用 JNI 技術編寫 Java 介面供 Android 程式呼叫。

匯入 OpenCV 庫

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})

主要注意點如下:

  1. include_directories新增標頭檔案查詢路徑,包括引入庫的和自己寫的
  2. add_library新增動態庫或靜態庫,其中本地的動態庫名稱,位置可以由set_target_properties設定
  3. find_library通過名稱查詢並引入庫,可以引入 NDK 中的庫,比如日誌模組
  4. 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 做邊框識別還是有很多侷限性,容易受背景顏色,其他邊框的干擾,如果待識別物體與背景的顏色很相似,那麼可能就識別不出來,或者背景有複雜的線也會干擾識別。之前看過一篇文章使用 TensorFlow 結合 OpenCV 來識別邊框的方式能很好的彌補單純用 OpenCV 來做識別的侷限性,可以參考:手機端運行卷積神經網路的一次實踐 — 基於 TensorFlow 和 OpenCV 實現文件檢測功能