极速扫码——zbar扫码库性能优化实践

移动开发 waitig 1071℃ 百度已收录 0评论

1 需求背景

公司的多款工具类app都用到了相机扫码功能。近来,因为业务的快速发展,对扫码模块的性能也有了更高要求,主要是两方面:

  1. 由于使用中经常会遇到商品条码密集排列的情况,因此要求扫码识别区域要非常精确。举个例子,比如扫码界面中展示给用户的扫码框是一个200*100的矩形,那么真正被识别的图像数据就只能是这个矩形框中的内容。
  2. 针对多个条码连续扫描识别的情形,要求每个条码的识别时间尽可能地短,这样使用起来效率更高,用户体验更好。

项目代码:SimpleScanner

2 优化效果

为了方便查看扫码识别速度,我们在系统相机预览的回调方法onPreviewFrame中添加了如下代码来打印耗时:

@Override
public void onPreviewFrame(byte[] data, Camera camera) {
    long startTime = SystemClock.currentThreadTimeMillis();
    ...

    //data数据处理及识别

    ...
    Log.e("logg", String.format("图像处理及识别耗时: %d ms", SystemClock.currentThreadTimeMillis() - startTime));
}

下面分别就扫描识别条形码、二维码,对本项目(SimpleScanner)和目前较为流行的扫码库barcodescanner (https://github.com/dm77/barcodescanner) 进行速度对比:

条形码

barcodescanner

E/logg: 图像处理及识别耗时: 469 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 439 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 434 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 446 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 455 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 439 ms
E/logg: Contents = 1234567890123, Format = CODE128

SimpleScanner

E/logg: 图像处理及识别耗时: 22 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 20 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 22 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 23 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 23 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 图像处理及识别耗时: 22 ms
E/logg: Contents = 1234567890123, Format = CODE128

二维码

barcodescanner

E/logg: 图像处理及识别耗时: 448 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 469 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 453 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 473 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 511 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 461 ms
E/logg: Contents = 草料二维码, Format = QRCODE

SimpleScanner

E/logg: 图像处理及识别耗时: 58 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 61 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 56 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 62 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 58 ms
E/logg: Contents = 草料二维码, Format = QRCODE
E/logg: 图像处理及识别耗时: 62 ms
E/logg: Contents = 草料二维码, Format = QRCODE

可见SimpleScanner相比barcodescanner基本能有90%左右的性能提升。虽然400ms并不起眼,但在实际使用中,一个是对焦即识别,另一个是对焦后要稍微停顿一下才能识别,对于连续多次扫码的使用场景,SimpleScanner的使用体验要明显更好。此外,上面的测试结果基于小米4(CPU为高通801),对于中低端手机,实际的耗时差别会更大。

3 项目结构简介

项目中比较重要的类主要有5个:

ZBarScannerView

zbar扫码视图,可以直接在Activity中使用。

主要做了一件事:对相机预览的回调方法onPreviewFrame中的data(预览图像数据)进行一些预处理,并将处理后的数据交给zbar解码库去识别。

BarcodeScannerView

基本扫码视图,是ZBarScannerView的父类。

主要包含了对相机预览的相关操作。同时,BarcodeScannerView也是一个视图容器(本质上是一个FrameLayout),包含了CameraPreview(相机预览画面)和ViewFinderView(覆盖在相机预览画面上层的扫码框、阴影遮罩等)。

CameraPreview

这是一个SurfaceView,用于展示相机预览画面。

ViewFinderView

覆盖在相机预览上层的view,包含扫码框、扫描线、扫码框周围的阴影遮罩等。

可以直接修改以下字段来改变ViewFinderView的外观:

private float widthRatio = 0.8f;//扫码框宽度占view总宽度的比例
private float heightWidthRatio = 0.5f;//扫码框的高宽比
private int leftOffset = -1;//扫码框相对于左边的偏移量,若为负值,则扫码框会水平居中
private int topOffset = 200;//扫码框相对于顶部的偏移量,若为负值,则扫码框会竖直居中
private boolean isLaserEnabled = true;//是否显示扫描线

ViewFinderView本身的代码非常简单,并且与相机预览、扫码识别的逻辑几乎没有任何关联,因此修改起来十分容易。如果需要,可以自由地修改这个类来定制出自己需要的扫码界面。

ViewFinderView和CameraPreview的宽高都是match_parent,也就是说ViewFinderView和CameraPreview是等大的。

SimpleScannerActivity

ZBarScannerView的使用方法示例。

4 主要优化点简介

这里只对主要的两个优化点做个简要介绍,具体细节请查看项目源码。

(1)精确截取扫码识别区域

通常做法:

目前市面上大多数app对于扫码识别区域的处理方式都是:将整个预览图像交给核心解码库(即zbar或zxing)进行识别。也就是说(如上图所示),虽然在屏幕偏上方有一个扫码框,用户也会自然而然地将要扫的条码放到扫码框区域之内,但实际上,在app内部,传递给核心解码库进行识别的,是整个预览画面。

这种处理方式的好处是代码逻辑非常简单,而缺点有两个:

  • 首先是上面提到过的那种场景,当有多个排列较为紧密的条码,而你只想识别其中的某一个时,操作起来会比较困难。
  • 第二,因为传递给核心解码库的是整个预览画面,数据量较大,因此识别速度也会较慢。

优化:

public Rect getScaledRect(int previewWidth, int previewHeight) {
    if (scaledRect == null) {
        Rect framingRect = mViewFinderView.getFramingRect();//获得扫码框区域
        int viewFinderViewWidth = mViewFinderView.getWidth();
        int viewFinderViewHeight = mViewFinderView.getHeight();

        int width, height;
        if (DisplayUtils.getScreenOrientation(getContext()) == Configuration.ORIENTATION_PORTRAIT//竖屏使用
                && previewHeight < previewWidth) {
            width = previewHeight;
            height = previewWidth;
        } else if (DisplayUtils.getScreenOrientation(getContext()) == Configuration.ORIENTATION_LANDSCAPE//横屏使用
                && previewHeight > previewWidth) {
            width = previewHeight;
            height = previewWidth;
        } else {
            width = previewWidth;
            height = previewHeight;
        }

        scaledRect = new Rect(framingRect);
        scaledRect.left = scaledRect.left * width / viewFinderViewWidth;
        scaledRect.right = scaledRect.right * width / viewFinderViewWidth;
        scaledRect.top = scaledRect.top * height / viewFinderViewHeight;
        scaledRect.bottom = scaledRect.bottom * height / viewFinderViewHeight;
    }

    return scaledRect;
}

要明白这个方法的含义,首先需要知道的是(如上图所示):虽然相机预览画面(CameraPreview)和上层的ViewFinderView大小一样,但实际上,系统传递给我们的预览图像数据(也就是onPreviewFrame方法的data参数),其尺寸与ViewFinderView的尺寸通常是不同的。因此,为了在预览图像数据中截取正确的区域,我们必须根据预览图像数据和ViewFinderView的尺寸之比对扫码框区域进行缩放。举个简单的例子:假设ViewFinderView的尺寸是1280*720,扫码框的尺寸是100*200,而预览图像数据的尺寸是2560*1440,那么,要想实际截取的区域与我们所看到的区域一致,我们就必须用一个200*400的矩形框去预览图像数据中截取。也就是说,如果预览图像数据的尺寸是ViewFinderView的尺寸的两倍,那么我们用来截取预览图像数据的矩形框的尺寸也应该是ViewFinderView中扫码框尺寸的两倍。

上面getScaledRect(int previewWidth, int previewHeight)方法所做的事情就是根据ViewFinderView和预览图像数据的尺寸之比,对扫码框的矩形区域进行缩放。

  • 方法的参数previewWidth和previewHeight就是预览图像数据的宽和高。
  • mViewFinderView.getFramingRect()获得的是扫码框在ViewFinderView中的矩形区域。

(2)提高识别速度

事实上,要使实际截取的区域与我们所看到的区域一致,仅仅像上面那样缩放扫码框是不够的。这是因为,系统传递给我们的预览图像数据不仅尺寸可能会与ViewFinderView不同,其方向通常也与窗口的方向不同。比如,我们看到的预览图像是下面这样的(图中的黄色框是我画的扫码框示意,你可以把薄荷糖盒子想象成要扫的条码):

而实际上,系统传递给我们的预览图像数据很可能是这样的:

显然,在这种情况下,如果你直接拿缩放后的矩形框去预览图像数据中截取,那就完全截错了地方。那该怎么办呢?有两种解决办法可供选择:

  1. 将图像数据顺时针旋转90度
  2. 将矩形框逆时针旋转90度

目前我所知的扫码库采用的都是第一种办法,即将图像数据顺时针旋转90度,代码如下:

public byte[] rotateData(byte[] data, Camera camera) {
    Camera.Parameters parameters = camera.getParameters();
    int width = parameters.getPreviewSize().width;
    int height = parameters.getPreviewSize().height;

    int rotationCount = getRotationCount();
    for (int i = 0; i < rotationCount; i++) {
        byte[] rotatedData = new byte[data.length];
        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++)
                rotatedData[x * height + height - y - 1] = data[x + y * width];
        }
        data = rotatedData;
        int tmp = width;
        width = height;
        height = tmp;
    }

    return data;
}

