Android 端相機視訊流採集與實時邊框識別
本文是 ofollow,noindex">SmartCamera 原理分析的文章,SmartCamera 是我開源的一個 Android 相機拓展模組,能夠實時採集並且識別相機內物體邊框是否吻合指定區域。
SmartCamera 是繼 SmartCropper 之後開源的另外一個基於 OpenCV 實現的開源庫,他們的不同點主要包括以下幾個方面:
- SmartCropper 是處理一張圖片,輸出一張裁剪的圖片,而 SmartCamera 需要實時處理 Android 相機輸出的視訊流,對效能要求會更高;
- SmartCamera 是識別相機內物體是否吻合指定的四邊形,實現方式上也會有所差異;
- 另外 SmartCropper 的使用者經常會反饋某些場景識別率不高,故 SmartCamera 提供了實時預覽模式,並且提供了更細化的演算法引數調優,讓開發者可以自己修改掃描演算法以獲得更好的適配性。
SmartCamera 具體能實現的功能如下所示:
功能描述及使用方法上更詳細的介紹請看 github 上的專案主頁或者 《SmartCamera 相機實時掃描識別庫》
閱讀本系列文章之前,讀者可以先閱讀之前寫的 《Android 端基於 OpenCV 的邊框識別功能》 ,瞭解如何從頭開始搭建一個整合 OpenCV 的 NDK 專案,瞭解 OpenCV 庫的作用及其用法;然後可以將 SmartCamera clone 到本地方便程式碼查閱與除錯。
最重要的是別忘記給 SmartCamera 和 SmartCropper 點個 star!
本文主要分兩個部分,第一部分是 Android 端相機視訊流採集,視訊流幀資料格式分析,以及如何提高採集的效能;第二部分是幀資料分析識別,判斷出影象內物體四邊是否吻合指定邊框。
相機視訊流採集
android.hardware.Camera 提供瞭如下 API 獲取相機視訊流:
mCamera.setPreviewCallback(new Camera.PreviewCallback() { @Override public void onPreviewFrame(byte[] data, Camera camera) { } });
每一幀的資料均會通過該回調返回,回撥內的 byte[] data 即是相機內幀影象資料。該回調會將每一幀資料一個不漏的給你,大多數情況下我們根本來不及處理,會將幀資料直接丟棄。另外每一幀的資料都是一塊新的記憶體區域會造成頻繁的 GC。
所以 android.hardware.Camera 提供了另外一個有更好的效能, 更容易控制的方式:
mCamera.setPreviewCallbackWithBuffer(new Camera.PreviewCallback() { @Override public void onPreviewFrame(byte[] data, Camera camera) { } });
該方法需要與以下方法配合使用:
mCamera.addCallbackBuffer(new byte[size])
這樣回撥內每一幀的 data 就會複用同一塊緩衝區域,data 物件沒有改變,但是 data 資料的內容改變了,並且該回調不會返回每一幀的資料,而是在重新呼叫 addCallbackBuffer 之後才會繼續回撥,這樣我們可以更容易控制回撥的數量。
程式碼如下:
mCamera.addCallbackBuffer(new byte[size]) mCamera.setPreviewCallbackWithBuffer(new Camera.PreviewCallback() { @Override public void onPreviewFrame(byte[] data, Camera camera) { processFrame(data); mCamera.addCallbackBuffer(data) } });
雖然我們會在 processFrame 函式中進行大量效能優化,但是為了不影響處理幀資料時阻塞 UI 執行緒造成掉幀,我們可以將處理邏輯放置到後臺執行緒中,這裡使用了 HandlerThread, 配合 Handler 將處理資料的邏輯放置到了後臺執行緒中。
最終程式碼如下所示:
HandlerThread processThread = new HandlerThread("processThread"); processThread.start(); processHandler = new Handler(processThread.getLooper()) { @Override public void handleMessage(Message msg) { super.handleMessage(msg); processFrame(previewBuffer); mCamera.addCallbackBuffer(previewBuffer); } }; mCamera.addCallbackBuffer(previewBuffer); mCamera.setPreviewCallbackWithBuffer(new Camera.PreviewCallback() { @Override public void onPreviewFrame(byte[] data, Camera camera) { processHandler.sendEmptyMessage(1); } }); mCamera.startPreview();
在 onPreviewFrame 回撥函式中只是傳送了訊息通知 HandlerThread 處理資料,處理的資料即為 previewBuffer ,處理完了之後呼叫:
mCamera.addCallbackBuffer(previewBuffer);
這樣 onPreviewFrame 會開始回撥下一幀資料。
那麼緩衝區域的大小 size 是如何確定的呢?這要從幀資料格式說起。
幀資料格式分析
首先每一幀圖片的預覽大小是我們提前設定好的,可以通過如下方法獲取:
int width = mCameraParameters.getPreviewSize().width; int height = mCameraParameters.getPreviewSize().height;
很多人可能會猜 size 應該等於 width * height,實際上這要看這每一幀圖片的格式,假設是 ARGB 格式,並且每個通道有 256(0x00 – 0xFF) 個值,每個通道需要一個位元組或者說 8 個位(bit)來表示,那麼表示每個畫素點的範圍是:
0x00000000 - 0xFFFFFFFF
一個畫素點總共需要 4 個位元組(byte)表示,也就能得出表示一張 width * height 圖片的 byte 陣列的大小為:
// 4個通道,每個通道 8 個位,總共需要 4 位元組 width * height * ( 8 + 8 + 8 + 8 ) / 8 = width * height * 4 (byte)
舉一反三,假設每一幀的圖片格式為 RGB_565,那麼 byte 陣列的大小是:
// 4個通道,需要 16 個位,總共需要 2 位元組 width * height * ( 5 + 6 + 5 ) / 8 = width * height * 2 (byte)
那麼回到 setPreviewCallbackWithBuffer 回撥返回的 data 資料,這個資料的格式是怎樣的呢?
不用猜,查閱 Android 官方開發者文件: https://developer.android.com/reference/android/hardware/Camera.PreviewCallback
得知 data 的預設格式為 YCbCr_420_SP (NV21) ,也可以通過如下程式碼設定成其他的預覽格式:
Camera.Parameters.setPreviewFormat(ImageFormat)
ImageFormat 枚舉了很多種圖片格式, 其中 ImageFormat.NV21 和 ImageFormat.YV12 是官方推薦的格式 ,原因是所有的相機都支援這兩種格式。
官方推薦也不是我瞎猜的,見官方文件:
https://developer.android.com/reference/android/hardware/Camera.Parameters#setPreviewFormat(int)
那麼 NV21, YV12 又是什麼格式,與我們熟知的 ARGB 格式有什麼不同呢?
NV21, YV12 格式均屬於 YUV 格式,也可以表示為 YCbCr,Cb、Cr的含義等同於U、V。
YUV,分為三個分量,“Y”表示明亮度(Luminance、Luma),“U” 和 “V” 則是色度、濃度(Chrominance、Chroma),Y’UV的發明是由於彩色電視與黑白電視的過渡時期[1]。黑白視訊只有Y(Luma,Luminance)視訊,也就是灰階值。到了彩色電視規格的制定,是以YUV/YIQ的格式來處理彩色電視影象,把UV視作表示彩度的C(Chrominance或Chroma),如果忽略C訊號,那麼剩下的Y(Luma)訊號就跟之前的黑白電視訊號相同,這樣一來便解決彩色電視機與黑白電視機的相容問題。Y’UV最大的優點在於只需佔用極少的頻寬。
上面的表述來至於維基百科。大致可以得出以下結論:
YUV 格式的圖片可以方便的提取 Y 分量從而得到灰度圖片。
關於 YUV 格式更詳細的介紹可以參考:https://www.cnblogs.com/azraelly/archive/2013/01/01/2841269.html
下面直接給出結論:
根據取樣格式不同, 或者說排列順序不同,YUV 又細分成了 NV21, YV12 等格式,如下所示:
I420: YYYYYYYY UU VV => YUV420P
YV12: YYYYYYYY VV UU => YUV420P
NV12: YYYYYYYY UV UV => YUV420SP
NV21: YYYYYYYY VU VU => YUV420SP
其中 YUV 4:2:0 取樣,每四個Y共用一組UV分量。
假設有一張 NV21 格式的圖片,大小為 width * height, 其中 Y 分量表示的灰度圖每個畫素可以使用 1byte 表示,Y 分量佔用了:
width * height * 1 byte
VU 分量佔用了:
width * height / 4 + width * height / 4
所以該圖片佔用總大小為:
width * height * 1.5 byte
終於確定了 size 的大小,實際上 Android API 已經給我們提供了方便的計算方法,我們不用背各個格式所需的大小:
width * height * ImageFormat.getBitsPerPixel(ImageFormat.NV21) / 8]
ImageFormat.getBitsPerPixel(ImageFormat.NV21) 返回了 12 ,表示 NV21 格式的圖片每個畫素需要 12 個 bit 表示,即 1.5 個 byte。
Android API 同樣提供了方法讓我們將 YUV 格式的圖片轉化為我們熟知的 ARGB 格式:
YuvImage = image = new YuvImage(data, ImageFormat.NV21, size.width, size.height, null); ByteArrayOutputStream stream = new ByteArrayOutputStream(); image.compressToJpeg(new Rect(0, 0, size.width, size.height), 100, stream); Bitmap bitmap = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size());
但是在實時掃描的場景中我們並不需要將 YUV 的格式轉成 ARGB 格式,而是直接將 data 資料傳遞給 jni 函式處理,下面開始分析如何處理幀資料,達到識別出邊框並且判斷是否吻合指定選框的效果。
幀資料識別
幀資料識別的主要功能位於 me.pqpo.smartcameralib.SmartScanner.previewScan()
幀資料回撥處程式碼如下:
addCallback(new Callback() { @Override public void onPicturePreview(CameraView cameraView, byte[] data) { super.onPicturePreview(cameraView, data); if (data == null || !scanning) { return; } int previewRotation = getPreviewRotation(); Size size = getPreviewSize(); Rect revisedMaskRect = getAdjustPreviewMaskRect(); if (revisedMaskRect != null && size != null) { int result = smartScanner.previewScan(data, size.getWidth(), size.getHeight(), previewRotation, revisedMaskRect); uiHandler.sendEmptyMessage(result); } } });
previewScan 方法的入參包括:幀影象資料,該影象的寬和高,當前相機預覽的旋轉角度(0,90,180,270),以及相機上層選框區域。具體實現如下:
public int previewScan(byte[] yuvData, int width, int height, int rotation, Rect maskRect) { float scaleRatio = calculateScaleRatio(maskRect.width(), maskRect.height()); Bitmap previewBitmap = null; if (preview) { previewBitmap = preparePreviewBitmap((int)(scaleRatio * maskRect.width()), (int)(scaleRatio * maskRect.height())); } return previewScan(yuvData, width, height, rotation, maskRect.left, maskRect.top, maskRect.width(), maskRect.height(), previewBitmap, scaleRatio); }
首先根據圖片識別的最大尺寸計算下縮小比例,適當的縮小待檢測影象的大小可以提高識別效率,然後根據是否開啟了預覽模式建立用於輸出識別結果的圖片,最後呼叫
previewScan(yuvData, width, height, rotation, maskRect.left, maskRect.top, maskRect.width(), maskRect.height(), previewBitmap, scaleRatio);
開始識別,其中引數不必多說,該方法是個 native 方法,基於 OpenCV 用 c++ 實現,具體位於 src/main/cpp/smart_camera.cpp,如下:
extern "C" JNIEXPORT jint JNICALL Java_me_pqpo_smartcameralib_SmartScanner_previewScan (JNIEnv *env, jclass type, jbyteArray yuvData_, jint width, jint height, jint rotation, jint x, jint y, jint maskWidth, jint maskHeight, jobject previewBitmap, jfloat ratio);
該方法便是掃描功能的核心,下面開始一步步分析。
該方法首先會呼叫 processMat 對幀資料做相應處理:
void processMat(void* yuvData, Mat& outMat, int width, int height, int rotation, int maskX, int maskY, int maskWidth, int maskHeight, float scaleRatio);
上個部分已經介紹過幀資料影象的格式為 YUV420sp ,size 為 (width + height) / 2 * 3 也等於 (height+height/2) * width,由於 OpenCV 中圖片處理都是基於 Mat 格式的,那麼進行如下操作,並且將其轉換成灰度圖:
Mat mYuv(height+height/2, width, CV_8UC1, (uchar *)yuvData); Mat imgMat(height, width, CV_8UC1); cvtColor(mYuv, imgMat, CV_YUV420sp2GRAY);
下面根據 rotation 將圖片進行選擇至正常位置。
接著根據給定選框的區域 maskX, maskY, maskWidth, maskHeight 裁剪出選框內的圖片,並且按入參 scaleRatio 進行縮小,如下所示
// 資料保護,防止選框區域超出圖片 int newHeight = imgMat.rows; int newWidth = imgMat.cols; maskX = max(0, min(maskX, newWidth)); maskY = max(0, min(maskY, newHeight)); maskWidth = max(0, min(maskWidth, newWidth - maskX)); maskHeight = max(0, min(maskHeight, newHeight - maskY)); Rect rect(maskX, maskY, maskWidth, maskHeight); Mat croppedMat = imgMat(rect); Mat resizeMat; resize(croppedMat, resizeMat, Size(static_cast <int> (maskWidth * scaleRatio), static_cast <int> (maskHeight * scaleRatio))); </int> </int>
然後進行一系列的 OpenCV 操作:
1. 高斯模糊(GaussianBlur),去除噪點
2. Canny 運算元(Canny),邊緣檢測
3. 膨脹操作(dilate),加強邊緣
4. 二值化處理(threshold),去除干擾
具體實現程式碼如下:
Mat blurMat; GaussianBlur(resizeMat, blurMat, Size(gScannerParams.gaussianBlurRadius,gScannerParams.gaussianBlurRadius), 0); Mat cannyMat; Canny(blurMat, cannyMat, gScannerParams.cannyThreshold1, gScannerParams.cannyThreshold2); Mat dilateMat; dilate(cannyMat, dilateMat, getStructuringElement(MORPH_RECT, Size(2, 2))); Mat thresholdMat; threshold(dilateMat, thresholdMat, gScannerParams.thresholdThresh, gScannerParams.thresholdMaxVal, CV_THRESH_OTSU);
到這裡,影象的初步處理就結束了,下面開始識別邊框以及判斷邊框是否吻合,先大致說一下實現思路:
1. 將圖片分割成四個檢測區域:
2. 分別檢測四個區域內的所有直線
3. 針對每個區域判斷是否存在一條符合條件的直線
這裡說一下為何不整張圖片做直線檢測,而是 4 個區域分別解除,原因是整圖檢測會出現很多幹擾直線,而我們關係的只是邊緣的直線。
首先看裁剪部分,得到四個區域的影象,croppedMatL,croppedMatT,croppedMatR,croppedMatB,程式碼實現如下:
int matH = outMat.rows; int matW = outMat.cols; int thresholdW = cvRound( gScannerParams.detectionRatio * matW); int thresholdH = cvRound( gScannerParams.detectionRatio * matH); //1. crop left Rect rect(0, 0, thresholdW, matH); Mat croppedMatL = outMat(rect); //2. crop top rect.x = 0; rect.y = 0; rect.width = matW; rect.height = thresholdH; Mat croppedMatT = outMat(rect); //3. crop right rect.x = matW - thresholdW; rect.y = 0; rect.width = thresholdW; rect.height = matH; Mat croppedMatR = outMat(rect); //4. crop bottom rect.x = 0; rect.y = matH - thresholdH; rect.width = matW; rect.height = thresholdH; Mat croppedMatB = outMat(rect);
針對這四塊區域分別做直線檢測:
vector <vec4i> linesLeft = houghLines(croppedMatL); vector <vec4i> linesTop = houghLines(croppedMatT); vector <vec4i> linesRight = houghLines(croppedMatR); vector <vec4i> linesBottom = houghLines(croppedMatB); if (previewBitmap != NULL) { drawLines(outMat, linesLeft, 0, 0); drawLines(outMat, linesTop, 0, 0); drawLines(outMat, linesRight, matW - thresholdW, 0); drawLines(outMat, linesBottom, 0, matH - thresholdH); mat_to_bitmap(env, outMat, previewBitmap); } int checkMinLengthH = static_cast <int> (matH * gScannerParams.checkMinLengthRatio); int checkMinLengthW = static_cast <int> (matW * gScannerParams.checkMinLengthRatio); if (checkLines(linesLeft, checkMinLengthH, true) && checkLines(linesRight, checkMinLengthH, true) && checkLines(linesTop, checkMinLengthW, false) && checkLines(linesBottom, checkMinLengthW, false)) { return 1; } return 0; </int> </int> </vec4i> </vec4i> </vec4i> </vec4i>
通過 OpenCV 提供的 houghLines 可以提前區域內識別出的所有直線並保持與 vector
bool checkLines(vector <vec4i> &lines, int checkMinLength, bool vertical) { for( size_t i = 0; i < lines.size(); i++ ) { Vec4i l = lines[i]; int x1 = l[0]; int y1 = l[1]; int x2 = l[2]; int y2 = l[3]; float distance; distance = powf((x1 - x2),2) + powf((y1 - y2),2); distance = sqrtf(distance); if (distance < checkMinLength) { continue; } if (x2 == x1) { return true; } float angle = cvFastArctan(fast_abs(y2 - y1), fast_abs(x2 - x1)); if (vertical) { if(fast_abs(90 - angle) < gScannerParams.angleThreshold) { return true; } } if (!vertical) { if(fast_abs(angle) < gScannerParams.angleThreshold) { return true; } } } return false; } </vec4i>
checkMinLength 表示檢測直線最小長度,只有大於這個值才認為改直線符合條件,vertical 表示檢測的實現是水平的還是豎著的。帶著這兩個引數來看 checkLines 的程式碼就必將容易理解了,前半部分判斷長度,後半部分判斷角度,均符合條件的則判斷通過。
如果四個區域均檢測通過了,那麼此幀影象檢測通過,預設情況下會觸發拍照。
感謝您的閱讀,如果覺得該專案不錯,請移步 SmartCamera 的 github 地址 ( https://github.com/pqpo/SmartCamera ) 點個 star!
>> 轉載請註明來源: