Android 與計算機視覺

不管你是否從事計算機視覺相關的工作,瞭解這方面的內容總是好的,因爲即使你現在的工作與 AI 無關,採用一些開放的 API 仍然有可能讓你的應用做得更好。比如,百度開發平臺就提供了許多 AI 相關的 API,像當下比較受歡迎的“白描”等應用,其實就是使用了百度的 API。所以,你也可以考慮一下能否藉助一些語音和文字識別等功能來賦能自己的應用。

因爲我們所做的計算機視覺的東西更多的是對圖片進行處理,這就涉及到 OpenCV 和 Tensorflow 在 Android 端的應用,以及相機和 Android 端的其他圖片處理邏輯。這不可避免地要用到 JNI 和 NDK 當中的一些內容。因此,在本篇文章中,我們想要討論的內容主要包括以下幾個方面的:

  1. Android 端圖片壓縮庫封裝
  2. Android 端相機庫封裝和性能優化
  3. JNI 和 NDK 調用,以及 CMake 在 Android 中的應用
  4. OpenCV 在 Android 中的集成和應用
  5. Tensorflow 在 Android 端的集成和應用

其實之前的文章中我們也提到過一些今天我們想討論的內容。所以在這裏相關的技術底層的知識能帶過的就直接帶過。我們會給出相關的技術文章的鏈接,如果感興趣的可以到指定的文章下面查看更具體的知識。

1、Android 端圖片壓縮庫封裝

爲什麼要做圖像壓縮呢?因爲太大的圖片上傳速度比較慢,會影響程序的用戶體驗;而過分的壓縮圖片會導致程序識別出來的效率比較低。識別的效率每提高 1 個百分點,標註團隊可能就要多標註幾萬張圖片。經過測試發現,把圖片的短邊控制在 1100 左右是最合適的,那麼此時我們就需要制定一個自己的壓縮策略。

這個在之前的文章中我們已經討論過,並且對 Android 端 Bitmap 相關的壓縮的知識都做了介紹。您可以到下面的文章下面瞭解下我們是如何對圖片壓縮庫進行封裝的,以及 Android 中圖片壓縮的底層原理:

開源一個 Android 圖片壓縮框架

當然,上面的文章在介紹的這方面的東西的時候,基於的是我們庫的第一個版本,那個版本可以滿足基本的功能。在後來的版本中,我們又對自己的庫做了完善,增加了更多的 feature。這裏我們主要介紹下新的框架相關的 API 以及後來我們如何做了兼容性的設計,以在第一個版本的基礎之上進行了功能性的拓展。

在實際的使用過程中,我們發現更多的時候你需要對 Bitmap 進行處理而不是 File. 在這個時候,第一個版本的庫就應用不上了。想了想,我們希望能夠對自己的庫進行拓展以支持更多的應用場景。這包括:

  1. 在當前線程中直接獲取壓縮的 Bitmap 而不是通過 Callback 或者 RxJava 的形式傳遞結果:因爲我們有一部分代碼本身就是在 RxJava 中異步執行的,回調或者使用 RxJava 會影響我們程序的邏輯結構。
  2. 直接使用 Bitmap 或者 byte[] 作爲參數進行壓縮而不是先寫入到 File 中,然後對文件進行讀取和壓縮:這個是存在具體的應用場景,比如當你從相機當中獲取數據的時候,實際獲取到的是 byte[],在連續拍攝的情況下,不斷寫入到文件再讀取並進行壓縮非常影響程序性能。
  3. 支持直接返回 Bitmap 而不是隻能返回 File 類型:有時候我們需要對程序的局部做優化,比如圖片處理結果的預覽,此時,如果我們返回的是 File 的話,一樣會影響我們程序的性能和邏輯結構。

最初,我們希望能夠像 Glide 那樣支持對自定義的數據結構進行壓縮,然後自定義圖片獲取的邏輯,然而考慮到時間和兼容的問題,直接放棄了這個想法,轉而採用更加簡單、直接的方式:

  1. 對入參這一塊,直接使用重載函數接受不同的參數類型
  2. 壓縮的過程可以直接使用 get() 方法把壓縮的中間結果返回給調用者
  3. 增加 asBitmap() 方法,轉換輸出參數類型爲 Bitmap 而不是 File 類型

