TRTCSDK自定义采集YUV视频通话

2021-08-06 10:18:40 浏览数 (1)

一、适用场景

腾讯TRTCSDK,提供了摄像头通话、录屏通话、基础美颜、高级美颜功能。

摄像头通话功能,是TRTCSDK对系统摄像头进行了封装,采集摄像头数据,编码传输通话。

如果您自研(或者购买第三方)美颜和特效处理模块,则需要自己采集和处理摄像头拍摄画面,对采集到的YUV数据、纹理数据进行操作处理,将处理后的数据,交给TRTCSDK编码传输通话。TRTCSDK是有提供自定义采集功能接口的。

二、API介绍:

enableCustomVideoCapture

sendCustomVideoData

如官网api文档介绍:

enableCustomVideoCapture( boolean enable )启用视频自定义采集模式

开启该模式后,SDK 不在运行原有的视频采集流程,只保留编码和发送能力。 您需要用 sendCustomVideoData() 不断地向 SDK 塞入自己采集的视频画面。

sendCustomVideoData( TRTCCloudDef.TRTCVideoFrame frame )向 SDK 投送自己采集的视频数据

Android 平台有两种的方案:

  • buffer 方案:对接起来比较简单,但是性能较差,不适合分辨率较高的场景。
  • texture 方案:对接需要一定的 OpenGL 基础,但是性能较好,640 × 360 以上的分辨率请采用该方案。

TRTCVideoFrame参数释义如下

写法示例:

代码语言:javascript复制
//YUV buffer方案
        TRTCCloudDef.TRTCVideoFrame frame = new TRTCCloudDef.TRTCVideoFrame();
        frame.bufferType = TRTC_VIDEO_BUFFER_TYPE_BYTE_ARRAY;
        frame.pixelFormat = TRTC_VIDEO_PIXEL_FORMAT_I420;
//        frame.pixelFormat = TRTC_VIDEO_PIXEL_FORMAT_NV21;
        frame.data = byte[](i420Data);
        frame.width = 320;
        frame.height = 240;
        
        TRTCCloud.sendCustomVideoData(frame);
        
//纹理 texture方案
        TRTCCloudDef.TRTCVideoFrame frame = new TRTCCloudDef.TRTCVideoFrame();
        frame.bufferType = TRTC_VIDEO_BUFFER_TYPE_TEXTURE;
        frame.pixelFormat = TRTCCloudDef.TRTC_VIDEO_PIXEL_FORMAT_Texture_2D;
        frame.texture = new TRTCCloudDef.TRTCTexture();
        frame.texture.textureId = textureId;
        frame.texture.eglContext14 = eglContext;
        frame.width = 960;
        frame.height = 720;
        frame.timestamp = 0;
        
        TRTCCloud.sendCustomVideoData(frame);

三、YUV Buffer方案:

本篇主要介绍yuv Buffer方案:使用安卓系统封装的camera2,采集到yuv数据,转换成标准的i420格式/nv21格式,交给TRTCSDK编码传输。

0、通话效果

写成的demo效果如下,源码地址点击下载。

两个图中,不同手机采集的YUV_420_888数据格式不同,左边是yuv420p,右边是yuv420sp

在开始讲demo代码实现过程之前,我们先回顾一下几个知识点:yuv数据、安卓相机camera2、yuv_420_888

这三个知识点,是demo中需要用的音视频基础,下面讲串起来讲一下。

1、yuv数据

简介:

与我们熟知的RGB类似,YUV也是一种颜色编码方法,最初用于电视系统以及模拟视频领域,它将亮度信息(Y)与色彩信息(UV)分离,没有UV信息一样可以显示完整的图像,只不过是黑白的,这样的设计很好地解决了彩色电视机与黑白电视的兼容问题。并且,YUV不像RGB那样要求三个独立的视频信号同时传输,所以用YUV方式传送占用极少的频宽。

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

将一张图片的Y、U、V数据单独显示就会如下图所示:

采样方式:

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

用三个图来直观地表示采集的方式吧,以黑点表示采样该像素点的Y分量,以空心圆圈表示采用该像素点的UV分量。

