“我正在参与「启航计划」”

一. 事务布景

咱们团队前段时间做了一款小型的智能硬件,它能够主动摄影一些产品的图片,这些图片将会出现在电商 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 之后,软件遇到的痛点问题基本能够处理。例如软件体积显着变小了。

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

别的,软件在运行时占用很多内存的情况也得到显着改善。如果需求在展现实时画面时,对图画做一些处理,也能够在 Native 层运用 OpenCV 来处理每一帧,然后将成果回来给应用层。