因此,在後來的版本中,你可以像下面這樣直接獲取到 Bitmap 的結果:

    Bitmap result = Compress.with(this, bytes)
            .setQuality(80)
            .strategy(Strategies.compressor())
            .setScaleMode(ScaleMode.SCALE_SMALLER) // 指定放縮的邊
            .setMaxHeight(1100f)
            .setMaxWidth(1100f)
            .asBitmap()  // 調用這個方法表示期望的返回類型是 Bitmap 而不是 File
            .asFlowable()
            .subscribe(b -> {
                // do somthing
            })

這裏關於 asBitmap() 方法的設計可以簡單說明一下。

Compressor 設計圖

圖片鏈接:https://www.processon.com/view/link/5cdfb769e4b00528648784b7

在第一個版本中,我們採用的上圖中第一個圖的設計。在這裏兩種壓縮策略均繼承自 AbstractStrategy,其實上述的 strategy() 方法,你可以把它理解成拐了一個彎,也就是它返回的具體的策略,你下面能調用的方法都侷限在具體的策略中。

在後來的設計中要在 asBitmap() 方法處返回一個具體的構建者繼續按照返回 Bitmap 的邏輯進行處理。此時我們直接返回的是第二張圖中的 BitmapBuilder 對象,而 Abstrategy 則依然按照返回 File 類型的邏輯走。這樣我們可以輕易地在原來的基礎上,通過拐一個彎的形式把後續的構建邏輯轉移到了 BitmapBuilder 對象上面。同時,爲了達到代碼複用的目的,我們引入了泛型的 RequestBuilder<T>。這樣 AbstractStrategy 和 BitmapBuilder 只需要實現它,並指定各自的資源類型即可。又因爲按照之前的邏輯,我們一直在構建的都是 AbstractStrategy 對象,因此,我們只需要把 AbstractStrategy 作爲參數傳入到具體的 RequestBuilder 裏面就可以從它上面直接獲取 Bitmap 了。(Bitmap 是在之間串聯的“通用貨幣”。)這樣我們既複用了大量的代碼又在兼容原始版本的基礎上進行了功能的拓展,妙極!

2、Android 相機庫封裝和性能優化

對於一個 ToC 的應用來說,用戶體驗直觀重要。按照我們的業務場景,如果使用拍照識別的效率比人工操作的效率還要低的話,那麼人工智能似乎就沒有存在的必要了。畢竟我們的目標是提升別人的工作效率,所以在相機這塊就必須做到快速響應。

在項目的初期我們使用的是 Google 開源的 CameraView. 然而在實際的應用過程中,我們逐漸發現這個庫存在大量不好的設計,影響了我們程序的應用性能:

相機啓動 TraceView 分析

配圖是我們使用 TraceView 對程序執行過程進行的性能分析

  1. 沒必要的數據結構構建,影響相機啓動速率:首先,當從相機的屬性當中讀取相機支持的尺寸的時候它會使用這些參數構建一個尺寸的寬高比到尺寸列表的哈希表結構。然後具體的運算的時候從這個哈希表中讀取尺寸再進行計算。這樣設計很不好!因爲當需要計算尺寸的時候遍歷一遍尺寸列表可能並不會佔用太多的時間,並且構建的哈希表結構使用並不頻繁,而在相機啓動階段進行不必要的計算反而影響了相機的啓動速率。

  2. 打開相機的操作在主線程當中執行,影響界面響應速率:前提是界面能夠快速響應用戶,即使打開的是一個黑色的等待界面也比按下沒有響應更容易接受。通過 TraceView 我們發現相機 open() 的過程大概佔用了相機啓動速率的 25%。因此把這個方法的調用放在主線程中是不太合適的。

  3. 相機不支持視頻拍攝和預覽:這個庫是不支持相機的視頻拍攝和預覽的。畢竟作爲計算機視覺的一部分的實時處理也是很重要的一部分。就算當前的項目中沒有這方面的功能,我們也應該考慮對這方面的功能進行支持。(這方面的內容基本就是 OpenGL + Camera)

於是乎,我們自己開發的一款相機庫就誕生了。當然當初開發的一個原因也是希望能夠支持 OpenGL。只是時間太有限,暫時還沒有太多時間去關注這些問題:

相機庫設計 UML 建模圖

圖片鏈接:https://www.processon.com/view/link/5c976af8e4b0d1a5b10a4049

關於這個庫,我只是把它所有的邏輯實現了一遍,並且在我的手機上面調試沒有什麼問題。如果具體應用到生存環境當中還需要更多的測試和驗證。關於 Android 相機開發的知識,主要覆蓋 Camera1 和 Camera2 兩塊內容。一個方法的實現邏輯看懂了,其他方法的實現與之類似,具體的內容可以參考項目的源碼。因爲本人當前時間和精力有限,所以暫時無法詳細講解相機 API 的使用。

