OpenCV + Kotlin 實(shí)現(xiàn) USB 攝像頭(相機(jī))實(shí)時(shí)畫(huà)面、拍照

pexels-regina-trissteria-13623557.jpg

一. 業(yè)務(wù)背景

我們團(tuán)隊(duì)前段時(shí)間做了一款小型的智能硬件,它能夠自動(dòng)拍攝一些商品的圖片,這些圖片將會(huì)出現(xiàn)在電商 App 的詳情頁(yè)并進(jìn)行展示。

基于以上的背景,我們需要一個(gè)業(yè)務(wù)后臺(tái)用于發(fā)送相應(yīng)的拍照指令,還需要開(kāi)發(fā)一款軟件(上位機(jī))用于接收拍照指令和操作硬件設(shè)備。

二. 原先的實(shí)現(xiàn)方式以及痛點(diǎn)

早期為了快速實(shí)現(xiàn)功能,我們團(tuán)隊(duì)使用 JavaCV 調(diào)用 USB 攝像頭(相機(jī))進(jìn)行實(shí)時(shí)畫(huà)面的展示和拍照。這樣的好處在于,能夠快速實(shí)現(xiàn)產(chǎn)品經(jīng)理提出的功能,并快速上線。當(dāng)然,也會(huì)遇到一些問(wèn)題。

我列舉幾個(gè)遇到的問(wèn)題:

  1. 軟件體積過(guò)大
  2. 編譯速度慢
  3. 軟件運(yùn)行時(shí)占用大量的內(nèi)存
  4. 對(duì)于獲取的實(shí)時(shí)畫(huà)面,不利于在軟件側(cè)(客戶端側(cè))調(diào)用機(jī)器學(xué)習(xí)或者深度學(xué)習(xí)的庫(kù),因?yàn)檎麄€(gè)軟件采用 Java/Kotlin 編寫的。

三. 使用 OpenCV 進(jìn)行重構(gòu)

基于上述的原因,我嘗試用 OpenCV 替代 JavaCV 看看能否解決這些問(wèn)題。

3.1JNI 調(diào)用的設(shè)計(jì)

由于我使用 OpenCV C++ 版本來(lái)進(jìn)行開(kāi)發(fā),因此在開(kāi)發(fā)之前需要先設(shè)計(jì)好應(yīng)用層(我們的軟件主要是采用 Java/Kotlin 編寫的)如何跟 Native 層進(jìn)行交互的一些的方法。比如:USB 攝像頭(相機(jī))的開(kāi)啟和關(guān)閉、拍照、相機(jī)相關(guān)參數(shù)的設(shè)置等等。

為此,設(shè)計(jì)了一個(gè)專門用于圖像處理的類 WImagesProcess(W 是項(xiàng)目的代號(hào)),它包含了上述的方法。

object WImagesProcess {

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

    /**
     * 算法的版本號(hào)
     */
    external fun getVersion():String

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

    /**
     * 開(kāi)啟俯拍相機(jī)
     * @param index 相機(jī)的 index id
     * @param cameraParaMap 相機(jī)相關(guān)的參數(shù)
     * @param listener jni 層給 Java 層的回調(diào)
     */
    external fun startTopVideoCapture(index:Int, cameraParaMap:Map<String,String>, listener: VideoCaptureListener)

    /**
     * 開(kāi)啟側(cè)拍相機(jī)
     * @param index 相機(jī)的 index id
     * @param cameraParaMap 相機(jī)相關(guān)的參數(shù)
     * @param listener jni 層給 Java 層的回調(diào)
     */
    external fun startRightVideoCapture(index:Int, cameraParaMap:Map<String,String>, listener: VideoCaptureListener)

    /**
     * 調(diào)用對(duì)應(yīng)的相機(jī)拍攝照片,使用時(shí)需要將 IntArray 轉(zhuǎn)換成 BufferedImage
     * @param cameraId  1:俯拍相機(jī); 2:側(cè)拍相機(jī)
     */
    external fun takePhoto(cameraId:Int): IntArray

