一. 業務背景
我們團隊前段時間做了一款小型的智能硬件,它能夠自動拍攝一些商品的圖片,這些圖片將會出現在電商 App 的詳情頁並進行展示。
基於以上的背景,我們需要一個業務後臺用於發送相應的拍照指令,還需要開發一款軟件(上位機)用於接收拍照指令和操作硬件設備。
二. 原先的實現方式以及痛點
早期爲了快速實現功能,我們團隊使用 JavaCV 調用 USB 攝像頭(相機)進行實時畫面的展示和拍照。這樣的好處在於,能夠快速實現產品經理提出的功能,並快速上線。當然,也會遇到一些問題。
我列舉幾個遇到的問題:
- 軟件體積過大
- 編譯速度慢
- 軟件運行時佔用大量的內存
- 對於獲取的實時畫面,不利於在軟件側(客戶端側)調用機器學習或者深度學習的庫,因爲整個軟件採用 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 之後,軟件遇到的痛點問題基本可以解決。例如軟件體積明顯變小了。
另外,軟件在運行時佔用大量內存的情況也得到明顯改善。如果需要在展示實時畫面時,對圖像做一些處理,也可以在 Native 層使用 OpenCV 來處理每一幀,然後將結果返回給應用層。