我們可以簡單概括一下這份設計圖當中的一些內容:

  1. 三種主要設計模式

    1. 門面模式:考慮到兼容 Camera1 和 Camera2 的問題,我們需要對外提供統一的 API 調用,所以,我們考慮了使用門面模式來做一個統一的封裝。這裏定義了 Camera1Manager 和 Camera2Manager 兩個實現,分別對應於兩種不同的相機 API. 它們統一繼承自 CameraManager 這個門面接口。這種設計模式的好處是對外是統一的,這樣結合具體的工廠+策略模式,我們可以讓用戶在 Camera1 和 Camera2 之間自由選擇。

    2. 策略模式+工廠模式:因爲考慮到各種不同應用場景的兼容,我們希望能夠用戶提供最大的自由度。所以,我們採用了策略的方式,對外提供了接口給用戶來計算最終想要得到的相機尺寸等參數。所以這裏我們定義了一個名爲 ConfigurationProvider 的類,它是一個單例的類,除了負責獲取相機參數的計算策略,同時肩負着內存緩存的責任。這樣對於很多參數的計算,包括預覽尺寸、照片尺寸、視頻尺寸等可以讓用戶自由來指定具體的大小。

  2. 三個主要的優化點

    1. 內存緩存優化:實際上作爲相機屬性的相機所支持尺寸等信息是不變的,使用內存緩存緩存這些數據之後下次就無需再次獲取並進行處理,這樣可以在下次相機啓動的時候顯著提升程序響應的速率。

    2. 延遲初始化,不使用不計算:爲了提升程序的響應速率,我們甚至對數字的計算也進行了優化,當然這個優化點可能效果沒有那麼明顯,但是如果你願意精益求精的話,這也可以當作一個優化點。目前程序裏面還是使用了浮點數進行計算,在早期對於作爲哈希表映射的鍵的字段,我們甚至直接使用移位預算。當然這種優化的效果還要取決於整體的數據量,並且數據量越大的時候優化效果越明顯。

    3. 異步線程優化:在早期的版本中,我們使用的是私有鎖進行線程優化。因爲要把線程的 open() 和設置參數放在兩個線程當中進行,因此不可避免地要遇到線程安全問題。而所謂的私有鎖其實就類似於 Collections.syncXXX() 所返回的同步容器。其實就是對容器的每個方法進行加鎖。這樣雖然可以解決問題,但是程序的結構不好看。所以,後來的版本中,我們直接使用 HandlerThread 來進行異步的調用。所謂的 HandlerThread,顧名思義,就是 Handler 和 Thread 的結合。