    /**
     * 設(shè)置相機(jī)的曝光
     * @param cameraId  1:俯拍相機(jī); 2:側(cè)拍相機(jī)
     */
    external fun exposure(cameraId: Int, value: Double):Double

    /**
     * 設(shè)置相機(jī)的亮度
     * @param cameraId  1:俯拍相機(jī); 2:側(cè)拍相機(jī)
     */
    external fun brightness(cameraId: Int, value: Double):Double

    /**
     * 設(shè)置相機(jī)的焦距
     * @param cameraId  1:俯拍相機(jī); 2:側(cè)拍相機(jī)
     */
    external fun focus(cameraId: Int, value: Double):Double

    /**
     * 關(guān)閉相機(jī),釋放相機(jī)的資源
     * @param cameraId 1:俯拍相機(jī); 2:側(cè)拍相機(jī)
     */
    external fun closeVideoCapture(cameraId:Int)
}

其中,VideoCaptureListener 是監(jiān)聽(tīng) USB 攝像頭(相機(jī))行為的 Listener。

interface VideoCaptureListener {

    /**
     * Native 層調(diào)用相機(jī)成功
     */
    fun onSuccess()

    /**
     * jni 將 Native 層調(diào)用相機(jī)獲取每一幀的 Mat 轉(zhuǎn)換成 IntArray,回調(diào)給 Java 層
     * @param array 回調(diào)給 Java 層的 IntArray,Java 層可以將其轉(zhuǎn)化成 BufferedImage
     */
    fun onRead(array: IntArray)

    /**
     * Native 層調(diào)用相機(jī)失敗
     */
    fun onFailed()
}

VideoCaptureListener#onRead() 方法是在攝像頭(相機(jī))打開(kāi)后,會(huì)實(shí)時(shí)將每一幀的數(shù)據(jù)通過(guò)回調(diào)的形式返回給應(yīng)用層。

3.2 JNI && Native 層的實(shí)現(xiàn)

定義一個(gè) xxx_WImagesProcess.h,它與應(yīng)用層的 WImagesProcess 類對(duì)應(yīng)。

#include <jni.h>

#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 項(xiàng)目中 WImagesProcess 類所在的 package 名稱。畢竟是公司項(xiàng)目,我不便貼出完整的 package 名稱。不熟悉這種寫法的,可以參考 JNI 的規(guī)范。

接下來(lái),需要定義一個(gè) xxx_WImagesProcess.cpp 用于實(shí)現(xiàn)上述的方法。

3.2.1 USB 攝像頭(相機(jī))的開(kāi)啟

僅以 startTopVideoCapture() 為例,它的作用是開(kāi)啟智能硬件的俯拍相機(jī),該硬件有 2 款相機(jī)介紹其中一種實(shí)現(xiàn)方式,另一種也很類似。

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

這個(gè)方法用了很多 JNI 相關(guān)的內(nèi)容,接下來(lái)會(huì)簡(jiǎn)單說(shuō)明。

首先,JavaHashMapToStlMap() 方法用于將 Java 的 HashMap 轉(zhuǎn)換成 C++ STL 的 Map。開(kāi)啟相機(jī)時(shí),需要傳遞相機(jī)相關(guān)的參數(shù)。由于相機(jī)需要設(shè)置參數(shù)很多,因此在應(yīng)用層使用 HashMap,傳遞到 JNI 層需要將他們進(jìn)行轉(zhuǎn)化成 C++ 能用的 Map。

void JavaHashMapToStlMap(JNIEnv* env, jobject hashMap, std::map<string, string>& 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);
    }
}

