Android CameraX 获取摄像头数据,采用ImageProxy进行数据分析


一、什么是Android CameraX


CameraX[1] 是一个 Jetpack 支持库,旨在帮助您简化相机应用的开发工作。它提供一致且易于使用的 API Surface,适用于大多数 Android 设备,并可向后兼容至 Android 5.0(API 级别 21)。

虽然它利用的是 camera2 的功能,但使用的是更为简单且基于用例的方法,该方法具有生命周期感知能力。它还解决了设备兼容性问题,因此您无需在代码库中添加设备专属代码。这些功能减少了将相机功能添加到应用时需要编写的代码量。

754419f49f7316ff22a833faed342974.png

官方教程也很详细,如下: 官方教程[2]

Add the Gradle dependencies

1.Open the build.gradle(Module: app) file and add the CameraX dependencies to our app Gradle file, inside the dependencies section:

def camerax_version = "1.0.0-alpha05"
// CameraX core library using camera2 implementation
implementation "androidx.camera:camera-camera2:$camerax_version"
// CameraX Lifecycle Library

2.CameraX needs some methods that are part of Java 8, so we need to set our compile options accordingly. At the end of the android block, right after buildTypes, add the following:

compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
}

2f5b9481800beb3e19d0a553d9d69cfb.png

3、Request camera permissions

public boolean checkPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && checkSelfPermission(
                Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(new String[]{
                    Manifest.permission.READ_EXTERNAL_STORAGE,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE,
                    Manifest.permission.CAMERA
            }, 1);


        }
        return false;
    }

4、Implement Preview use case

创建ImageCaptureConfig和ImageCapture这两个对象,用imageCapture.takePicture方法传入相片保存地址就行了。当然在生命周期绑定中也加上imageCapture。

ImageCaptureConfig可以定制相片尺寸和长宽比例,这里的尺寸和比例跟相机预览的尺寸比例无关,我测试传入任何比例都能得到图片。

// 2. capture
        ImageCaptureConfig imageCaptureConfig = new ImageCaptureConfig.Builder()
                .setTargetAspectRatio(new Rational(1,1))
                .setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
                .build();
        final ImageCapture imageCapture = new ImageCapture(imageCaptureConfig);
        viewFinder.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View view) {
                File photo = new File(getExternalCacheDir() + "/" + System.currentTimeMillis() + ".jpg");
                imageCapture.takePicture(photo, new ImageCapture.OnImageSavedListener() {
                    @Override
                    public void onImageSaved(@NonNull File file) {
                        showToast("saved " + file.getAbsolutePath());
                    }


                    @Override
                    public void onError(@NonNull ImageCapture.UseCaseError useCaseError, @NonNull String message, @Nullable Throwable cause) {
                        showToast("error " + message);
                        cause.printStackTrace();
                    }
                });
                return true;
            }
        });


        CameraX.bindToLifecycle(this, preview, imageCapture);

5.Implement ImageCapture use case

给TextureView设置布局变化的监听,用updateTransform()更新相机预览,然后startCamera()启动相机

TextureView viewFinder = findViewById(R.id.view_finder);
        viewFinder.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
            @Override
            public void onLayoutChange(View view, int i, int i1, int i2, int i3, int i4, int i5, int i6, int i7) {
                updateTransform();
            }
        });


        viewFinder.post(new Runnable() {
            @Override
            public void run() {
                startCamera();
            }
        });

更新相机预览:主要是给TextureView设置一个旋转的矩阵变化,防止预览方向不对

private void updateTransform() {
        Matrix matrix = new Matrix();
        // Compute the center of the view finder
        float centerX = viewFinder.getWidth() / 2f;
        float centerY = viewFinder.getHeight() / 2f;


        float[] rotations = {0,90,180,270};
        // Correct preview output to account for display rotation
        float rotationDegrees = rotations[viewFinder.getDisplay().getRotation()];


        matrix.postRotate(-rotationDegrees, centerX, centerY);


        // Finally, apply transformations to our TextureView
        viewFinder.setTransform(matrix);
    }