(更多的內容可以參考:Android 相機庫開發實踐

3、JNI 和 NDK 調用,以及 CMake 在 Android 中的應用

在之前我們想在在 Java 或者 Android 中調用 C++ 代碼是比較複雜的。這需要進行動態或者靜態的註冊。對於靜態註冊的方式,你需要一步一步地進行編譯;對於動態註冊的方式,你需要把方法一個個地在 native 層進行註冊。不過後來有了 CMake 之後一切都變得簡單了。當然對於 CMake,如果做過 native 的同學肯定不會陌生。對於一般應用層開發的同學,其實也可以瞭解下它。因爲,有了它之後你可以很容易地把你的一部分實現邏輯放在 native 層裏,而 native 層相對於 Java 層比較安全,而且藉助 C++ 和 NDK 你可以做出更多有趣的東西。

要在 Android 端使用 CMake 而是很簡單的,你需要首先在 AS 裏面安裝下相關的 SDK 工具:

CMake 開發環境搭建

然後,你需要在 Gradle 裏面做簡單的配置:

CMake gradle 配置

當然,雖然我們這樣將其來容易,但是進行配置的時候可能需要很多的相關的專業知識。

這裏面會配置到一個 CMake path,它就是指向的 CMake 的配置文件 CMakeLists.txt。通常我們程序中要用到的一些三方的 native 庫就需要在這個地方進行配置。比如下面的就是之前項目裏面的 CMake 的配置。這裏面配置了一些 OpenCV 的庫以及我們自己的代碼所在的位置,並且引用了 NDK 裏面的一些相關的庫:

# 設置要求的 CMake 的最低版本
cmake_minimum_required(VERSION 3.4.1)

# 指定頭文件的目錄
include_directories(opencv/jni/include
        src/main/cpp/include
        ../../common)

add_library(opencv_calib3d STATIC IMPORTED)
add_library(opencv_core STATIC IMPORTED)
# ....

#if(EXISTS ${PROJECT_SOURCE_DIR}/opencv/libs/${ANDROID_ABI}/libtbb.a)
#    add_library(tbb STATIC IMPORTED)
#endif()

set_target_properties(opencv_calib3d PROPERTIES IMPORTED_LOCATION ${PROJECT_SOURCE_DIR}/opencv/libs/${ANDROID_ABI}/libopencv_calib3d.a)
set_target_properties(opencv_core PROPERTIES IMPORTED_LOCATION ${PROJECT_SOURCE_DIR}/opencv/libs/${ANDROID_ABI}/libopencv_core.a)
set_target_properties(opencv_features2d PROPERTIES IMPORTED_LOCATION ${PROJECT_SOURCE_DIR}/opencv/libs/${ANDROID_ABI}/libopencv_features2d.a)
# ....

add_library(everlens
        SHARED
        src/main/cpp/img_proc.cpp
        src/main/cpp/img_cropper.cpp
        src/main/cpp/android_utils.cpp
        ../../common/EdgeFinder.cpp
        ../../common/ImageFilter.cpp)

find_library(log-lib
        log)

find_library(jnigraphics-lib
        jnigraphics)

if(EXISTS ${PROJECT_SOURCE_DIR}/opencv/libs/${ANDROID_ABI}/libtbb.a)
    target_link_libraries(
            my_library
            opencv_stitching
            opencv_features2d
            # ....

            ${log-lib}
            ${jnigraphics-lib})
else()
    target_link_libraries(
            my_library
            opencv_stitching
            opencv_features2d
            # ....

            ${log-lib}
            ${jnigraphics-lib})
endif()

對於 CMake 的一些指令,之前也進行過一些總結,並且對指定了官方文檔的地址。如果想要了解的話,可以到文章下面瞭解更多的內容:

常用的 CMake 指令總結

使用 CMake 的好處主要是 AS 支持得比較好:

  1. 可以根據 native 層代碼到 Java 層代碼之間的關係,鼠標左鍵 + Ctrl 即可直接完成 native 層方法和 Java 層方法之間的跳轉;

  2. 無需進行繁瑣的動態註冊和靜態註冊,只需要在 CMake 和 Gradle 當中進行配置,可以把注意力更多地放在自己的代碼的邏輯實現上。

當然,就算使用了 CMake 有時候還是需要了解一些 JNI 中動態註冊的內容,因爲有時候當你在 native 層中從 Java 層傳入的對象上面獲取信息的時候還是需要進行動態註冊。比如,

#include <jni.h>
#include <string>
#include <android_utils.h>

// 定義一個結構體及實例 gPointInfo
static struct {
    jclass jClassPoint;
    jmethodID jMethodInit;
    jfieldID jFieldIDX;
    jfieldID jFieldIDY;
} gPointInfo;

// 初始化 Class 信息,注意下映射關係是如何表達的,其實就類似於反編譯之後的註釋
static void initClassInfo(JNIEnv *env) {
    gPointInfo.jClassPoint = reinterpret_cast<jclass>(env -> NewGlobalRef(env -> FindClass("android/graphics/Point")));
    gPointInfo.jMethodInit = env -> GetMethodID(gPointInfo.jClassPoint, "<init>", "(II)V");
    gPointInfo.jFieldIDX = env -> GetFieldID(gPointInfo.jClassPoint, "x", "I");
    gPointInfo.jFieldIDY = env -> GetFieldID(gPointInfo.jClassPoint, "y", "I");
}

// 動態註冊,在這裏初始化
extern "C"
JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv *env = NULL;
    if (vm->GetEnv((void **) &env, JNI_VERSION_1_4) != JNI_OK) {
        return JNI_FALSE;
    }
    initClassInfo(env);
    return JNI_VERSION_1_4;
}

