OpenCV + Kotlin 實現 USB 攝像頭(相機)實時畫面、拍照

語言: CN / TW / HK

“我正在參加「掘金·啟航計劃」”

一. 業務背景

我們團隊前段時間做了一款小型的智慧硬體,它能夠自動拍攝一些商品的圖片,這些圖片將會出現在電商 App 的詳情頁並進行展示。

基於以上的背景,我們需要一個業務後臺用於傳送相應的拍照指令,還需要開發一款軟體(上位機)用於接收拍照指令和操作硬體裝置。

二. 原先的實現方式以及痛點

早期為了快速實現功能,我們團隊使用 JavaCV 呼叫 USB 攝像頭(相機)進行實時畫面的展示和拍照。這樣的好處在於,能夠快速實現產品經理提出的功能,並快速上線。當然,也會遇到一些問題。

我列舉幾個遇到的問題: 1. 軟體體積過大 2. 編譯速度慢 3. 軟體執行時佔用大量的記憶體 4. 對於獲取的實時畫面,不利於在軟體側(客戶端側)呼叫機器學習或者深度學習的庫,因為整個軟體採用 Java/Kotlin 編寫的。

三. 使用 OpenCV 進行重構

基於上述的原因,我嘗試用 OpenCV 替代 JavaCV 看看能否解決這些問題。

3.1JNI 呼叫的設計

由於我使用 OpenCV C++ 版本來進行開發,因此在開發之前需要先設計好應用層(我們的軟體主要是採用 Java/Kotlin 編寫的)如何跟 Native 層進行互動的一些的方法。比如:USB 攝像頭(相機)的開啟和關閉、拍照、相機相關引數的設定等等。

為此,設計了一個專門用於影象處理的類 WImagesProcess(W 是專案的代號),它包含了上述的方法。

```kotlin object WImagesProcess {

init {
    System.load("${FileUtil.loadPath}WImagesProcess.dll")
}

/**
 * 演算法的版本號
 */
external fun getVersion():String

/**
 * 獲取 OpenCV 對應相機的 index id
 * @param pidvid 相機的 pid、vid
 */
external fun getCameraIndexIdFromPidVid(pidvid:String):Int

/**
 * 開啟俯拍相機
 * @param index 相機的 index id
 * @param cameraParaMap 相機相關的引數
 * @param listener jni 層給 Java 層的回撥
 */
external fun startTopVideoCapture(index:Int, cameraParaMap:Map<String,String>, listener: VideoCaptureListener)

/**
 * 開啟側拍相機
 * @param index 相機的 index id
 * @param cameraParaMap 相機相關的引數
 * @param listener jni 層給 Java 層的回撥
 */
external fun startRightVideoCapture(index:Int, cameraParaMap:Map<String,String>, listener: VideoCaptureListener)

/**
 * 呼叫對應的相機拍攝照片,使用時需要將 IntArray 轉換成 BufferedImage
 * @param cameraId  1:俯拍相機; 2:側拍相機
 */
external fun takePhoto(cameraId:Int): IntArray

/**
 * 設定相機的曝光
 * @param cameraId  1:俯拍相機; 2:側拍相機
 */
external fun exposure(cameraId: Int, value: Double):Double

/**
 * 設定相機的亮度
 * @param cameraId  1:俯拍相機; 2:側拍相機
 */
external fun brightness(cameraId: Int, value: Double):Double

/**
 * 設定相機的焦距
 * @param cameraId  1:俯拍相機; 2:側拍相機
 */
external fun focus(cameraId: Int, value: Double):Double

/**
 * 關閉相機,釋放相機的資源
 * @param cameraId 1:俯拍相機; 2:側拍相機
 */
external fun closeVideoCapture(cameraId:Int)

} ```

其中,VideoCaptureListener 是監聽 USB 攝像頭(相機)行為的 Listener。

```kotlin interface VideoCaptureListener {

/**
 * Native 層呼叫相機成功
 */
fun onSuccess()

/**
 * jni 將 Native 層呼叫相機獲取每一幀的 Mat 轉換成 IntArray,回撥給 Java 層
 * @param array 回撥給 Java 層的 IntArray,Java 層可以將其轉化成 BufferedImage
 */
fun onRead(array: IntArray)

/**
 * Native 層呼叫相機失敗
 */
fun onFailed()

} ```

VideoCaptureListener#onRead() 方法是在攝像頭(相機)開啟後,會實時將每一幀的資料通過回撥的形式返回給應用層。

