OpenCV + Kotlin 實現 USB 攝像頭(相機)實時畫面、拍照
“我正在參加「掘金·啟航計劃」”
一. 業務背景
我們團隊前段時間做了一款小型的智慧硬體,它能夠自動拍攝一些商品的圖片,這些圖片將會出現在電商 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.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
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
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 之後,軟體遇到的痛點問題基本可以解決。例如軟體體積明顯變小了。
另外,軟體在執行時佔用大量記憶體的情況也得到明顯改善。如果需要在展示實時畫面時,對影象做一些處理,也可以在 Native 層使用 OpenCV 來處理每一幀,然後將結果返回給應用層。
- OpenCV Kotlin 實現 USB 攝像頭(相機)實時畫面、拍照
- 使用 Kotlin Compose Desktop 實現了一個簡易的"手機助手"
- 使用 OpenCV 微信二維碼引擎實現二維碼識別
- 基於 Kotlin OkHttp 實現易用且功能強大的網路框架(一)
- 使用 OpenCV 實現國慶漸變版的頭像
- 在 Kotlin 中使用 WebFlux R2DBC 開發 Web 專案
- Kotlin Contract
- Kotlin Collection VS Kotlin Sequence VS Java Stream
- 在 Kotlin 的 data class 中使用 MapStruct
- 基於 Kotlin Netty 實現一個簡單的 TCP 自定義協議
- Kotlin Coroutines Flow 系列(五) 其他的操作符
- 如何使用 Ktor 快速開發 Web 專案