(更多的內容可以參考:在 Android 中使用 JNI 的總結

4、OpenCV 在 Android 中的集成和應用:圖片裁剪以及透視變換

4.1 關於 OpenCV 的集成

當然,不引用 OpenCV 的 C++ 庫,直接使用別人封裝好的 Java 庫也是可以的,這取決於具體的應用場景。比如,如果你不需要實現特別複雜的功能,只需要簡單的圖像處理即可,那麼別人包裝過的 Java 庫已經可以完全滿足你的需求。但如果你像我們一樣,本身需要包裝和編譯來自算法同學的 C++ 算法,甚至還需要使用 OpenCV 的拓展庫,那麼使用 Java 包裝後的庫可能無法滿足你的需求。

下面是 OpenCV 及其拓展庫的 Github 地址:

有了這些庫你還是無法直接將其應用到程序當中的。因爲上述項目得到的是 OpenCV 的源碼,主要是源代碼以及一些頭文件,還需要對它們進行編譯然後再應用到自己的項目當中。

Build OpenCV 3.3 Android SDK on Mac OSX

當然也有一些已經編譯完成的 OpenCV 及其拓展庫,我們可以在 CMake 中配置之後直接將其引用到我們的項目中:

opencv3-android-sdk-with-contrib

所以最終項目的結構如下:

OpenCV 集成之後的項目結構

左邊圈出的部分是 OpenCV 及 CMake 的一些配置,右邊是封裝之後的 Java 方法。

4.2 關於 OpenCV 的應用

OpenCV 可以用來處理做很多圖片處理的工作,很多工作是使用 Android 原生的 Bitmap 無法完成的。比如,圖片不規則裁剪之後的透視變換、灰度化處理等。其實,不論你在 native 層如何對圖片進行處理,在 Android 當中,對 native 層的輸入和 native 層的輸出都是 Bitmap. 而 OpenCV::Mat 就像是 native 層圖片處理的通用貨幣。所以,一個完整的圖片處理的流程大致是:

  1. Step1: Java 層的 Bitmap 轉換成 native 層的 Mat;
  2. Step2: 使用 Mat 進行圖片處理;
  3. Step3: 將 native 層的 Mat 轉換成 Java 層的 Bitmap 並返回。

將 Java 層的 Bitmap 轉換成 native 層的 Mat 你可以使用下面的方法:

#include <jni.h>
#include <android/bitmap.h>
#include "android_utils.h"

void bitmap_to_mat(JNIEnv *env, jobject &srcBitmap, Mat &srcMat) {
    void *srcPixels = 0;
    AndroidBitmapInfo srcBitmapInfo;
    try {
        // 調用 AndroidBitmap 中的方法獲取 bitmap 信息
        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);
        // 根據 bitmap 的格式構建不同通道的 Mat
        if (srcBitmapInfo.format == ANDROID_BITMAP_FORMAT_RGBA_8888) {
            Mat tmp(srcHeight, srcWidth, CV_8UC4, srcPixels);
            tmp.copyTo(srcMat);
            cvtColor(tmp, srcMat, COLOR_RGBA2RGB);
        } 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);
        // 構建一個 Java 層的異常並將其拋出
        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;
    }
}

這裏主要是先從 Bitmap 中獲取圖片具體的信息,這裏調用了 NDK 裏面的圖像相關的一些方法。然後利用得到的圖片尺寸信息和顏色信息構建 OpenCV 裏面的 Mat. Mat 就類似於 MATLAB 裏面的矩陣,它包含了圖像的像素等信息,並且也提供了類似於 eye(), zeros() 等類似的方法用來構建特殊的矩陣。

將 Bitmap 轉換成 Mat 之後就是如何使用它們了。下面是一份用來對圖片進行裁剪和透視變換的算法:

// 將 Java 層的頂點轉換成 native 層的 Point 對象
static std::vector<Point> pointsToNative(JNIEnv *env, jobjectArray points_) {
    int arrayLength = env->GetArrayLength(points_);
    std::vector<Point> result;
    for(int i = 0; i < arrayLength; i++) {
        jobject point_ = env -> GetObjectArrayElement(points_, i);
        int pX = env -> GetIntField(point_, gPointInfo.jFieldIDX);
        int pY = env -> GetIntField(point_, gPointInfo.jFieldIDY);
        result.push_back(Point(pX, pY));
    }
    return result;
}

// 裁剪並且透視變化
extern "C" JNIEXPORT void JNICALL
Java_xxxx_MyCropper_nativeCrop(JNIEnv *env, jclass type, jobject srcBitmap, jobjectArray points_, jobject outBitmap) {
    std::vector<Point> points = pointsToNative(env, points_);
    if (points.size() != 4) {
        return;
    }
    // 取出四個頂點
    Point leftTop = points[0], rightTop = points[1], rightBottom = points[2], leftBottom = points[3];

    // 獲取源圖和結果圖對應的 Mat
    Mat srcBitmapMat, dstBitmapMat;
    bitmap_to_mat(env, srcBitmap, srcBitmapMat);
    AndroidBitmapInfo outBitmapInfo;
    AndroidBitmap_getInfo(env, outBitmap, &outBitmapInfo);
    int newHeight = outBitmapInfo.height, newWidth = outBitmapInfo.width;
    dstBitmapMat = Mat::zeros(newHeight, newWidth, srcBitmapMat.type());

    // 將圖片的頂點放進集合當中,用來調用透視的方法
    std::vector<Point2f> srcTriangle, 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 轉換成 Bitmap 輸出到 Java 層
    mat_to_bitmap(env, dstBitmapMat, outBitmap);
}