3.2 JNI && Native 層的實現

定義一個 xxx_WImagesProcess.h,它與應用層的 WImagesProcess 類對應。

```cpp

include

ifndef _Include_xxx_WImagesProcess

define _Include_xxx_WImagesProcess

ifdef __cplusplus

extern "C" {

endif

JNIEXPORT jstring JNICALL Java_xxx_WImagesProcess_getVersion (JNIEnv* env, jobject);

JNIEXPORT void JNICALL Java_xxx_WImagesProcess_startTopVideoCapture (JNIEnv* env, jobject,int index,jobject cameraParaMap ,jobject listener);

JNIEXPORT void JNICALL Java_xxx_WImagesProcess_startRightVideoCapture (JNIEnv* env, jobject, int index, jobject cameraParaMap, jobject listener);

JNIEXPORT jintArray JNICALL Java_xxx_WImagesProcess_takePhoto (JNIEnv* env, jobject, int cameraId);

JNIEXPORT double JNICALL Java_xxx_WImagesProcess_exposure (JNIEnv* env, jobject, int cameraId,double value);

JNIEXPORT double JNICALL Java_xxx_WImagesProcess_brightness (JNIEnv* env, jobject, int cameraId, double value);

JNIEXPORT double JNICALL Java_xxx_WImagesProcess_focus (JNIEnv* env, jobject, int cameraId, double value);

JNIEXPORT void JNICALL Java_xxx_WImagesProcess_closeVideoCapture (JNIEnv* env, jobject, int cameraId);

JNIEXPORT int JNICALL Java_xxx_WImagesProcess_getCameraIndexIdFromPidVid (JNIEnv* env, jobject, jstring pidvid);

ifdef __cplusplus

}

endif

endif

pragma once

```

xxx 代表的是 Java 專案中 WImagesProcess 類所在的 package 名稱。畢竟是公司專案,我不便貼出完整的 package 名稱。不熟悉這種寫法的,可以參考 JNI 的規範。

接下來,需要定義一個 xxx_WImagesProcess.cpp 用於實現上述的方法。

3.2.1 USB 攝像頭(相機)的開啟

僅以 startTopVideoCapture() 為例,它的作用是開啟智慧硬體的俯拍相機,該硬體有 2 款相機介紹其中一種實現方式,另一種也很類似。

```cpp JNIEXPORT void JNICALL Java_xxx_WImagesProcess_startTopVideoCapture (JNIEnv* env, jobject, int index, jobject cameraParaMap, jobject listener){ jobject topListener = env-> NewLocalRef(listener);

std::map<string, string> mapOut;
JavaHashMapToStlMap(env,cameraParaMap,mapOut);

jclass listenerClass = env->GetObjectClass(topListener);
jmethodID successId = env->GetMethodID(listenerClass, "onSuccess", "()V");
jmethodID readId = env->GetMethodID(listenerClass, "onRead", "([I)V");
jmethodID failedId = env->GetMethodID(listenerClass, "onFailed", "()V");
jobject listenerObject = env->NewLocalRef(listenerClass);


try {
    topVideoCapture = wImageProcess.getVideoCapture(index, mapOut);
    env->CallVoidMethod(listenerObject, successId);

    jintArray jarray;
    topVideoCapture >> topFrame;
    int* data = new int[topFrame.total()];
    int size = topFrame.rows * topFrame.cols;
    jarray = env->NewIntArray(size);

    char r, g, b;

    while (topFlag) {
        topVideoCapture >> topFrame;

        for (int i = 0;i < topFrame.total();i++) {
            r = topFrame.data[3 * i + 2];
            g = topFrame.data[3 * i + 1];
            b = topFrame.data[3 * i + 0];
            data[i] = (((jint)r << 16) & 0x00FF0000) +
                (((jint)g << 8) & 0x0000FF00) + ((jint)b & 0x000000FF);
        }

        env->SetIntArrayRegion(jarray, 0, size, (jint*)data);
        env->CallVoidMethod(listenerObject, readId, jarray);
        waitKey(100);
    }
    topVideoCapture.release();
    env->ReleaseIntArrayElements(jarray, env->GetIntArrayElements(jarray, JNI_FALSE), 0);
    delete []data;
}
catch (...) {
    env->CallVoidMethod(listenerObject, failedId);
}

env->DeleteLocalRef(listenerObject);
env->DeleteLocalRef(topListener);

} ```

這個方法用了很多 JNI 相關的內容,接下來會簡單說明。