简单的说,就是根据位置关系对图像数据中的每一个点进行映射赋值。对于尺寸为1920*1080的图像预览数据,rotatedData[x * height + height - y - 1] = data[x + y * width]这样的运算要重复200多万次,这显然是一个十分耗时的过程。

而另一种选择就是将矩形框逆时针旋转90度,其代码如下:

public Rect getRotatedRect(int previewWidth, int previewHeight, Rect rect) {
    if (rotatedRect == null) {
        int rotationCount = getRotationCount();
        rotatedRect = new Rect(rect);

        if (rotationCount == 1) {//若相机图像需要顺时针旋转90度,则将扫码框逆时针旋转90度
            rotatedRect.left = rect.top;
            rotatedRect.top = previewHeight - rect.right;
            rotatedRect.right = rect.bottom;
            rotatedRect.bottom = previewHeight - rect.left;
        } else if (rotationCount == 2) {//若相机图像需要顺时针旋转180度,则将扫码框逆时针旋转180度
            rotatedRect.left = previewWidth - rect.right;
            rotatedRect.top = previewHeight - rect.bottom;
            rotatedRect.right = previewWidth - rect.left;
            rotatedRect.bottom = previewHeight - rect.top;
        } else if (rotationCount == 3) {//若相机图像需要顺时针旋转270度,则将扫码框逆时针旋转270度
            rotatedRect.left = previewWidth - rect.bottom;
            rotatedRect.top = rect.left;
            rotatedRect.right = previewWidth - rect.top;
            rotatedRect.bottom = rect.right;
        }
    }

    return rotatedRect;
}