算法最終的輸出結果:

透視變化和圖像切割

5、Tensorflow 在 Android 端的集成和應用:圖片邊緣檢測

在之前對圖片的邊緣進行檢測的時候,因爲發現 OpenCV 算法效果不太理性,所以後來選擇使用 TensorFlow 對圖片進行邊緣檢測。這就涉及到在 Android 端集成 Tensorflow Lite。前段時間也看到愛奇藝的 SmartVR 的介紹。藉助一些官方的資料,在 Android 端使用 TF 並不難。在 Tensorflow 的開源倉庫中已經有一些 Sample 可供參考:

做邊緣檢測當然有些大材小用的味道,但是對於我們 Android 開發者來說,借這個機會了解如何在 Android 端集成一些 Tensorflow 也可以拓展一下。畢竟這種東西屬於當下比較熱門的東西,說不定哪天心血來潮自己訓練一個模型呢 😃

在 Android 端引入 Tensorflow 並不複雜,只需要添加相關的倉庫以及依賴:

allprojects {
    repositories {
        jcenter()
        maven { url 'https://google.bintray.com/tensorflow' }
    }
}

dependencies {
    // ...
    // tensorflow
    api 'org.tensorflow:tensorflow-lite:0.0.0-nightly'
}

困難的地方在於如何對訓練的模型的輸入和輸出進行處理。因爲所謂的模型,你可以將其理解成鍛煉出來的一個函數,你只需要按照要求的輸入,它就可以按照固定的格式給你返回一個輸出。所以,具體的輸入和輸出是什麼樣的還要取決於鍛鍊模型的同學。

在我們之前開發的時候,最初是訓練模型的同學使用 Python 代碼調用的模型。使用 Python 雖然代碼簡潔,但是對於客戶端開發簡直就是噩夢。因爲像 NumPyPillow 這種函數庫,一行代碼的任務,你可能要“翻譯”很久。後來,我們使用的是 C++ + OpenCV 的形式。對於 iOS 開發,因爲他們可以使用 Object-C 與 C++ 混編,所以比較輕鬆。對於 Android 開發則需要做一些處理。

下面是加載模型以及在調用 Tensorflow 之前在 Java 層所做的一些處理:

public class TFManager {

    private static TFManager instance;

    private static final float IMAGE_MEAN = 128.0f;
    private static final float IMAGE_STD = 128.0f;

    private Interpreter interpreter;
    private int[] intValues;
    private ByteBuffer imgData;
    private int inputSize = 256;

    public static TFManager create(Context context) { // DCL
        if (instance == null) {
            synchronized (TFManager.class) {
                if (instance == null) {
                    instance = new TFManager(context);
                }
            }
        }
        return instance;
    }

    // 從 Assets 中加載模型,用來初始化 TF
    private static MappedByteBuffer loadModelFile(AssetManager assets, String modelFilename) throws IOException {
        AssetFileDescriptor fileDescriptor = assets.openFd(modelFilename);
        FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
        FileChannel fileChannel = inputStream.getChannel();
        long startOffset = fileDescriptor.getStartOffset();
        long declaredLength = fileDescriptor.getDeclaredLength();
        return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
    }

