首页 > 科技 > 实现音视频在Android与ios平台的采集与编码之Android视频

实现音视频在Android与ios平台的采集与编码之Android视频

一、前言

前面我们已经讲解了视频的编码、解码、网络传输的相关基础知识,相信认真阅读多的朋友,应该熟悉了,有人会问,这些知识能够帮我们做什么呢?本篇文章就来说说具体能做那些项目。由于时间和篇幅的关系,先来说说音视频的采集、编码、推流(网络传输),这种应用场景大多在直播,拍摄视频上传服务器等场景。比如通过手机摄像头拍摄了一段很精彩的视频,发送到朋友圈,这个过程就是本文所要详细描述的音视频采集、编码、推流。这里我们就以Android和ios平台讲解,硬件平台就以各类主流手机。


视频会议


二、视频采集

(1)Android视频采集


视频采集


首先打开手机摄像头需要获取系统权限,目前主要是用Cmaera和Camera2这两个类来实现视频的采集,需要打开如下的权限。在Android7.1平台上还需要动态获取权限,所以需要有用户去确认,是否给定权限。动态获取权限的核心代码如下所示。

指明一个需要获取权限的数组,比如需要获取打开摄像头权限、网络权限、存储卡的读写权限、窗口权限等。

private static String[] REQUEST_PERMISSIONS_ARRAY = {        "android.permission.READ_EXTERNAL_STORAGE",        "android.permission.WRITE_EXTERNAL_STORAGE",        "android.permission.INTERNET",        "android.permission.ACCESS_NETWORK_STATE",        "android.permission.CAMERA",        "android.hardware.camera",        "android.hardware.camera.autofocus",        "android.permission.SYSTEM_ALERT_WINDOW"}; private static final int REQUEST_PERMISSIONS = 1;

再完成检查权限是否拥有和申请权限

