高端玩法,相机视频流采集与实时边框识别
今日科技快讯
近日,美团点评(3690.HK,以下简称美团) 发布2018年中期报告,这也是美团继9月20日上市以来披露的首份财报。财报显示美团上半年营收263亿元人民币,净亏损288亿元人民币,经调整后亏损42亿元。
作者简介
大家周日好,今天是节前的最后一个工作日,公众号的技术文章也就发到今天为止,这里祝大家国庆快乐!我们国庆长假之后准时再见。
本篇来自 pqpo 的投稿,分享了关于 Android 端相机视频流采集与实时边框识别,一起来看看!希望大家喜欢。
pqpo 的博客地址:
https://pqpo.me/
前言
本文是 SmartCamera 原理分析的文章,SmartCamera 是我开源的一个 Android 相机拓展模块,能够实时采集并且识别相机内物体边框是否吻合指定区域。
SmartCamera 是继 SmartCropper 之后开源的另外一个基于 OpenCV 实现的开源库,他们的不同点主要包括以下几个方面:
SmartCropper 是处理一张图片,输出一张裁剪的图片,而 SmartCamera 需要实时处理 Android 相机输出的视频流,对性能要求会更高;
SmartCamera 是识别相机内物体是否吻合指定的四边形,实现方式上也会有所差异;
另外 SmartCropper 的使用者经常会反馈某些场景识别率不高,故 SmartCamera 提供了实时预览模式,并且提供了更细化的算法参数调优,让开发者可以自己修改扫描算法以获得更好的适配性。
SmartCamera 具体能实现的功能如下所示:
功能描述及使用方法上更详细的介绍请看 github 上的项目主页或者 《SmartCamera 相机实时扫描识别库》,地址如下所示:
https://pqpo.me/2018/08/25/smartcamera-recognizes-lib/
本文主要分两个部分,第一部分是 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 将图片进行选择至正常位置。
if (rotation == 90) {
matRotateClockWise90(imgMat);
} else if (rotation == 180) {
matRotateClockWise180(imgMat);
} else if (rotation == 270) {
matRotateClockWise270(imgMat);
}
接着根据给定选框的区域 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(maskWidth * scaleRatio),
static_cast(maskHeight * scaleRatio)));
然后进行一系列的 OpenCV 操作:
高斯模糊(GaussianBlur),去除噪点
Canny 算子(Canny),边缘检测
膨胀操作(dilate),加强边缘
二值化处理(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);
到这里,图像的初步处理就结束了,下面开始识别边框以及判断边框是否吻合,先大致说一下实现思路:
将图片分割成四个检测区域:
分别检测四个区域内的所有直线
针对每个区域判断是否存在一条符合条件的直线
这里说一下为何不整张图片做直线检测,而是 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 linesLeft = houghLines(croppedMatL);
vector linesTop = houghLines(croppedMatT);
vector linesRight = houghLines(croppedMatR);
vector 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(matH * gScannerParams.checkMinLengthRatio);
int checkMinLengthW = static_cast(matW * gScannerParams.checkMinLengthRatio);
if (checkLines(linesLeft, checkMinLengthH, true)
&& checkLines(linesRight, checkMinLengthH, true)
&& checkLines(linesTop, checkMinLengthW, false)
&& checkLines(linesBottom, checkMinLengthW, false)) {
return 1;
}
return 0;
通过 OpenCV 提供的 houghLines 可以提取区域内识别出的所有直线并保存与 vector 内,接着根据 previewBitmap 是否为空输出识别结果的图片,最终各区域检测直线的代码位于 checkLines:
bool checkLines(vector &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;
}
checkMinLength 表示检测直线最小长度,只有大于这个值才认为改直线符合条件,vertical 表示检测的实现是水平的还是竖直的。带着这两个参数来看 checkLines 的代码就比较容易理解了,前半部分判断长度,后半部分判断角度,均符合条件的则判断通过。
如果四个区域均检测通过了,那么此帧图像检测通过,默认情况下会触发拍照。
感谢您的阅读,如果觉得该项目不错,请移步 SmartCamera 的 github 地址
https://github.com/pqpo/SmartCamera
点个 star!
欢迎长按下图 -> 识别图中二维码
或者 扫一扫 关注我的公众号