    // 初始化 TF
    private TFManager(Context context) {
        try {
            interpreter = new Interpreter(loadModelFile(context.getAssets(), "Model.tflite"));
            interpreter.setNumThreads(1);
            interpreter.resizeInput(0, new int[]{1, 256, 256, 3});
            intValues = new int[inputSize * inputSize];
            imgData = ByteBuffer.allocateDirect(inputSize * inputSize * 4 * 3);
            imgData.order(ByteOrder.nativeOrder());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    // 邊緣識別
    public EdgePoint[] recognize(Bitmap bitmap) {
        long timeStart = System.currentTimeMillis();
        imgData.rewind();
        Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true);
        scaledBitmap.getPixels(intValues, 0, scaledBitmap.getWidth(), 0, 0, scaledBitmap.getWidth(), scaledBitmap.getHeight());
        for (int i = 0; i < inputSize; ++i) {
            for (int j = 0; j < inputSize; ++j) {
                int pixelValue = intValues[i * inputSize + j];
                // 取 RBG 做歸一化處理,結果在 -1 到 1 之間
                imgData.putFloat((((pixelValue >> 16) & 0xFF) - IMAGE_MEAN) / IMAGE_STD); // R
                imgData.putFloat((((pixelValue >> 8) & 0xFF) - IMAGE_MEAN) / IMAGE_STD); // G
                imgData.putFloat(((pixelValue & 0xFF) - IMAGE_MEAN) / IMAGE_STD); // B
            }
        }
        LogUtils.d("----------TFManager prepare imgData cost : " + (System.currentTimeMillis() - timeStart));

        timeStart = System.currentTimeMillis();
        Map<Integer, Object> outputMap = new HashMap<>();
        outputMap.put(0, new float[1][256][256][5]);

        Object[] inputArray = {imgData};
        // 調用 TF 進行識別
        interpreter.runForMultipleInputsOutputs(inputArray, outputMap);

        // 對識別的結果進行處理,主要是對圖片的像素進行處理
        float[][][][] arr = (float[][][][]) outputMap.get(0);
        int[][] colors = new int[5][256 * 256];
        for (int i=0; i<5; i++) {
            for (int j=0; j<256; j++) {
                for (int k=0; k<256; k++) {
                    colors[i][j*256 + k] = (int) (arr[0][j][k][i] * 256);
                }
            }
        }
        LogUtils.d("----------TFManager handle TF result cost : " + (System.currentTimeMillis() - timeStart));

        timeStart = System.currentTimeMillis();
        // 將得到的圖片像素按照固定的格式交給 native 層繼續進行邊緣識別
        EdgePoint[] points = ImgProc.findEdges(bitmap, colors);
        LogUtils.d("----------TFManager" + Arrays.toString(points));
        LogUtils.d("----------TFManager find edges cost : " + (System.currentTimeMillis() - timeStart));
        return points;
    }
}

這裏程序的主要執行流程是:

  1. 從 Assets 的模型文件中獲取打開輸入流,然後從輸入流中打開一個管道,這裏用到了 NIO 中的一些類。然後,從管道中獲取一個字節緩存區。文件讀寫的時候管道直接與緩衝區進行交互。除了作爲一個緩存區,這個緩存區還具有內存映射的功能。類似於 mmap 吧,主要是爲了提升文件讀寫的效率。

  2. 初始化並配置 Tensorflow,上面的一些參數用來設置線程數量等信息,比較簡單。後面等一些參數主要用來按照模型等要求對 TF 進行調整。比如,我們使用模型來判斷圖片的頂點的時候使用的是隻包含 RGB 三個緯度的 256 * 256 的圖片。所以,這裏使用了下面幾行代碼來進行設置:

// inputSize = 256;
interpreter.resizeInput(0, new int[]{1, 256, 256, 3});
intValues = new int[inputSize * inputSize];
// 256 * 256 的圖片,3 個緯度,四張圖
imgData = ByteBuffer.allocateDirect(inputSize * inputSize * 4 * 3);
  1. 對要識別對圖片進行處理。這裏需要先對圖片進行放縮,將其控制到 256 * 256 的大小。然後,使用 Bitmap 的方法獲取圖片的像素。下面的幾行代碼是對圖片對 RBG 三種色彩提取,並分別對其進行歸一化處理。處理之後對結果統一寫入到 imgData 當中,作爲模型對輸入。

  2. 按照模型對輸出對文件的格式構建一個 Java 對象作爲模型的輸出參數。調用模型的方法進行識別。