先记住下面这段话,以后提取每个像素的YUV分量会用到。

  1. YUV 4:4:4采样,每一个Y对应一组UV分量。
  2. YUV 4:2:2采样,每两个Y共用一组UV分量。 
  3. YUV 4:2:0采样,每四个Y共用一组UV分量。 

存储方式:

YUV格式有两大类:planar和packed,译为平面格式和打包格式

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

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

以yuv420为例,因为存储方式不同,yuv420分为yuv420p、yuv420sp,我们先看下面图解

yuv420p

yuv420sp

观察一下上面两个图,数据的存储方式不一样,

图一中,Y,U,V三个分量都是平面格式,U平面紧跟在Y平面之后,然后才是V平面,这种格式就是I420格式;如果存储排序V平面在U平面前面,那就是YV12格式。

图二种,Y分量平面格式,UV打包格式,UV交叉存储,U在前,V在后,这种格式就是NV12格式;如果是V在前,U在后,那就是NV21格式。

常用格式:

I420: YYYYYYYY UU VV    =>YUV420P

YV12: YYYYYYYY VV UU    =>YUV420P

NV12: YYYYYYYY UVUV     =>YUV420SP

NV21: YYYYYYYY VUVU     =>YUV420SP

2、安卓相机camera2

简介:

从 Android 5.0 开始,Google 引入了一套全新的相机框架 Camera2(android.hardware.camera2)并且废弃了旧的相机框架 Camera1(android.hardware.Camera)。

Camera1 那寥寥无几的 API 和极差的灵活性早已不能满足日益复杂的相机功能开发。Camera2 的出现给相机应用程序带来了巨大的变革,因为它的目的是为了给应用层提供更多的相机控制权限,从而构建出更高质量的相机应用程序。

Pipeline

Camera2 的 API 模型被设计成一个 Pipeline(管道),它按顺序处理每一帧的请求并返回请求结果给客户端。下面这张来自官方的图展示了 Pipeline 的工作流程。

为了解释上面的示意图,假设我们想要同时拍摄两张不同尺寸的图片,并且在拍摄的过程中闪光灯必须亮起来。整个拍摄流程如下:

  1. 创建一个用于从 Pipeline 获取图片的 CaptureRequest。
  2. 修改 CaptureRequest 的闪光灯配置,让闪光灯在拍照过程中亮起来。
  3. 创建两个不同尺寸的 Surface 用于接收图片数据,并且将它们添加到 CaptureRequest 中。
  4. 发送配置好的 CaptureRequest 到 Pipeline 中等待它返回拍照结果。

一个新的 CaptureRequest 会被放入一个被称作 Pending Request Queue 的队列中等待被执行,当 In-Flight Capture Queue 队列空闲的时候就会从 Pending Request Queue 获取若干个待处理的 CaptureRequest,并且根据每一个 CaptureRequest 的配置进行 Capture 操作。最后我们从不同尺寸的 Surface 中获取图片数据并且还会得到一个包含了很多与本次拍照相关的信息的 CaptureResult,流程结束。

代码示例

关于camera2的几个关键类(CameraManager、CameraCharacteristics、CameraDevice、Surface、CameraCaptureSession、CaptureRequest、CaptureResult),本文不一一介绍,直接上demo中的代码,展示如何打开摄像头拿到Image帧。

代码语言:javascript复制
public class Camera2Helper {

//    需要在surfaceView可见之后,打开摄像头
    public void openCamera(int width, int height, SurfaceTexture mSurfaceTexture) {
        //开启摄像头线程
        mBackgroundThread = new HandlerThread("CameraBackground");
        mBackgroundThread.start();
        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
        
        //获得所有摄像头的管理者CameraManager
            CameraManager cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
            //获得某个摄像头的特征,支持的参数
            CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics("1");
            //支持的STREAM CONFIGURATION
            StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
            
            //获取手机支持的分辨率枚举值
            Size[] outputSizes = map.getOutputSizes(SurfaceTexture.class);
            
            
            //todo,计算后,从枚举数组中,选取1个分辨率,比如320X240
            mPreviewSize = outputSizes [i];

            //打开相机,第一个参数指示打开哪个摄像头,第二个参数stateCallback为相机的状态回调接口,第三个参数用来确定Callback在哪个线程执行,为null的话就在当前线程执行
            cameraManager.openCamera("1", mCameraDeviceStateCallback, mBackgroundHandler);

    }
    