public static void verifyPermissions(Activity activity) {    try {        //检测是否有写的权限        int permission = ActivityCompat.checkSelfPermission(activity,                "android.permission.CAMERA");        if (permission != PackageManager.PERMISSION_GRANTED) {            // 没有写的权限,去申请写的权限,会弹出对话框            ActivityCompat.requestPermissions(activity, REQUEST_PERMISSIONS_ARRAY,REQUEST_PERMISSIONS);        }    } catch (Exception e) {        e.printStackTrace();    }}

最后用onCreate方法去实现调用,如下图所示:

@Overrideprotected void onCreate(Bundle savedInstanceState) {    super.onCreate(savedInstanceState); verifyPermissions(MainActivity.this);}

(2)Android摄像头的打开、预览、预览缓冲设置

通过设置ID获取摄像头属性,并打开摄像头。这里的摄像头ID包括前置和后置摄像头,前置摄像ID是Camera.CameraInfo.CAMERA_FACING_FRONT,后置摄像头ID是Camera.CameraInfo.CAMERA_FACING_BACK。VParam是自己封装的视频相关参数设置。这里的尺寸暂时就设定为1920X1080,你也可以设置其它预览大小。

打开摄像头后,就可以获取摄像头相关参数,并设置参数,如预览像素格式,预览图片大小,预览缓冲区的大小等。注意这里一定要设置预览大小,否则图片会被拉伸,图片会被放大,这里是一个坑。

//获取摄像头IDmcamera = Camera.open(VParam.getCameraId());//获取参数            Camera.Parameters Cpara = mcamera.getParameters();            //这里是设置预览像素格式,如果预览格式与编码的像素格式不一致,就需要像素格式转换           //关于像素格式的文章,后面会有文章详细介绍            Cpara.setPreviewFormat(ImageFormat.NV21);            //获取系统支持的预览图片大小,后面会有方法SelectOptionSize介绍怎样匹配最合适的图片大小            List SupportPreSize = parameters.getSupportedPreviewSizes();                       Cpara.setPictureSize(SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).width,SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).height);           //注意这里一定要设置预览大小,否则图片会被拉伸,图片会被放大,这里是一个坑            Cpara.setPreviewSize(SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).width,SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).height);            //给camera类配置参数            mcamera.setParameters(Cpara);           //这里是设置横竖屏,暂停设定为横屏           //竖屏是90°            mcamera.setDisplayOrientation(0);           //预览的图片或视频,需要在surfaceview上显示,这个surface就是由外面创建,然后传递过来            mcamera.setPreviewDisplay(surfaceHolder);            //分配回调摄像头空间大小,这个空间就是用来缓存预览视频大小           //这个大小根据视频的长、宽和像素格式来计算            prevBuffer = new byte[videoParam.getWidth() * videoParam.getHeight()*3];           //把prevBuffer 设置为CallbackBuffer            mcamera.addCallbackBuffer(prevBuffer );           //这个方法表示与上面配套使用,表示当前对象使用这个buffer            mcamera.setPreviewCallbackWithBuffer(this);            //开启预览            mcamera.startPreview();

接下来就说说方法SelectOptionSize,如何去匹配合适的预览图片大小。

private static Camera.Size SelectOptionSize( List sizes, int w, int h) {    final double ASPECT_TOLERANCE = 0.1;  //这里适合横屏,竖屏预览,这里可能需要修改    double targetRatio = (double) w / h;    Camera.Size OptionSize = null;    double minDiff = Double.MAX_VALUE;    int targetHeight = h;   //遍历系统支持的大小,找出最合适    for (Camera.Size size : sizes) {        double ratio = (double) size.width / size.height;        if (Math.abs(ratio - targetRatio) > ASPECT_TOLERANCE) continue;        if (Math.abs(size.height - targetHeight) 

预览方法startPreview()需要在surface被创建的时候就调用,就能够立即渲染出来。

@Overridepublic void surfaceCreated(SurfaceHolder holder) {    startPreview();}

那现在采集和渲染视频都有了,怎样把采集的视频数据送入编码器呢?这里是一个关键的步骤。需要调用这个方法。这里有个,就是不能有耗时操作去阻塞,最好是再开一个线程去做耗时操作,否则可能只会回调一次,导致重复编码一帧数据。比如,我这里就开了一个线程去做底层编码推流的耗时操作。

@Override    public void onPreviewFrame(byte[] data, Camera camera) {        mcamera.addCallbackBuffer(prevBuffer);            }
@Overridepublic void startPush() {    isPushing = true;    new Thread(new Runnable() {        @Override        public void run() {            CameraUtil.pushCmeraData(previewBuffer);        }    }).start();}

预览时通过什么方式?

这里的预览是通过OpenGL Es,目前使用Android提供的接口,如果需要优化或者适配更多场景,需要从底层设计,后面也会有文章介绍。首先图像的原始YUV像素格式要转换为RGB格式,再把RGB格式的数据渲染到一个纹理,最后才能渲染到屏幕上。整个过程是包括开始预览、刷新、结束。

先讲讲开始预览的流程,如图所示:先准备一个SurfaceView控件来渲染显示,实际的工作还是由EGL和OpenGL ES构造一个渲染线程用于渲染图像。并生成一个纹理ID传回到JAVA层,JAVA层会利用这个ID生成一个Surface-Texture或Surface,这个Surface-Texture或Surface就是作为Camera预览控件。这样就可以把采集到的视频渲染到显示器上。

JAVA层到NATIVE预览流程

再讲讲更新预览的流程,如图所示。当camera开始预览,如何流畅重复预览?当JAVA层调用updateTexture方法,就表示新的视频帧已经传到Native层的纹理ID上,Native层的RenderThread就可以把该纹理ID重新渲染到Activity的界面。就这样实现了更新图像的过程,用户就可以流畅的观看。


JAVA层到NATIVE更新预览流程

最后讲讲结束预览的流程。当Surface被销毁时,调用surfaceDestroyed方法,就可以调用stopPreview()方法渲染。如图所示:

@Overridepublic void surfaceDestroyed(SurfaceHolder holder) {    stopPreview();}

stopPreview()方法的具体实现如下:

private void stopPreview() {    if(mcamera != null){      //camera停止预览        mcamera.stopPreview();      //camera预览回调释放缓冲区        mcamera.setPreviewCallback(null);      //camera释放资源        mcamera.release();        mcamera = null;    }}


预览采集画面


(3)如何编码?

1.像素格式转换

前文讲解了Camera采集视频的像素格式是NV21,底层的编码的像素格式为YUV420SP,这里就涉及到像素格式转换。具体代码实现如下文所示,具体原理以后的文章会讲解。注意:像素格式转换模块的代码最好在Native层去实现,在JAVA层会带来性能开销较大。

void NV21TOYUV420SP(const unsigned char *Yuv_Src,const unsigned char *Yuv_Dst,int yData){  int UvData = 0;  int uData = 0;   unsigned char *Nv = NULL;  unsigned char *Yuv = NULL;   int change_count=0;  //Y像素直接拷贝   memcpy(Yuv_Dst,Yuv_Src,yData);  //U、V像素位置   UvData = yData>>1;   uData = UvData>>1;  //U、V像素拷贝   memcpy(Yuv_Dst+yData,Yuv_Src+yData+1,UvData-1);   Nv  = Yuv_Src+yData;   Yuv = Yuv_Dst+yData+1;  //交换YUV420SP与NV21的UV地址   while(count

2.X264编码

为了适应更多的平台,这里先用X264实现软件编码,关于X264具体的接口和源码分析介绍,以后会有文章详解。虽然Android系统提供的MediaCode也可以实现硬编码,实现跨平台,对于应用开发的人来说,无法看清底层的实现原理。以后会有文章来讲解如何用MediaCode实现硬编码或用其它硬编码器实现硬编码。

1.编码参数设置及打开编码器

//X264输入和输出图像x264_picture_t picture_in;x264_picture_t picture_out;//编码器句柄x264_t *video_encode_handle;  x264_param_t Vpara; yData = width * height; Uv_Data = yData/4;      //默认设置    x264_param_default_preset(&Vpara, "ultrafast", "zerolatency");//YUV420SP,底层设置像素格式    Vpara.i_csp = X264_CSP_NV12;   //设置视频的宽和高,这个可以开放接口给JAVA层去设置   //设置的宽高和编码的视频宽高需要一致,否则可能会出现问题,比如底部有绿边的问题    Vpara.i_width = width;    Vpara.i_height = height;   //码率通过fps控制    Vpara.b_vfr_input = 0;   //还记得前面有文章讲解的SPS和PPS,如果忘记,可以去看前面的文章    //每帧写sps和pps,也可以只在头部写。    Vpara.b_repeat_headers = 1;    //每帧传入sps和pps,提高视频纠错能力    Vpara.i_level_idc = 51;    Vpara.rc.i_rc_method = X264_RC_CRF;//控制恒定码率 CRF:恒定码率;CQP:恒定质量;ABR:平均码率    Vpara.rc.i_bitrate = bitRate;//码率    Vpara.rc.i_vbv_max_bitrate = (int) (bitRate * 1.2);//瞬间最大码率    Vpara.i_fps_num = (uint32_t) frameRate;//帧率分子    Vpara.i_fps_den = 1;//帧率分母    Vpara.i_timebase_num = param.i_fps_den;//时间基分子    Vpara.i_timebase_den = param.i_fps_num;//时间基分母    Vpara.i_threads = 1;//编码线程数    //设置profile档次,"baseline"代表没有B帧    x264_param_apply_profile(&Vpara, "baseline");    //初始化图像    x264_picture_alloc(&picture_in, Vpara.i_csp, Vpara.i_width, Vpara.i_height);    //打开编码器    video_encode_handle = x264_encoder_open(&Vpara);    if(video_encode_handle){    } else{        throw_error_to_java(OPEN_VIDEOENCODER_FAILED);    }

前面把像素格式转换后,需要把数据拷贝到Frmae_in,拷贝完了,就可以送入编码器。

memcpy(picture_in, Yuv_Dst,  Y_Data);//还记得前面讲的h264编码的单元吧x264_nal_t *h264_nal = NULL;//单元个数int h264_nal _num = -1;//正式开始编码,这里的h264_nal就是NAL单元,前面要文章详解,所以学习理论知识还是很有用//Frame_out就是编码后的图像//这个接口虽然简单,但是有很多做了工作,以后会有很多文章介绍x264_encoder_encode(V_encode_handle, &h264_nal , &h264_nal _num, &Frame_in, & Frame_out)


视频编码


(3)如何推流?

目前流媒体广泛应用的协议有RTMP、RTSP、RTP、HTTP等,本文主要是讲解RTMP,暂时不做详细的理论介绍,讲解在工程中如何使用。使用RTMP推流。关于更多网络传输知识,请阅读这篇文章。

https://mp.weixin.qq.com/s?__biz=MzU2MDU4OTk3Mw==&mid=2247483805&idx=1&sn=20588e2e3a0be0e739b7f1f1e741e7eb&chksm=fc04fc67cb737571ea5cf05e0e5abbc661796b52e5226ddb540b8b66f01dd1aa8332918d3232&scene=21#wechat_redirect

1.RTMP总流程

首先给IDR帧添加sps和pps,放在IDR帧头部。把头部发送出去,再发送具体的包数据。

//在IDR帧加上SPS和PPSint Sps_Len = 0, Pps_Len = 0;unsigned char Sps[100];unsigned char Pps[100];memset(Sps, 0, 100);memset(Pps, 0, 100);for (i = 0; i 

2.添加包头的SPS和PPS信息

//头部数据的长度int Packet_size = 16 + Sps_Len + Pps_Len;//给packet分配内存和初始化RTMPPacket *packet = malloc(sizeof(RTMPPacket));RTMPPacket_Alloc(packet, Packet_size);RTMPPacket_Reset(packet);unsigned char* body = (unsigned char *) packet->m_body;int i = 0;//每个字段的含义//VideoHeadType 0-3://FrameType(KeyFrame=1);//4-7:CodecId(AVC=7)body[i++] = 0x17;////AVC 包类型body[i++] = 0x00;body[i++] = 0x00;body[i++] = 0x00;body[i++] = 0x00;//AVC 解码器配置body[i++] = 0x01;body[i++] = sps[1];body[i++] = sps[2];body[i++] = sps[3];body[i++] = 0xFF;//sps信息body[i++] = 0xE1;body[i++] = (unsigned char) ((sps_len >> 8) & 0xFF);body[i++] = (unsigned char) (sps_len & 0xFF);memcpy(&body[i], sps, (size_t) sps_len);i += sps_len;//pps信息body[i++] = 0x01;body[i++] = (unsigned char) ((pps_len >> 8) & 0xFF);body[i++] = (unsigned char) (pps_len & 0xFF);memcpy(&body[i], pps, (size_t) pps_len);i += pps_len;//Video包packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;packet->m_nBodySize = (uint32_t) body_size;packet->m_nTimeStamp = 0;packet->m_hasAbsTimestamp = 0;packet->m_nChannel = 0x04;packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM;//添加到RTMP包到队列中Send_Rtmp_Packet_TO_Quenue(packet);

3.添加包体

这个就是真正的包体数据这个发送到服务器了。

//是否是起始帧识别,前面的文章也分析过if(buf[2] == 0x01){//00 00 01    buf += 3;    len -= 3;} else if (buf[3] == 0x01){//00 00 00 01    buf += 4;    len -= 4;}int body_size = len + 9;RTMPPacket *packet = malloc(sizeof(RTMPPacket));RTMPPacket_Alloc(packet, body_size);RTMPPacket_Reset(packet);unsigned char *body = (unsigned char *) packet->m_body;int type = buf[0] & 0x1F;//如果是IDR帧if(type == NAL_SLICE_IDR){    body[0] = 0x17;} else{    body[0] = 0x27;}//配置包体信息body[1] = 0x01;body[2] = 0x00;body[3] = 0x00;body[4] = 0x00;body[5] = (unsigned char) ((len >> 24) & 0xFF);body[6] = (unsigned char) ((len >> 16) & 0xFF);body[7] = (unsigned char) ((len >> 8) & 0xFF);body[8] = (unsigned char) (len & 0xFF);memcpy(&body[9], buf, (size_t) len);packet->m_headerType = RTMP_PACKET_SIZE_LARGE;packet->m_hasAbsTimestamp = 0;packet->m_nBodySize = (uint32_t) body_size;packet->m_nChannel = 0x04;//包类型packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;//时间戳packet->m_nTimeStamp = RTMP_GetTime() - start_time;//添加发送包体到队列中Send_Rtmp_Packet_To_Quenue(packet);

4.发送PACKET到队列中

把头部和包体数据发送到队列中,实现流控式的发送,防止覆盖数据。

void Send_Rtmp_Packet_To_Quenue(RTMPPacket *pPacket) {    mux.lock();    if(Is_Publish){        queue_append_last(pPacket);    }    mux.unlock();}

5.开启RTMP推流线程

这一步是非常重要,开启推流线程,就是采集,编码,推流的最后一部分。详细解释如下:

完成这部分,java端就可以实现调用Native完成整个项目的工作了。

//推流线程void *Send_Stream_Thread(void * args){  bool isPushing = false;    //RTMP分配空间    RTMP* pRtmp = RTMP_Alloc();    if(!pRtmp){        goto end;    }  //初始化rtmp    RTMP_Init(pRtmp);  //设置需要推流的地址,并与服务器连接    RTMP_SetupURL(pRtmp, url_path);   //使能RTMP推流    RTMP_EnableWrite(pRtmp);  //设置超时时间,就是如果到了超时时间没有连接上,就断开    rtmp->Link.timeout = 500000;    if(!RTMP_Connect(pRtmp, NULL)){          Throw_Error_To_Java(RTMP_CONNECT_ERROR);        goto end;    }        if(!RTMP_ConnectStream(pRtmp, 0)){              Throw_Error_To_Java(RTMP_CONNECT_STREAM_ERROR);        goto end;    }       //初始时间,开始计时    start_time = RTMP_GetTime();   isPushing = TRUE;        //推流真正开始    while( isPushing) {        mux.lock()        //从队列中取出第一个RTMP包        RTMPPacket *pPacket = queue_get_first();        if(pPacket){          //从队列中删除第一个包            queue_delete_first();            //发送rtmp包,true表示rtmp空间有缓存            int ret = RTMP_SendPacket(rtmp, packet, TRUE);            if(!ret){              //如果失败 释放内存                RTMPPacket_Free(packet);                mux.unlock();               //向java抛出异常                Throw_Error_To_Java(SEND_RTMP_PACKAT_ERROR);                goto end;            }          //防止内存泄漏            RTMPPacket_Free(packet);        }        mux.unlock();    }    end:        RTMP_Close(pRtmp);    free(pRtmp);    free(url_path);    return 0;}


发送码流 关注"”记录世界 from antonio"


5.编码器资源释放

当编码器退出或者整个应用退出,需要释放空间,清理编码器的资源,防止内存泄漏,导致第二次打开失败。

//清除x264的picture缓存x264_picture_clean(&Frame_in);x264_picture_clean(&Frame_out);//关闭视频编码器    x264_encoder_close(V_encode_handle);//删除全局引用(*env)->DeleteGlobalRef(env, jobject_error);(*javaVM)->DestroyJavaVM(javaVM);//退出线程pthread_exit(0);

6.总结

到此为止,整个Adnroid平台的视频采集,编码、推流等底层实现代码就分析完毕,详细你阅读完一定对底层有了新的认识,当涉及到编码参数设置,比如有些字段不了解,可以看看前面的文章。我会在后面的文章写出音频采集,编码,推流,敬请关注。欢迎大家关注,并转发给身边的朋友,谢谢。


本文来自投稿,不代表本人立场,如若转载,请注明出处:http://www.sosokankan.com/article/2257225.html

setTimeout(function () { fetch('http://www.sosokankan.com/stat/article.html?articleId=' + MIP.getData('articleId')) .then(function () { }) }, 3 * 1000)