启动相机:创建PreviewConfig和Preview这两个对象,可以设置预览图像的尺寸和比例,在OnPreviewOutputUpdateListener回调中用setSurfaceTexture方法,将相机图像输出到TextureView。最后用CameraX.bindToLifecycle方法将相机与当前页面的生命周期绑定。

private void startCamera() {
        // 1. preview
        PreviewConfig previewConfig = new PreviewConfig.Builder()
                .setTargetAspectRatio(new Rational(1, 1))
                .setTargetResolution(new Size(640,640))
                .build();


        Preview preview = new Preview(previewConfig);
        preview.setOnPreviewOutputUpdateListener(new Preview.OnPreviewOutputUpdateListener() {
            @Override
            public void onUpdated(Preview.PreviewOutput output) {
                ViewGroup parent = (ViewGroup) viewFinder.getParent();
                parent.removeView(viewFinder);
                parent.addView(viewFinder, 0);


                viewFinder.setSurfaceTexture(output.getSurfaceTexture());
                updateTransform();
            }
        });


        CameraX.bindToLifecycle(this, preview);

这样就实现了基本的相机预览功能。这几个方法都很简单明了,对外只依赖一个TextureView。生命周期自动绑定,这意味着代码可以写在一块,在一处调用。不像以前这里插一段代码,那里插一段代码。

还有最大的好处,就是可扩展性。相机预览使用了PreviewConfig和Preview两个对象,加入新的相机功能同样是加两个对象XXXConfig和XXX,其他地方都不同改!

加入拍照功能就加入ImageCaptureConfig和ImageCapture,加入图像分析功能就加入ImageAnalysisConfig和ImageAnalysis,非常方便统一。

6、Implement ImageAnalysis use case

图片分析名字很高大上,实际上就是图像数据回调,实时获取相机的图像数据,可以自己处理这些图像。

创建ImageAnalysisConfig和ImageAnalysis这两个对象,创建一个HandlerThread用于在子线程中处理数据,创建一个ImageAnalysis.Analyzer接口实现类,在analyze(ImageProxy imageProxy, int rotationDegrees)回调方法中就能拿到图像数据了。当然ImageAnalysis对象也要绑定生命周期。

我这里分析图像数据用了之前写的一个工具YUVDetectView,来分析图像属于哪种YUV420格式。

// 3. analyze
        HandlerThread handlerThread = new HandlerThread("Analyze-thread");
        handlerThread.start();


        ImageAnalysisConfig imageAnalysisConfig = new ImageAnalysisConfig.Builder()
                .setCallbackHandler(new Handler(handlerThread.getLooper()))
                .setImageReaderMode(ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
                .setTargetAspectRatio(new Rational(2, 3))
//                .setTargetResolution(new Size(600, 600))
                .build();


        ImageAnalysis imageAnalysis = new ImageAnalysis(imageAnalysisConfig);
        imageAnalysis.setAnalyzer(new MyAnalyzer());


        CameraX.bindToLifecycle(this, preview, imageCapture, imageAnalysis);






    private class MyAnalyzer implements ImageAnalysis.Analyzer {


        @Override
        public void analyze(ImageProxy imageProxy, int rotationDegrees) {
            final Image image = imageProxy.getImage();
            if(image != null) {
                Log.d("chao", image.getWidth() + "," + image.getHeight());
                imageView.input(image);
            }
        }
    }

二、摄像头数据处理

就图像而言,首先需要获得摄像头采集的数据,然后得到这个byte[] 进行编码,再进行后续的封包与发送。我们通 过CameraX图像分析接口得到的数据为ImageProxy(Image的代理类)

那么怎么从ImageProxy/Image 中获取 我们需要的数据呢,这个数据格式是什么?

@Override
public void analyze(ImageProxy image, int rotationDegrees) {
    Log.i(TAG, "analyze: " + image.getWidth() + "  height " + image.getHeight() + " rotationDegrees:" + rotationDegrees);
    //图像格式
    int format = image.getFormat();
    if (format != ImageFormat.YUV_420_888) {
        Log.i(TAG, "analyze: format:" + format);
    }




    lock.lock();
    ImageProxy.PlaneProxy[] planes = image.getPlanes();


    lock.unlock();
}

可以通过 getPlanes方法得到PlaneProxy数组。PlaneProxy为Image.Plane代理,同ImagePrxoy与Image的关系 一样。

/** A plane proxy which has an analogous interface as {@link android.media.Image.Plane}. */
interface PlaneProxy {
    /**
     * Returns the row stride.
     *
     * <p>@see {@link android.media.Image.Plane#getRowStride()}.
     */
    int getRowStride();


    /**
     * Returns the pixel stride.
     *
     * <p>@see {@link android.media.Image.Plane#getPixelStride()}.
     */
    int getPixelStride();


    /**
     * Returns the pixels buffer.
     *
     * <p>@see {@link android.media.Image.Plane#getBuffer()}.
     */
    ByteBuffer getBuffer();
}

8310ea612403899e47041bbff54466dc.png

YUV420根据颜色数据的存储顺序不同,又分为了多种不同的格式,这些格式实际存储的信息还是完全一致的。举 例来说,对于4x4的图片,在YUV420下,任何格式都有16个Y值,4个U值和4个V值,不同格式只是Y、U和V的排 列顺序变化。I420 为 YYYYYYYYYYYYYYYYUUUUVVVV ,YUV420 是一类格式的集合,YUV420并不能完全确定颜色数据的存储顺序。

PlaneProxy/Plane

Y、U和V三个分量的数据分别保存在三个Plane类中,即通过 getPlanes()得到的数组。Plane 实际是对ByteBuffer的封装。

Image保证了planes[0]一定是Y,planes[1]一定是U,planes[2]一定是V。且对于plane [0],Y分量数据一定是连续存储的,中间不会有U或V数据穿插,也就是说我们一定能够一次性得到所有Y分量的值

但是对于UV数据,可能存在以下两种情况:

1. planes[1] = {UUUU...},planes[2] = {VVVV...};  //I420


2. planes[1] = {UVUV...},planes[2] = {VUVU...}。

PixelStride

所以在我么取数据时需要在根据Plane中的另一个信息来确定如何取对应的U或者V数据。

// 行内数据值间隔
// 1:表示无间隔取值,即为上面的第一种情况
// 2: 表示需要间隔一个数据取值,即为上面的第二种情况
 int pixelStride = plane.getPixelStride();

根据这个属性,我们将确定数据如何存储,因此如果需要取出代表I420格式的byte[],则为:YUV420中,Y数据长度为: width*height , 而U、V都为:width / 2 * height / 2。

ImageProxy.PlaneProxy[] planes = image.getPlanes();
//y数据的这个值只能是:1
int pixelStride = planes[0].getPixelStride();
int pixelStride2 = planes[1].getPixelStride();
Log.i(TAG, "pixelStride: " + pixelStride+" pixelStride2: "+ pixelStride2 );

8c26bfb38b0924e46199fe04872082fd.png

pixelStride: 1 pixelStride2: 2

小米手机运行 planes[0] 的PixelStride 为1 planes[1]的 PixelStride为2

说明是UVUV交叉存储

// Y数据 pixelStride一定为1
        int pixelStride = planes[0].getPixelStride();
        planes[0].getBuffer() // Y数据
        byte[] u = new byte[image.getWidth() / 2 * image.getHeight() / 2];
        int pixelStride = planes[1].getPixelStride();
        if (pixelStide == 1) {
            planes[1].getBuffer() // U数据
        } else if (pixelStide == 2) {
            ByteBuffer uBuffer = planes[1].getBuffer()
            for (int i = 0; i < uBuffer.remaining(); i+=2) {
                u[i] = uBuffer.get(); //丢弃一个数据,这个数据其实是V数据,但是我们还是到planes[2]中获取V数据 
                uBuffer.get();
            }
        }

但是如果使用上面的代码去获取YUV数据,可能你会惊奇的发现,并不是在所有你设置的Width与 Height(分辨率)下都能够正常运行。我们忽略了什么,为什么会出现问题呢?

在Plane中 我们已经使用了 getBuffer 与 getPixelStride 两个方法,但是还有一个 getRowStride没有用到.

RowStride

RowStride表示行步长,Y数据对应的行步长可能为:

1.等于Width;2.大于Width;

以4x4的I420为例,其数据可以看为

aec30c250fb8f97ae21de03fed913bed.png

如果RowStride等于Width,那么我们直接通过 planes[0].getBuffer() 获得Y数据没有问题。

但是如果RowStride大于Width,比如对于4x4的I420,如果每行需要以8字节对齐,那么可能得到的RowStride不等于4(Width),而是得到8。那么此时会在每行数据末尾补充占位的无效数据:

b94294e6972f9f1a5fb59a6b78794c02.png

获取Y数据

ImageProxy.PlaneProxy[] planes = image.getPlanes();
        // todo 避免内存抖动.
        int size = image.getWidth() * image.getHeight() * 3 / 2;
        if (yuv420 == null || yuv420.capacity() < size) {
            yuv420 = ByteBuffer.allocate(size);
        }
        yuv420.position(0);
        /**
         * Y数据
         */
        ImageProxy.PlaneProxy plane = planes[0];//y数据
        //pixelStride = 1 : 取值无间隔
        //pixelStride = 2 : 间隔1个字节取值
        // y的此数据应该都是1
        int pixelStride = plane.getPixelStride();//Y的肯定为1
        //大于等于宽, 表示连续的两行数据的间隔
        //  如:640x480的数据,
        //  可能得到640
        //  可能得到650,表示每行最后10个字节为补位的数据
        int rowStride = plane.getRowStride();//rowStride 可能末尾有填充
        ByteBuffer buffer = plane.getBuffer();
        byte[] row = new byte[image.getWidth()];
        // 每行要排除的无效数据,但是需要注意:实际测试中 最后一行没有这个补位数据
        byte[] skipRow = new byte[rowStride - image.getWidth()];
        for (int i = 0; i < image.getHeight(); i++) {
            buffer.get(row);
            yuv420.put(row);
            // 不是最后一行
            if (i < image.getHeight() - 1) {
                buffer.get(skipRow);//最后一行因为后面跟着U 数据,没有无效占位数据,不需要丢弃
            }
        }

而对于U与V数据,对应的行步长可能为:

1.等于Width;2.大于Width;3.等于Width/2;4.大于Width/2

等于Width

这表示,我们获得planes[1]中不仅包含U数据,还会包含V的数据,此时pixelStride==2。

UVUV
UVUV

那么V数据:planes[2],则为:

VUVU
VUVU

大于Width

与Y数据一样,可能由于字节对齐,出现RowStride大于Width的情况,与等于Width一样,planes[1]中不仅包含U 数据,还会包含V的数据,此pixelStride==2。

UVUV0000








UVUV最后一行没有站位





planes[2],则为:

VUVU0000








VUVU最后一行没有站位





等于Width/2

当获取的U数据对应的RowStride等于Width/2,表示我们得到的planes[1]只包含U数据。此时pixelStride==1。那么planes[1]+planes[2]为:

UU

UU

VV

VV

这种情况,所有的U数据是连在一起的,即 planes[1].getBuffer 可以直接获得完整的U数据。

大于Width/2

同样我们得到的planes[1]只包含U数据,但是与Y数据一样,可能存在占位数据。此时pixelStride==1。planes[1]+planes[2]为:

UU000000








UU最后一行没有站位



VV000000








VV最后一行没有站位



获取UV数据

/**
         * U V 数据
         */
        for (int i = 1; i < 3; i++) {//planes[1] | planes[2] uv数据处理
            plane = planes[i];
            pixelStride = plane.getPixelStride();//1 I420 2 交错packed UVUV
            // uv数据的rowStride可能是
            // 如:640的宽
            // 可能得到320, pixelStride 为1
            // 可能大于320同时小于640,有为了补位的无效数据  pixelStride 为1
            // 可能得到640 uv数据在一起,pixelStride为2
            // 可能大于640,有为了补位的无效数据 pixelStride为2
            rowStride = plane.getRowStride();
            buffer = plane.getBuffer();
            int uvWidth = image.getWidth() / 2;
            int uvHeight = image.getHeight() / 2;


            for (int j = 0; j < uvHeight; j++) {
                for (int k = 0; k < rowStride; k++) {
                    // 最后一行,是没有补位数据的
                    if (j == uvHeight - 1) {
                        //只有自己(U/V)的数据
                        if (pixelStride == 1) {
                            // 结合外层if 则表示:
                            //  如果是最后一行,我们就不管结尾的占位数据了
                            if (k >= uvWidth) {
                                break;
                            }
                        } else if (pixelStride == 2) {
                            //与同级if相同意思
                            // todo uv混合,
                            //  planes[2]:uvu
                            //  planes[3]:vuv
                            if (k >= image.getWidth() - 1) {
                                break;
                            }
                        }
                    }
                    byte b = buffer.get();
                    if (pixelStride == 2) {
                        //打包格式 uv在一起,偶数位取出来是U数据:0 2 4 6
                        if (k < image.getWidth() && k % 2 == 0) {
                            yuv420.put(b);
                        }
                    } else if (pixelStride == 1) {
                        if (k < uvWidth) {
                            yuv420.put(b);
                        }
                    }
                }
            }
        }

三、YUV简介

与RGB类似,YUV也是一种颜色编码方法,主要用于视频领域,它将亮度信息(Y)与色彩信息(UV)分离,没有UV信息一样可以显示完整的图像,只不过是黑白的,比如这样的设计解决了彩色电视机与黑白电视的兼容问题。

YUV,分为三个分量,“Y”表示的是明亮度(Luminance或Luma),也就是灰度值;而“U”和“V” 表示的则是色度(Chrominance或Chroma),作用是用于指定像素的颜色。

UV 即CbCr(C代表颜色,b代表蓝色,r代表红色)

分类

YUV格式有两大类:==平面(planar)和紧凑(packed==)。

对于planar的YUV格式,先连续存储所有像素点的Y,紧接着存储所有像素点的U,随后是存储所有像素点的V,或者是先v后u

对于packed的YUV格式,每个像素点的Y,U,V是连续交替存储的

采样

主流的采样方式有三种,YUV4:4:4,YUV4:2:2,YUV4:2:0

YUV 4:4:4采样,每一个Y对应一组UV分量,一个YUV占8+8+8 = 24bits 3个字节。

YUV 4:2:2采样,每两个Y共用一组UV分量,一个YUV占8+4+4 = 16bits 2个字节。

YUV 4:2:0采样,每四个Y共用一组UV分量,一个YUV占8+2+2 = 12bits 1.5个字节。

最常见的YUV420P和YUV420SP都是基于4:2:0采样的,所以如果图片的宽为width,高为heigth,在内存中占的空间为width * height * 3 / 2,其中前width * height的空间存放Y分量,接着width * height / 4存放U分量,最后width * height / 4存放V分量

YUV格式

常见的YUV格式有YUY2、YUYV、YVYU、UYVY、AYUV、Y41P、Y411、Y211、IF09、IYUV、YV12、YVU9、YUV411、YUV420等,Android中比较常见是YUV420分为两种:YUV420PYUV420SP。所以就先了解下YUV420PYUV420SP.

libX264中对YUV各种格式的定义。

#define X264_CSP_I400           0x0001  /* monochrome 4:0:0 */
#define X264_CSP_I420           0x0002  /* yuv 4:2:0 planar */
#define X264_CSP_YV12           0x0003  /* yvu 4:2:0 planar */
#define X264_CSP_NV12           0x0004  /* yuv 4:2:0, with one y plane and one packed u+v */
#define X264_CSP_NV21           0x0005  /* yuv 4:2:0, with one y plane and one packed v+u */
#define X264_CSP_I422           0x0006  /* yuv 4:2:2 planar */
#define X264_CSP_YV16           0x0007  /* yvu 4:2:2 planar */
#define X264_CSP_NV16           0x0008  /* yuv 4:2:2, with one y plane and one packed u+v */
#define X264_CSP_YUYV           0x0009  /* yuyv 4:2:2 packed */
#define X264_CSP_UYVY           0x000a  /* uyvy 4:2:2 packed */
#define X264_CSP_V210           0x000b  /* 10-bit yuv 4:2:2 packed in 32 */
#define X264_CSP_I444           0x000c  /* yuv 4:4:4 planar */
#define X264_CSP_YV24           0x000d  /* yvu 4:4:4 planar */

YUV420P

YUV420P是平面模式,Y , U , V分别在不同平面,也就是有三个平面,它是YUV标准格式4:2:0

4916dd27ba8ddff6f38c0ea0945a5899.png

那么真实的在字节流中就是按照行从左到右一行一行的拼起来的:

802e0d38c019b47101936656ef51c028.png

==YUV420P分为:YU12和YV12==

YU12格式

在Android中也叫作I420格式,首先是所有Y值,然后是所有U值,最后是所有V值。比如6x6的图片,内存大小就是6x6x3/2=54个字节。为了更清晰的查看,我们换行看,真实的是一行byte[]数据流。

YYYYYY
YYYYYY
YYYYYY
YYYYYY
UUUUUU
VVVVVV

YV12格式

YV12格式与YU12基本相同,首先是所有Y值,然后是所有V值,最后是所有U值。比如6x6的图片,内存大小就是6x6x3/2=54个字节

YYYYYY
YYYYYY
YYYYYY
YYYYYY
VVVVVV
UUUUUU

YUV420SP

YUV420SP 也是是平面模式分为NV21和NV12两种格式。Y是一个平面,UV是一个平面,UV/VU为交替存储,而不是分为三个平面

在Android Camera中文档中强烈推荐使用NV21YV12,因为这两种格式支持所有的相机设备。Camera默认输出YUV的数据格式为NV21。但是在Camera2中,推荐使用的格式则是YUV_420_888

a4ad3671927cd5e9564951703a6ad1e9.png

NV21格式

在Android Camera中手机从摄像头采集的预览数据默认值是NV21

NV21存储顺序是先存Y值,再VU交替存储:YYYYVUVUVU,比如6x6的图片,内存大小就是6x6x3/2=54个字节

YYYYYY
YYYYYY
YYYYYY
YYYYYY
VUVUVU
VUVUVU

NV12格式

NV12存储顺序是先存Y值,再UV交替存储:YYYYUVUVUV,比如6x6的图片,内存大小就是6x6x3/2=54个字节

YYYYYY
YYYYYY
YYYYYY
YYYYYY
UVUVUV
UVUVUV

这里先熟悉下Android中常见的YUV420PYUV420SP。一般我们在使用yuv数据的时候,会对yuv数据进行变换,比如:摄像头数据旋转,从一种格式转为另一种数据等。

References

[1] CameraX: https://developer.android.com/training/camerax?hl=zh-cn
[2] 官方教程: https://developer.android.com/training/camerax?hl=zh-cn

(文章来源:https://gitee.com/kusebingtang/my-blog-markdown/blob/master/Android%20CameraX%E8%8E%B7%E5%8F%96H264%E7%A0%81%E6%B5%81.md#)

f6abf3a3f85301e90a9e92c71bf27a6b.png

《Android Camera开发入门》、《Camx初认识》已经上架,可以点击了解 -> 小驰成长圈 |期待见证彼此的成长 120d8376969d21652408c9d0420d985b.png

1cbdb6ab7f78c7f9b1bcd3229257a9f6.png

觉得不错,点个赞呗 f950a567f98461d201018ea1385e18c8.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值