首先,JavaHashMapToStlMap() 方法用於將 Java 的 HashMap 轉換成 C++ STL 的 Map。開啟相機時,需要傳遞相機相關的引數。由於相機需要設定引數很多,因此在應用層使用 HashMap,傳遞到 JNI 層需要將他們進行轉化成 C++ 能用的 Map。

```cpp void JavaHashMapToStlMap(JNIEnv env, jobject hashMap, std::map& mapOut) { // Get the Map's entry Set. jclass mapClass = env->FindClass("java/util/Map"); if (mapClass == NULL) { return; } jmethodID entrySet = env->GetMethodID(mapClass, "entrySet", "()Ljava/util/Set;"); if (entrySet == NULL) { return; } jobject set = env->CallObjectMethod(hashMap, entrySet); if (set == NULL) { return; } // Obtain an iterator over the Set jclass setClass = env->FindClass("java/util/Set"); if (setClass == NULL) { return; } jmethodID iterator = env->GetMethodID(setClass, "iterator", "()Ljava/util/Iterator;"); if (iterator == NULL) { return; } jobject iter = env->CallObjectMethod(set, iterator); if (iter == NULL) { return; } // Get the Iterator method IDs jclass iteratorClass = env->FindClass("java/util/Iterator"); if (iteratorClass == NULL) { return; } jmethodID hasNext = env->GetMethodID(iteratorClass, "hasNext", "()Z"); if (hasNext == NULL) { return; } jmethodID next = env->GetMethodID(iteratorClass, "next", "()Ljava/lang/Object;"); if (next == NULL) { return; } // Get the Entry class method IDs jclass entryClass = env->FindClass("java/util/Map$Entry"); if (entryClass == NULL) { return; } jmethodID getKey = env->GetMethodID(entryClass, "getKey", "()Ljava/lang/Object;"); if (getKey == NULL) { return; } jmethodID getValue = env->GetMethodID(entryClass, "getValue", "()Ljava/lang/Object;"); if (getValue == NULL) { return; } // Iterate over the entry Set while (env->CallBooleanMethod(iter, hasNext)) { jobject entry = env->CallObjectMethod(iter, next); jstring key = (jstring)env->CallObjectMethod(entry, getKey); jstring value = (jstring)env->CallObjectMethod(entry, getValue); const char keyStr = env->GetStringUTFChars(key, NULL); if (!keyStr) { return; } const char* valueStr = env->GetStringUTFChars(value, NULL); if (!valueStr) { env->ReleaseStringUTFChars(key, keyStr); return; }

    mapOut.insert(std::make_pair(string(keyStr), string(valueStr)));

    env->DeleteLocalRef(entry);
    env->ReleaseStringUTFChars(key, keyStr);
    env->DeleteLocalRef(key);
    env->ReleaseStringUTFChars(value, valueStr);
    env->DeleteLocalRef(value);
}

} ```

接下來幾行,表示將應用層傳遞的 VideoCaptureListener 在 JNI 層需要獲取其型別。然後,查詢 VideoCaptureListener 中的幾個方法,便於後面呼叫。這樣 JNI 層就可以跟應用層的 Java/Kotlin 進行互動了。

cpp jclass listenerClass = env->GetObjectClass(topListener); jmethodID successId = env->GetMethodID(listenerClass, "onSuccess", "()V"); jmethodID readId = env->GetMethodID(listenerClass, "onRead", "([I)V"); jmethodID failedId = env->GetMethodID(listenerClass, "onFailed", "()V");

接下來,開始開啟攝像頭(相機),並回調給應用層,這樣 VideoCaptureListener#onSuccess() 方法就能收到回撥。

cpp topVideoCapture = wImageProcess.getVideoCapture(index, mapOut); env->CallVoidMethod(listenerObject, successId);

開啟攝像頭(相機)後,就可以實時把獲取的每一幀返回給應用層。同樣,VideoCaptureListener#onRead() 方法就能收到回撥。

```cpp while (topFlag) { topVideoCapture >> topFrame;

        for (int i = 0;i < topFrame.total();i++) {
            r = topFrame.data[3 * i + 2];
            g = topFrame.data[3 * i + 1];
            b = topFrame.data[3 * i + 0];
            data[i] = (((jint)r << 16) & 0x00FF0000) +
                (((jint)g << 8) & 0x0000FF00) + ((jint)b & 0x000000FF);
        }

        env->SetIntArrayRegion(jarray, 0, size, (jint*)data);
        env->CallVoidMethod(listenerObject, readId, jarray);
        waitKey(100);
    }

```