    ......
    ......
    
    private CameraDevice.StateCallback mCameraDeviceStateCallback = new CameraDevice.StateCallback() {

        //摄像头打开的回调
        @Override
        public void onOpened(CameraDevice camera) {
                startPreview(camera);
        }
    }
    
    
    private void startPreview(CameraDevice camera) {

//      这里设置的就是预览大小
        mSurfaceTexture.setDefaultBufferSize(previewWidth, previewHeight);
        Surface surface = new Surface(mSurfaceTexture);
        try {
            // 设置捕获请求为预览,这里还有拍照啊,录像等
            mPreviewBuilder = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
        } catch (Exception e) {
            e.printStackTrace();
        }

        //      就是在这里,通过这个set(key,value)方法,设置曝光啊,自动聚焦等参数!! 如下举例:
        mPreviewBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);

        mImageReader = ImageReader.newInstance(previewWidth, previewHeight, ImageFormat.YUV_420_888, 1);

        mImageReader.setOnImageAvailableListener(mOnImageAvailableListener, mBackgroundHandler);

        // 这里一定分别add两个surface,一个Textureview的,一个ImageReader的,如果没add,会造成没摄像头预览,或者没有ImageReader的那个回调!!
        mPreviewBuilder.addTarget(surface);
        mPreviewBuilder.addTarget(mImageReader.getSurface());
        try {
            mCameraDevice.createCaptureSession(Arrays.asList(surface, mImageReader.getSurface()), mSessionStateCallback, mBackgroundHandler);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    
     private CameraCaptureSession.StateCallback mSessionStateCallback = new CameraCaptureSession.StateCallback() {


        @Override
        public void onConfigured(CameraCaptureSession session) {
            try {
                session.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
    
    
    private ImageReader.OnImageAvailableListener mOnImageAvailableListener = new ImageReader.OnImageAvailableListener() {


        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        @Override
        public void onImageAvailable(ImageReader reader) {

            // 获取捕获的照片数据
            Image image = reader.acquireNextImage();
            //todo 回调出image,使用yuv工具转换成标准的i420/nv21
        }
        
    }
    
}

3、YUV_420_888

简介

如上文介绍,仅yuv420就有4种格式,自然是不利于程序开发的,Image就这样出世了,Image类在API 19中引入,但真正开始发挥作用还是在API 21引入CameraDevice和MediaCodec的增强后。API 21引入了Camera2,deprecated掉了Camera,确立Image作为相机得到的原始帧数据的载体;硬件编解码的MediaCodec类加入了对Image和Image的封装ImageReader的全面支持。

YUV_420_888是YUV的泛化格式,能够表示任何4:2:0的平面和半平面格式,每个分量用8 bits 表示。带有这种格式的图像使用3个独立的Buffer表示,每一个Buffer表示一个颜色平面(Plane),除了Buffer外,它还提供rowStride、pixelStride来描述对应的Plane。 使用Image的getPlanes()获取plane数组: Image.Plane[] planes = image.getPlanes(); 它保证planes[0] 总是Y ,planes[1] 总是U(Cb), planes[2]总是V(Cr)。并保证Y-Plane永远不会和U/V交叉(yPlane.getPixelStride()总是返回 1 )。U/V-Plane总是有相同的rowStridepixelStride()(即有:uPlane.getRowStride() == vPlane.getRowStride() 和 uPlane.getPixelStride() == vPlane.getPixelStride();)。

U/V的平(Planar)面和半平面(Semi-Planar)

U/V的Planar存储(YUV420P)

我测试不同安卓设备,找到存储格式是Planar的设备:

代码语言:javascript复制
               Log.i(TAG,"image format: "  image.getFormat());
                // 从image里获取三个plane
                Image.Plane[] planes = image.getPlanes();

                for (int i = 0; i < planes.length; i  ) {
                    ByteBuffer iBuffer = planes[i].getBuffer();
                    int iSize = iBuffer.remaining();
                    Log.i(TAG, "pixelStride  "   planes[i].getPixelStride());
                    Log.i(TAG, "rowStride   "   planes[i].getRowStride());
                    Log.i(TAG, "width  "   image.getWidth());
                    Log.i(TAG, "height  "   image.getHeight());
                    Log.i(TAG, "Finished reading data from plane  "   i);
                    }
//                getPixelStride() 获取行内连续两个颜色值之间的距离(步长)。
//                getRowStride() 获取行间像素之间的距离。

输出如下:

代码语言:javascript复制
image format: 35
pixelStride 1
rowStride 1920
width 1920
height 1080
buffer size 2073600
Finished reading data from plane 0
pixelStride 1
rowStride 960
width 1920
height 1080
buffer size 518400
Finished reading data from plane 1
pixelStride 1
rowStride 960
width 1920
height 1080
buffer size 518400
Finished reading data from plane 2

在ImageFormat中,YUV_420_888格式的数值是35,如上所示,可知当前Preview格式是YUV_420_888,根据image的分辨率是 1920 x 1080 ,像素点个数是2073600 。下面分别对plane[0]、plane[1]、plane[2]作分析。

  • plane[0]表示Y,rowStride是1920 ,其pixelStride是1 ,说明Y存储时中间无间隔,每行1920个像素全是Y值,buffer size 是 plane[0]的1/4 ,buffer size / rowStride= 1080可知Y有1080行。
  • plane[1]表示U,rowStride是960 ,其pixelStride也是1,说明连续的U之间没有间隔,每行只存储了960个数据,buffer size 是 plane[0]的1/4 ,buffer size / rowStride = 540 可知U有540行,对于U来说横纵都是1/2采样。
  • pane[2]和plane[1]相同。

U/V的Semi-Planar存储 (YUV420SP)

部分华为机都属于此类,采用相同的代码输出如下:

代码语言:javascript复制
image format: 35
pixelStride 1
rowStride 1920
width 1920
height 1080
buffer size 2073600
Finished reading data from plane 0
pixelStride 2
rowStride 1920
width 1920
height 1080
buffer size 1036800
Finished reading data from plane 1
pixelStride 2
rowStride 1920
width 1920
height 1080
buffer size 1036800
Finished reading data from plane 2

image格式依然是YUV_420_888,分辨率是1920 x 1080 。

  • plane[0] 是Y数据,从rowStride是1920和 pixelStride是1,可知每行1920个像素且Y数据之间无间隔,从buffer size / rowStride = 1080 Y数据有1080行。
  • plane[1] 是U数据,rowStride 是1920, rowStride是2 ,说明每行1920个像素中每两个连续的U之间隔了一个像素,buffer中索引为: 0 , 2 , 4, 6, 8 … 是U数据,即步长为2。 每行实际的U数据只占1/2 ,buffer size / rowStride = 540 只有540行,说明纵向采样也是1/2 ,但buffer size 是 plane[0]的 1/2而不是1/4, 连续的U之间到底存储了V数据,才使得buffer size 变为plane[0]的1/2了
  • 同plane[1], 连续的V之间到底存储了U数据,才使得buffer size 变为plane[0]的1/2了

注意

Iamge的Plane中拿数据,一个plane中拿到rowStride和image.width并不总是相等,也就是说每行存储的数据宽度,并不是总是等于image宽度的,笔者亲测,有出现数据宽度大于image宽度的案例。

如下图:Plane[0]的rowStride是384,而Image宽度是320

为了方便理解这种情况,如下以6*4的图片为例,bytebuffer的排列可以理解如下:

在这里width=6,height=4,rowStride=6或者8,等于8时,最后两列会由于某些原因空一些byte,如果你转成rgb图像预览,会发现有规律的绿色栅格。

当然这张图只是说可以这么理解,实际上拿到的一维的byte数组,是每行数据接出来的如下。

好了,结合上面我对YUV_420_888数据格式的认知,我们需要把他转换成标准的I420或NV21格式,以交给TRTCSDK传输,这里笔者写了两个java层的转换算法:

代码语言:javascript复制
public class YUVUtils {


    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public static byte[] getI420DataFromImage(Image image) {

        Image.Plane[] planes = image.getPlanes();
        int width = image.getWidth();
        int height = image.getHeight();

        byte[] yBytes = new byte[width * height];
        byte[] uBytes = new byte[width * height / 4];
        byte[] vBytes = new byte[width * height / 4];
        byte[] i420 = new byte[width * height * 3 / 2];


        for (int i = 0; i < planes.length; i  ) {
            int dstIndex = 0;
            int uIndex = 0;
            int vIndex = 0;
            int pixelStride = planes[i].getPixelStride();
            int rowStride = planes[i].getRowStride();

            ByteBuffer buffer = planes[i].getBuffer();

            byte[] bytes = new byte[buffer.capacity()];

            buffer.get(bytes);
            int srcIndex = 0;
            if (i == 0) {
                for (int j = 0; j < height; j  ) {
                    System.arraycopy(bytes, srcIndex, yBytes, dstIndex, width);
                    srcIndex  = rowStride;
                    dstIndex  = width;
                }
            } else if (i == 1) {
                for (int j = 0; j < height / 2; j  ) {
                    for (int k = 0; k < width / 2; k  ) {
                        uBytes[dstIndex  ] = bytes[srcIndex];
                        srcIndex  = pixelStride;
                    }

                    if (pixelStride == 2) {
                        srcIndex  = rowStride - width;
                    } else if (pixelStride == 1) {
                        srcIndex  = rowStride - width / 2;
                    }
                }
            } else if (i == 2) {
                for (int j = 0; j < height / 2; j  ) {
                    for (int k = 0; k < width / 2; k  ) {
                        vBytes[dstIndex  ] = bytes[srcIndex];
                        srcIndex  = pixelStride;
                    }

                    if (pixelStride == 2) {
                        srcIndex  = rowStride - width;
                    } else if (pixelStride == 1) {
                        srcIndex  = rowStride - width / 2;
                    }
                }
            }
            System.arraycopy(yBytes, 0, i420, 0, yBytes.length);
            System.arraycopy(uBytes, 0, i420, yBytes.length, uBytes.length);
            System.arraycopy(vBytes, 0, i420, yBytes.length   uBytes.length, vBytes.length);

        }

        return i420;
    }
    
    

//NV21格式,暂未发现数据宽度不等于Image宽度的现象,所以简单暴力的复制Plane的data。
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public static byte[] getNV21DataFromImage(Image image) {
        Rect crop = image.getCropRect();
        int format = image.getFormat();
        int width = crop.width();
        int height = crop.height();
        Image.Plane[] planes = image.getPlanes();
        byte[] data = new byte[width * height * 3 / 2];

        // Y-buffer
        ByteBuffer yBuffer = planes[0].getBuffer();
        int ySize = yBuffer.remaining();


        // V-buffer
        ByteBuffer vBuffer = planes[2].getBuffer();
        int vSize = vBuffer.remaining();


        yBuffer.get(data, 0, ySize);

        vBuffer.get(data, ySize, vSize);


        return data;
    }

}

YUV更高效的处理方式,还是采用ndk集成libyuv。

4、TRTCSDK视频通话

转好了I420 data buffer或者NV21 data buffer格式,经过您三方美颜数据处理之后,就可以交给TRTCSDK的sendCustomVideoData接口了,即可实现自定义采集视频通话。

TRTCSDK集成、初始化等写法,官网有比较详细的介绍,写法比较简单,结合demo看代码、文档即可。

代码语言:javascript复制
//YUV buffer方案         
TRTCCloudDef.TRTCVideoFrame frame =newTRTCCloudDef.TRTCVideoFrame();
frame.bufferType = TRTC_VIDEO_BUFFER_TYPE_BYTE_ARRAY;
frame.pixelFormat = TRTC_VIDEO_PIXEL_FORMAT_I420;
//        frame.pixelFormat = TRTC_VIDEO_PIXEL_FORMAT_NV21;
frame.data = byte[](i420Data);
frame.width =320;         
frame.height =240;                  
TRTCCloud.sendCustomVideoData(frame);

四、TRTC通话状态

使用TRTC提供的监控仪表盘,看到通话正常

五、demo下载

myCustomVideoCaptureDemo

0 人点赞