OpenCV + Kotlin 实现 USB 摄像头(相机)实时画面、拍照

pexels-regina-trissteria-13623557.jpg

一. 业务背景

我们团队前段时间做了一款小型的智能硬件,它能够自动拍摄一些商品的图片,这些图片将会出现在电商 App 的详情页并进行展示。

基于以上的背景,我们需要一个业务后台用于发送相应的拍照指令,还需要开发一款软件(上位机)用于接收拍照指令和操作硬件设备。

二. 原先的实现方式以及痛点

早期为了快速实现功能,我们团队使用 JavaCV 调用 USB 摄像头(相机)进行实时画面的展示和拍照。这样的好处在于,能够快速实现产品经理提出的功能,并快速上线。当然,也会遇到一些问题。

我列举几个遇到的问题:

  1. 软件体积过大
  2. 编译速度慢
  3. 软件运行时占用大量的内存
  4. 对于获取的实时画面,不利于在软件侧(客户端侧)调用机器学习或者深度学习的库,因为整个软件采用 Java/Kotlin 编写的。

三. 使用 OpenCV 进行重构

基于上述的原因,我尝试用 OpenCV 替代 JavaCV 看看能否解决这些问题。

3.1JNI 调用的设计

由于我使用 OpenCV C++ 版本来进行开发,因此在开发之前需要先设计好应用层(我们的软件主要是采用 Java/Kotlin 编写的)如何跟 Native 层进行交互的一些的方法。比如:USB 摄像头(相机)的开启和关闭、拍照、相机相关参数的设置等等。

为此,设计了一个专门用于图像处理的类 WImagesProcess(W 是项目的代号),它包含了上述的方法。

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。

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 类对应。

#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 项目中 WImagesProcess 类所在的 package 名称。毕竟是公司项目,我不便贴出完整的 package 名称。不熟悉这种写法的,可以参考 JNI 的规范。

接下来,需要定义一个 xxx_WImagesProcess.cpp 用于实现上述的方法。

3.2.1 USB 摄像头(相机)的开启

仅以 startTopVideoCapture() 为例,它的作用是开启智能硬件的俯拍相机,该硬件有 2 款相机介绍其中一种实现方式,另一种也很类似。

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。

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

接下来几行,表示将应用层传递的 VideoCaptureListener 在 JNI 层需要获取其类型。然后,查找 VideoCaptureListener 中的几个方法,便于后面调用。这样 JNI 层就可以跟应用层的 Java/Kotlin 进行交互了。

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() 方法就能收到回调。

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

打开摄像头(相机)后,就可以实时把获取的每一帧返回给应用层。同样,VideoCaptureListener#onRead() 方法就能收到回调。

        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 中,有以下这样一段代码:

topVideoCapture = wImageProcess.getVideoCapture(index, mapOut);

它的用途是通过 index id 打开对应的相机,并设置相机需要的参数,最后返回 VideoCapture 对象。

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

对于存在同时调用多个相机的情况,OpenCV 需要基于 index id 来获取对应的相机。那如何获取 index id 呢?以后有机会再写一篇文章吧。

WImagesProcess 类还额外提供了多个方法用于设置相机的曝光、亮度、焦距等。我们在启动相机的时候不是可以通过 HashMap 来传递相机需要的参数嘛,为何还提供这些方法呢?这样做的目的是因为针对不同商品拍照时,可能会调节相机相关的参数,因此 WImagesProcess 类提供了这些方法。

3.2.3 拍照

基于 cameraId 来找到对应的相机进行拍照,并将结果返回给应用层,唯一需要注意的是 C++ 得手动释放资源。

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 应用层的调用

上述代码写好后,摄像头(相机)在应用层的打开就非常简单了,大致的代码如下:

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() {
             ......
      }
})

应用层的拍照也很简单:

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

其中,toBufferedImage() 是 Kotlin 的扩展函数。因为 takePhoto() 方法返回 IntArray 对象。

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 来处理每一帧,然后将结果返回给应用层。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,686评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,668评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,160评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,736评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,847评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,043评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,129评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,872评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,318评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,645评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,777评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,861评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,589评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,687评论 2 351

推荐阅读更多精彩内容