旋转矩形框的原理很简单,假设矩形框的left、top、right、bottom分别为a、b、c、d,如下图所示:

以下面这种方向的预览图像数据为例:

我们需要得到下面这样的一个矩形框,也就是将矩形框逆时针旋转了90度:

由图可知旋转前后矩形框的对应关系为:

lefttoprightbottom
旋转前abcd
旋转后bheight-cdheight-a

有了这张表格那么下面这段代码就很容易理解了:

if (rotationCount == 1) {//若相机图像需要顺时针旋转90度,则将扫码框逆时针旋转90度
    rotatedRect.left = rect.top;
    rotatedRect.top = previewHeight - rect.right;
    rotatedRect.right = rect.bottom;
    rotatedRect.bottom = previewHeight - rect.left;
}

需要注意的是:第二种处理方法(旋转矩形框,而不旋转预览图像数据)只适用核心解码库为zbar的情形。如果核心解码库为zxing,由于zxing不能识别竖直排列的条形码,因此只能采用旋转预览图像数据这种方式。

5 最后

相机扫码涉及的知识点比较多,限于篇幅无法在此一一讲清。建议有兴趣深入的朋友先对Camera类、相机预览机制、各种方向的概念(窗口方向、设备自然方向、相机方向)等做一个大概了解,然后再去阅读本项目(SimpleScanner)的源码。

参考

  • Camera官方文档:https://developer.android.com/guide/topics/media/camera.html
  • barcodescanner:https://github.com/dm77/barcodescanner

本文由【waitig】发表在等英博客
本文固定链接:极速扫码——zbar扫码库性能优化实践
欢迎关注本站官方公众号,每日都有干货分享!
等英博客官方公众号
点赞 (0)分享 (0)