  3. 對模型對輸出結果進行處理。根據我們上述定義對模型輸出 new float[1][256][256][5],這裏實際對含義是 256 * 256 的 5 張圖片。因爲模型輸出的數據並不是原始的像素信息,所以需要乘以 256 來得到真正的圖片的像素。最後就是使用這些像素以及 Bitmap 的方法來得到最終的 Bitmap.

上面調用完了模型但是整個流程還沒有結束。因爲只是調用模型得到了五張識別之後的圖片。這五張圖片就是只留下了圖片邊緣的邊框,所以想要得到圖片的頂點還需要繼續對這五張圖進行處理。這部分需要一些算法,雖然在 Java 層去判斷也是可以的,但是在 native 層,藉助 OpenCV 的一些庫可以使整個過程更加簡單。因此,這裏又要涉及一個 JNI 調用:

extern "C"
JNIEXPORT void JNICALL
Java_com_xxx_ImgProc_nativeFindEdges(JNIEnv *env, jclass type, jintArray mask1_, jintArray mask2_,
                                                        jintArray mask3_, jintArray mask4_, jintArray mask5_,
                                                        jobject origin, jobjectArray points) {
    // 從 Java 中傳入的數組元素
    jint *mask1 = env->GetIntArrayElements(mask1_, NULL);
    jint *mask2 = env->GetIntArrayElements(mask2_, NULL);
    jint *mask3 = env->GetIntArrayElements(mask3_, NULL);
    jint *mask4 = env->GetIntArrayElements(mask4_, NULL);
    jint *mask5 = env->GetIntArrayElements(mask5_, NULL);

    // 原圖轉換成 Native 層的 mat
    Mat originMat;
    bitmap_to_mat(env, origin, originMat);

    // 構建一個集合
    std::vector<jint*> jints;
    jints.push_back(mask1);
    jints.push_back(mask2);
    jints.push_back(mask3);
    jints.push_back(mask4);
    jints.push_back(mask5);

    // 從像素點中得到對應的 Mat 並將其放到一個集合當中
    std::vector<cv::Mat> masks;
    for (int k = 0; k < 5; ++k) {
        Mat mask(256, 256, CV_8UC1);
        for (int i = 0; i < 256; ++i) {
            for (int j = 0; j < 256; ++j) {
                mask.at<uint8_t>(i, j) = (char)(*(jints[k] + i * 256 + j));
            }
        }
        masks.push_back(mask);
    }

    try {
        // 調用算法進行邊緣檢測
        EdgeFinder finder = ImageEngine::EdgeFinder(
                originMat, masks[0], masks[1], masks[2], masks[3], masks[4]);
        vector<cv::Point2d> retPoints = finder.FindBorderCrossPoint();
        // 將得到的“點”轉換成 Java 層的對象
        jclass class_point = env->FindClass("com/xxx/EdgePoint");
        jmethodID method_point = env->GetMethodID(class_point, "<init>", "(FF)V");
        // 將頂點組成一個 Java 數組返回
        for (int i = 0; i < 4; ++i) {
            jobject point = env->NewObject(class_point, method_point, retPoints[i].x, retPoints[i].y);
            env->SetObjectArrayElement(points, i, point);
        }
    }  catch (cv::Exception &e) {
        jclass je = env->FindClass("java/lang/Exception");
        env -> ThrowNew(je, e.what());
        return;
    } catch (...) {
        jclass je = env->FindClass("java/lang/Exception");
        env -> ThrowNew(je, "unknown");
        return;
    }

    // 釋放資源
    env->ReleaseIntArrayElements(mask1_, mask1, 0);
    env->ReleaseIntArrayElements(mask2_, mask2, 0);
    env->ReleaseIntArrayElements(mask3_, mask3, 0);
    env->ReleaseIntArrayElements(mask4_, mask4, 0);
    env->ReleaseIntArrayElements(mask5_, mask5, 0);
}

這裏的主要邏輯是把之前得到的像素以及原始圖片的 Bitmap 統一傳入到 native 層,然後這些像素中得到 OpenCV 對應的 Mat,再一起作爲算法的參數調用算法來得到頂點信息。最後把得到的頂點信息映射成 Java 層的類,並將其放在數組中返回即可。

從上面的流程中也可以看出,整個調用流程實際上進行了多次的 JNI 調用:

  1. 調用 Bitmap 的方法本身就是一次 JNI 調用,調用了 Android 底層的 Skia 的庫來實現圖片處理;
  2. TF-Lite 本身就是對 native 層方法的一個封裝,調用其方法也涉及到 JNI 調用;
  3. 最後是對模型識別的結果進行處理,這也涉及 JNI 調用。

JNI 調用的時候需要進行額外的轉換操作,需要在函數開始的時候把 Java 層的對象轉換成 native 層的對象,在算法調用完畢之後再將 native 層的對象轉換成 Java 層的對象。這是我們以後可以優化的一個地方。

總結

以上就是計算機視覺在 Android 中的應用,主要涉及 JNI 的一些內容,以及 OpenCV 和 Tensorflow 的一些應用。再這之前介紹了圖片壓縮和相機庫的一些封裝,如果你的程序中需要一些圖片處理的功能的話,我想這些東西肯定是對你有用的 😄


關注作者獲取更多知識:

更多知識請參考 Github, Android-notes

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章