後面的程式碼是關閉相機,釋放資源。

3.2.2 開啟相機,設定相機引數

在 3.2.1 中,有以下這樣一段程式碼:

cpp topVideoCapture = wImageProcess.getVideoCapture(index, mapOut);

它的用途是通過 index id 開啟對應的相機,並設定相機需要的引數,最後返回 VideoCapture 物件。

```cpp VideoCapture WImageProcess::getVideoCapture(int index, std::map cameraParaMap) { VideoCapture capture(index);

for (auto & t : cameraParaMap) {
    int key = stoi(t.first);
    double value = stod(t.second);
    capture.set(key, value);
}

return capture;

} ```

對於存在同時呼叫多個相機的情況,OpenCV 需要基於 index id 來獲取對應的相機。那如何獲取 index id 呢?以後有機會再寫一篇文章吧。

WImagesProcess 類還額外提供了多個方法用於設定相機的曝光、亮度、焦距等。我們在啟動相機的時候不是可以通過 HashMap 來傳遞相機需要的引數嘛,為何還提供這些方法呢?這樣做的目的是因為針對不同商品拍照時,可能會調節相機相關的引數,因此 WImagesProcess 類提供了這些方法。

3.2.3 拍照

基於 cameraId 來找到對應的相機進行拍照,並將結果返回給應用層,唯一需要注意的是 C++ 得手動釋放資源。

```cpp JNIEXPORT jintArray JNICALL Java_xxx_WImagesProcess_takePhoto (JNIEnv* env, jobject, int cameraId) {

Mat mat;
if (cameraId == 1) {
    mat = topFrame;
}
else if (cameraId == 2) {
    mat = rightFrame;
}

int* data = new int[mat.total()];

char r, g, b;

for (int i = 0;i < mat.total();i++) {
    r = mat.data[3 * i + 2];
    g = mat.data[3 * i + 1];
    b = mat.data[3 * i + 0];
    data[i] = (((jint)r << 16) & 0x00FF0000) +
        (((jint)g << 8) & 0x0000FF00) + ((jint)b & 0x000000FF);
}

jint* _data = (jint*)data;

int size = mat.rows * mat.cols;
jintArray jarray = env->NewIntArray(size);
env->SetIntArrayRegion(jarray, 0, size, _data);
delete []data;
return jarray;

} ```

最後,將 CV 程式和 JNI 相關的程式碼最終編譯成一個 dll 檔案,供軟體(上位機)呼叫,實現最終的需求。

3.3 應用層的呼叫

上述程式碼寫好後,攝像頭(相機)在應用層的開啟就非常簡單了,大致的程式碼如下:

```kotlin val map = HashMap() map[CAP_PROP_FRAME_WIDTH] = 4208.toString() map[CAP_PROP_FRAME_HEIGHT] = 3120.toString() map[CAP_PROP_AUTO_EXPOSURE] = 0.25.toString() map[CAP_PROP_EXPOSURE] = getTopExposure() map[CAP_PROP_GAIN] = getTopFocus() map[CAP_PROP_BRIGHTNESS] = getTopBrightness() WImagesProcess.startTopVideoCapture(index + CAP_DSHOW, map, object : VideoCaptureListener { override fun onSuccess() { ...... }

  override fun onRead(array: IntArray) {
         ......
  }

  override fun onFailed() {
         ......
  }

}) ```

應用層的拍照也很簡單:

cpp val bufferedImage = WImagesProcess.takePhoto(cameraId).toBufferedImage()

其中,toBufferedImage() 是 Kotlin 的擴充套件函式。因為 takePhoto() 方法返回 IntArray 物件。

kotlin fun IntArray.toBufferedImage():BufferedImage { val destImage = BufferedImage(FRAME_WIDTH,FRAME_HEIGHT, BufferedImage.TYPE_INT_RGB) destImage.setRGB(0,0,FRAME_WIDTH,FRAME_HEIGHT, this,0,FRAME_WIDTH) return destImage }

這樣,對於應用層的呼叫是非常簡單的。

四. 總結

通過 OpenCV 替換 JavaCV 之後,軟體遇到的痛點問題基本可以解決。例如軟體體積明顯變小了。

不同版本軟體大小變更.PNG

另外,軟體在執行時佔用大量記憶體的情況也得到明顯改善。如果需要在展示實時畫面時,對影象做一些處理,也可以在 Native 層使用 OpenCV 來處理每一幀,然後將結果返回給應用層。