接下來(lái)幾行,表示將應(yīng)用層傳遞的 VideoCaptureListener 在 JNI 層需要獲取其類型。然后,查找 VideoCaptureListener 中的幾個(gè)方法,便于后面調(diào)用。這樣 JNI 層就可以跟應(yīng)用層的 Java/Kotlin 進(jìn)行交互了。

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");

接下來(lái),開(kāi)始打開(kāi)攝像頭(相機(jī)),并回調(diào)給應(yīng)用層,這樣 VideoCaptureListener#onSuccess() 方法就能收到回調(diào)。

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

打開(kāi)攝像頭(相機(jī))后,就可以實(shí)時(shí)把獲取的每一幀返回給應(yīng)用層。同樣,VideoCaptureListener#onRead() 方法就能收到回調(diào)。

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

后面的代碼是關(guān)閉相機(jī),釋放資源。

3.2.2 打開(kāi)相機(jī),設(shè)置相機(jī)參數(shù)

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

topVideoCapture = wImageProcess.getVideoCapture(index, mapOut);

它的用途是通過(guò) index id 打開(kāi)對(duì)應(yīng)的相機(jī),并設(shè)置相機(jī)需要的參數(shù),最后返回 VideoCapture 對(duì)象。

VideoCapture WImageProcess::getVideoCapture(int index, std::map<string, string> cameraParaMap) {
    VideoCapture capture(index);
    
    for (auto & t : cameraParaMap) {
        int key = stoi(t.first);
        double value = stod(t.second);
        capture.set(key, value);
    }

    return capture;
}

對(duì)于存在同時(shí)調(diào)用多個(gè)相機(jī)的情況,OpenCV 需要基于 index id 來(lái)獲取對(duì)應(yīng)的相機(jī)。那如何獲取 index id 呢?以后有機(jī)會(huì)再寫一篇文章吧。

WImagesProcess 類還額外提供了多個(gè)方法用于設(shè)置相機(jī)的曝光、亮度、焦距等。我們?cè)趩?dòng)相機(jī)的時(shí)候不是可以通過(guò) HashMap 來(lái)傳遞相機(jī)需要的參數(shù)嘛,為何還提供這些方法呢?這樣做的目的是因?yàn)獒槍?duì)不同商品拍照時(shí),可能會(huì)調(diào)節(jié)相機(jī)相關(guān)的參數(shù),因此 WImagesProcess 類提供了這些方法。

3.2.3 拍照

基于 cameraId 來(lái)找到對(duì)應(yīng)的相機(jī)進(jìn)行拍照,并將結(jié)果返回給應(yīng)用層,唯一需要注意的是 C++ 得手動(dòng)釋放資源。

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 相關(guān)的代碼最終編譯成一個(gè) dll 文件,供軟件(上位機(jī))調(diào)用,實(shí)現(xiàn)最終的需求。

3.3 應(yīng)用層的調(diào)用

上述代碼寫好后,攝像頭(相機(jī))在應(yīng)用層的打開(kāi)就非常簡(jiǎn)單了,大致的代碼如下:

val map = HashMap<String,String>()
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() {
             ......
      }
})

應(yīng)用層的拍照也很簡(jiǎn)單:

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

其中,toBufferedImage() 是 Kotlin 的擴(kuò)展函數(shù)。因?yàn)?takePhoto() 方法返回 IntArray 對(duì)象。

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
}

這樣,對(duì)于應(yīng)用層的調(diào)用是非常簡(jiǎn)單的。

四. 總結(jié)

通過(guò) OpenCV 替換 JavaCV 之后,軟件遇到的痛點(diǎn)問(wèn)題基本可以解決。例如軟件體積明顯變小了。


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

另外,軟件在運(yùn)行時(shí)占用大量?jī)?nèi)存的情況也得到明顯改善。如果需要在展示實(shí)時(shí)畫(huà)面時(shí),對(duì)圖像做一些處理,也可以在 Native 層使用 OpenCV 來(lái)處理每一幀,然后將結(jié)果返回給應(yīng)用層。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容