Android 端相机视频流采集与实时边框识别

  • 2018-09-12
  • 23,208
  • 20

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

本文是 SmartCamera 原理分析的文章,SmartCamera 是我开源的一个 Android 相机拓展模块,能够实时采集并且识别相机内物体边框是否吻合指定区域。

SmartCamera 是继 SmartCropper 之后开源的另外一个基于 OpenCV 实现的开源库,他们的不同点主要包括以下几个方面:

  1. SmartCropper 是处理一张图片,输出一张裁剪的图片,而 SmartCamera 需要实时处理 Android 相机输出的视频流,对性能要求会更高;
  2. SmartCamera 是识别相机内物体是否吻合指定的四边形,实现方式上也会有所差异;
  3. 另外 SmartCropper 的使用者经常会反馈某些场景识别率不高,故 SmartCamera 提供了实时预览模式,并且提供了更细化的算法参数调优,让开发者可以自己修改扫描算法以获得更好的适配性。

SmartCamera 具体能实现的功能如下所示:

smartcamera_demo.gif

功能描述及使用方法上更详细的介绍请看 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 将图片进行选择至正常位置。


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 操作:

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 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!

>> 转载请注明来源:Android 端相机视频流采集与实时边框识别

评论

  • 开发者头条回复

    感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/2a6vat 欢迎点赞支持!
    使用开发者头条 App 搜索 55960 即可订阅《pqpo》

  • 乐飞回复

    这个东西怎么应用到实际场景呢?这是为了什么情景而产生的技术呢?

    • pqpo回复

      目前就有很多应用在上传身份证或者银行卡的时候就会自动扫描并且拍照。

  • 匿名回复

    如果想要识别高分辨率的图片,视频流是不是满足不了。

  • xinxing回复

    如果想要识别高分辨率的图片,视频流是不是满足不了。

  • xinxing回复

    如果想要识别获取高分辨率的图片,视频流是不是满足不了。

    • pqpo回复

      最终得到的图片和设置相机的分辨率有关,如果是拍照截取就是设置的拍照大小,如果是视频流截取就是设置的预览大小。

  • JackPan回复

    想咨询一下,卡片备忘录的OCR识别是怎么实现的

  • NANA回复

    感谢您分享这么好用的库,有个问题想咨询一下。我目前想在您这个库的基础之上,额外增加zoom放大的功能,来实现类似于微信一样的扫一扫自动放大的效果。但是使用parameters.setZoom(zoom); camera.setParameters(parameters);之后没有出现放大效果,请问是哪里出现问题了呢?

  • DearKoala回复

    可以视频流大致识别到了,直接抓取照片,进行进一步识别。

  • 小鬼回复

    自定义蒙版选框视图,识别不出来,博主,能帮忙分析下是什么原因吗?

    • pqpo回复

      给的信息有点少,不太好分析

      • 小鬼回复

        我发现识别的话,得挨着边框很近的位置,更容易识别,有没有办法调整哪些参数达到识别率更高点呢?我有调整detectionRatio和maxSize,都对应调大了,但是效果不明显

  • 郑卓峰回复

    博主你好,谢谢您写的这篇博客,我想请问一下setPreviewFormat和setPictureFormat区别是?能改他们的速度吗,也就是一秒多少帧?

    • pqpo回复

      setPreviewFormat 和 setPictureFormat 只是设置预览和拍照的图片格式,预览速度的话可以在系统回调 onPicturePreview 自己控制一下

  • dan回复

    博主,AndroidQ打开相机出现异常:java.io.IOException: setPreviewTexture failed

  • changan回复

    博主你好,SmartCamera 和 SmartCropper 可以共同使用吗,so会不会冲突啊

    • pqpo回复

      opencv 部分是重复的,有能力的话建议合并重新编译成一个 so

回复给 小鬼 点击这里取消回复。