Android 视频编解码

1,778 阅读12分钟

1. RGB & YUV

这里我们只讲常用到的两种色彩空间。

  • RGB

RGB的颜色模式应该是我们最熟悉的一种,在现在的电子设备中应用广泛。通过R G B三种基础色,可以混合出所有的颜色。

  • YUV

这里着重讲一下YUV,这种色彩空间并不是我们熟悉的。这是一种亮度与色度分离的色彩格式。

早期的电视都是黑白的,即只有亮度值,即Y。有了彩色电视以后,加入了UV两种色度,形成现在的YUV,也叫YCbCr。

Y:亮度,就是灰度值。除了表示亮度信号外,还含有较多的绿色通道量。

U:蓝色通道与亮度的差值。

V:红色通道与亮度的差值。

1.1 采用YUV有什么优势

人眼对亮度敏感,对色度不敏感,因此减少部分UV的数据量,人眼却无法感知出来,这样可以通过压缩UV的分辨率,在不影响观感的前提下,减小视频的体积。

RGB和YUV的换算

  Y = 0.299R + 0.587G + 0.114B 
  U = -0.147R - 0.289G + 0.436B
  V = 0.615R - 0.515G - 0.100B
——————————————————
  R = Y + 1.14V
  G = Y - 0.39U - 0.58V
  B = Y + 2.03U

2. 音频

  1. 音频数据的承载方式最常用的是脉冲编码调制,即PCM。在自然界中,声音是连续不断的,是一种模拟信号,那怎样才能把声音保存下来呢?那就是把声音数字化,即转换为数字信号。我们知道声音是一种波,有自己的振幅和频率,那么要保存声音,就要保存声音在各个时间点上的振幅。而数字信号并不能连续保存所有时间点的振幅,事实上,并不需要保存连续的信号,就可以还原到人耳可接受的声音。根据奈奎斯特采样定理:为了不失真地恢复模拟信号,采样频率应该不小于模拟信号频谱中最高频率的2倍。

    根据以上分析,PCM的采集步骤分为以下步骤:

    模拟信号->采样->量化->编码->数字信号

    img

3. 音视频编码

3.1 视频编码

视频编码格式有很多,比如H26x系列和MPEG系列的编码,这些编码格式都是为了适应时代发展而出现的。

其中,H26x(1/2/3/4/5)系列由ITU(International Telecommunication Union)国际电传视讯联盟主导

MPEG(1/2/3/4)系列由MPEG(Moving Picture Experts Group, ISO旗下的组织)主导。

当然,他们也有联合制定的编码标准,那就是现在主流的编码格式H264,当然还有下一代更先进的压缩编码标准H265。

H264编码简介

H264是目前最主流的视频编码标准,所以我们后续的文章中主要以该编码格式为基准。

H264由ITU和MPEG共同定制,属于MPEG-4第十部分内容。

由于H264编码算法十分复杂,不是一时半刻能够讲清楚的,也不在本人目前的能力范围内,所以这里只简单介绍在日常开发中需要了解到的概念。实际上,视频的编码和解码部分通常由框架(如Android硬解/FFmpeg)完成,一般的开发者并不会接触到。

3.1.1 视频帧

我们已经知道,视频是由一帧一帧画面构成的,但是在视频的数据中,并不是真正按照一帧一帧原始数据保存下来的(如果这样,压缩编码就没有意义了)。

H264会根据一段时间内,画面的变化情况,选取一帧画面作为完整编码,下一帧只记录与上一帧完整数据的差别,是一个动态压缩的过程。在H264中,三种类型的帧数据分别为:

I帧:帧内编码帧。就是一个完整帧。

P帧:前向预测编码帧。是一个非完整帧,通过参考前面的I帧或P帧生成。

B帧:双向预测内插编码帧。参考前后图像帧编码生成。B帧依赖其前最近的一个I帧或P帧及其后最近的一个P帧。

  • 图像组:GOP和关键帧:IDR

全称:Group of picture。指一组变化不大的视频帧。

GOP的第一帧成为关键帧:IDR

IDR都是I帧,可以防止一帧解码出错,导致后面所有帧解码出错的问题。当解码器在解码到IDR的时候,会将之前的参考帧清空,重新开始一个新的序列,这样,即便前面一帧解码出现重大错误,也不会蔓延到后面的数据中。

注:关键帧都是I帧,但是I帧不一定是关键帧

  • DTS与PTS

DTS全称:Decoding Time Stamp。标示读入内存中数据流在什么时候开始送入解码器中进行解码。也就是解码顺序的时间戳。

PTS全称:Presentation Time Stamp。用于标示解码后的视频帧什么时候被显示出来。

在没有B帧的情况下,DTS和PTS的输出顺序是一样的,一旦存在B帧,PTS和DTS则会不同。

3.2 音频编码

3.2.1 音频编码格式

原始的PCM音频数据也是非常大的数据量,因此也需要对其进行压缩编码。和视频编码一样,音频也有许多的编码格式,如:WAV、MP3、WMA、APE、FLAC等等,音乐发烧友应该对这些格式非常熟悉,特别是后两种无损压缩格式。AAC是新一代的音频有损压缩技术,一种高压缩比的音频压缩算法。在MP4视频中的音频数据,大多数时候都是采用AAC压缩格式。

3.2.2 AAC压缩格式

AAC格式主要分为两种:ADIF、ADTS。

ADIF:Audio Data Interchange Format。 音频数据交换格式。这种格式的特征是可以确定的找到这个音频数据的开始,不需进行在音频数据流中间开始的解码,即它的解码必须在明确定义的开始处进行。这种格式常用在磁盘文件中。

ADTS:Audio Data Transport Stream。 音频数据传输流。这种格式的特征是它是一个有同步字的比特流,解码可以在这个流中任何位置开始。它的特征类似于mp3数据流格式。

ADTS可以在任意帧解码,它每一帧都有头信息。ADIF只有一个统一的头,所以必须得到所有的数据后解码。且这两种的header的格式也是不同的,目前一般编码后的都是ADTS格式的音频流。

ADIF数据格式:

image.png

3.3 音视频容器

细心的读者可能已经发现,前面我们介绍的各种音视频的编码格式,没有一种是我们平时使用到的视频格式,比如:mp4、rmvb、avi、mkv、mov...

没错,这些我们熟悉的视频格式,其实是包裹了音视频编码数据的容器,用来把以特定编码标准编码的视频流和音频流混在一起,成为一个文件。

例如:mp4支持H264、H265等视频编码和AAC、MP3等音频编码。

mp4是目前最流行的视频格式,在移动端,一般将视频封装为mp4格式。

4. 硬解码和软解码

在手机或者PC上,都会有CPU、GPU或者解码器等硬件。通常,我们的计算都是在CPU上进行的,也就是我们软件的执行芯片,而GPU主要负责画面的显示(是一种硬件加速)。

  • 软解码,就是指利用CPU的计算能力来解码,通常如果CPU的能力不是很强的时候,一则解码速度会比较慢,二则手机可能出现发热现象。但是,由于使用统一的算法,兼容性会很好。
  • 硬解码,指的是利用手机上专门的解码芯片来加速解码。通常硬解码的解码速度会快很多,但是由于硬解码由各个厂家实现,质量参差不齐,非常容易出现兼容性问题。

4.1 Android平台的硬解码

MediaCodec 是Android 4.1(api 16)版本引入的编解码接口,是所有想在Android上开发音视频的开发人员绕不开的坑,MediaCodec 类可以用来访问底层媒体编解码器,即编码器/解码器的组件。 它是 Android 底层多媒体支持架构的一部分(通常与 MediaExtractor,MediaSync,MediaMuxer,MediaCrypto,MediaDrm,Image,Surface 和 AudioTrack 一起使用)。

4.1.1 数据流

首先,来看看MediaCodec的数据流,也是官方Api文档中的,很多文章都会引用。

image.png

仔细看一下,MediaCodec将数据分为两部分,分别为input(左边)和output(右边),即输入和输出两个数据缓冲区。

input:是给客户端输入需要解码的数据(解码时)或者需要编码的数据(编码时)。

output:是输出解码好(解码时)或者编码好(编码时)的数据给客户端。

MediaCodec内部使用异步的方式对input和output数据进行处理。MediaCodec将处理好input的数据,填充到output缓冲区,交给客户端渲染或处理

注:客户端处理完数据后,必须手动释放output缓冲区,否则将会导致MediaCodec输出缓冲被占用,无法继续解码。

4.1.2 状态

image.png

仔细看看这幅图,整体上分为三个大的状态:Sotpped、Executing、Released。

  • Stoped:包含了3个小状态:Error、Uninitialized、Configured。

首先,新建MediaCodec后,会进入Uninitialized状态; 其次,调用configure方法配置参数后,会进入Configured;

  • Executing:同样包含3个小状态:Flushed、Running、End of Stream。

再次,调用start方法后,MediaCodec进入Flushed状态; 接着,调用dequeueInputBuffer方法后,进入Running状态; 最后,当解码/编码结束时,进入End of Stream(EOF)状态。 这时,一个视频就处理完成了。

  • Released:最后,如果想结束整个数据处理过程,可以调用release方法,释放所有的资源。

那么,Flushed是什么状态呢?

从图中我们可以看到,在Running或者End of Stream状态时,都可以调用flush方法,重新进入Flushed状态。

当我们在解码过程中,进入了End of Stream后,解码器就不再接收输入了,这时候,需要调用flush方法,重新进入接收数据状态。

或者,我们在播放视频过程中,想进行跳播,这时候,我们需要Seek到指定的时间点,这时候,也需要调用flush方法,清除缓冲,否则解码时间戳会混乱。

4.2 解码流程

MediaCodec有两种工作模式,分别为异步模式和同步模式,这里我们使用同步模式,异步模式可以参考官网例子

根据官方的数据流图和状态图,画出一个最基础的解码流程如下:

image.png

经过初始化和配置以后,进入循环解码流程,不断的输入数据,然后获取解码完数据,最后渲染出来,直到所有数据解码完成(End of Stream)。

4.3 开始解码

根据上面的流程图,可以发现,无论音频还是视频,解码流程基本是一致的,不同的地方只在于【配置】、【渲染】两个部分。

5. MediaCodec 详解

image.png

5.1 MediaCodec的同步模式

代码实现如下所示:

    public H264MediaCodecEncoder(int width, int height) {
        //设置MediaFormat的参数
        MediaFormat mediaFormat = MediaFormat.createVideoFormat(MIMETYPE_VIDEO_AVC, width, height);
        mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible);
        mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, width * height * 5);
        mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);//FPS
        mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
​
        try {
            //通过MIMETYPE创建MediaCodec实例
            mMediaCodec = MediaCodec.createEncoderByType(MIMETYPE_VIDEO_AVC);
            //调用configure,传入的MediaCodec.CONFIGURE_FLAG_ENCODE表示编码
            mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            //调用start
            mMediaCodec.start();
​
​
        } catch (Exception e) {
            e.printStackTrace();
        }
​
    }

调用putData向队列中add 原始YUV数据

  public void putData(byte[] buffer) {
        if (yuv420Queue.size() >= 10) {
            yuv420Queue.poll();
        }
        yuv420Queue.add(buffer);
 }
//开启编码
   public void startEncoder() {
       isRunning = true;
       ExecutorService executorService = Executors.newSingleThreadExecutor();
       executorService.execute(new Runnable() {
           @Override
           public void run() {
               byte[] input = null;
               while (isRunning) {
                       
                   if (yuv420Queue.size() > 0) {
                           //从队列中取数据
                       input = yuv420Queue.poll();
                   }
                   if (input != null) {
                       try {
                               //【1】dequeueInputBuffer
                           int inputBufferIndex = mMediaCodec.dequeueInputBuffer(TIMEOUT_S);
                           if (inputBufferIndex >= 0) {
                                //【2】getInputBuffer
                               ByteBuffer inputBuffer = null;
                               if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
                                   inputBuffer = mMediaCodec.getInputBuffer(inputBufferIndex);
                               } else {
                                   inputBuffer = mMediaCodec.getInputBuffers()[inputBufferIndex];
                               }
                               inputBuffer.clear();
                               inputBuffer.put(input);
                               //【3】queueInputBuffer
                               mMediaCodec.queueInputBuffer(inputBufferIndex, 0, input.length, getPTSUs(), 0);
                           }
​
                           MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
                           //【4】dequeueOutputBuffer
                           int outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_S);
                           if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                               MediaFormat newFormat = mMediaCodec.getOutputFormat();
                               if (null != mEncoderCallback) {
                                   mEncoderCallback.outputMediaFormatChanged(H264_ENCODER, newFormat);
                               }
                               if (mMuxer != null) {
                                   if (mMuxerStarted) {
                                       throw new RuntimeException("format changed twice");
                                   }
                                   // now that we have the Magic Goodies, start the muxer
                                   mTrackIndex = mMuxer.addTrack(newFormat);
                                   mMuxer.start();
​
                                   mMuxerStarted = true;
                               }
                           }
​
                           while (outputBufferIndex >= 0) {
                               ByteBuffer outputBuffer = null;
                                //【5】getOutputBuffer
                               if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
                                   outputBuffer = mMediaCodec.getOutputBuffer(outputBufferIndex);
                               } else {
                                   outputBuffer = mMediaCodec.getOutputBuffers()[outputBufferIndex];
                               }
                               if (bufferInfo.flags == MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
                                   bufferInfo.size = 0;
                               }
​
                               if (bufferInfo.size > 0) {
​
                                   // adjust the ByteBuffer values to match BufferInfo (not needed?)
                                   outputBuffer.position(bufferInfo.offset);
                                   outputBuffer.limit(bufferInfo.offset + bufferInfo.size);
                                   // write encoded data to muxer(need to adjust presentationTimeUs.
                                   bufferInfo.presentationTimeUs = getPTSUs();
​
                                   if (mEncoderCallback != null) {
                                       //回调
                                       mEncoderCallback.onEncodeOutput(H264_ENCODER, outputBuffer, bufferInfo);
                                   }
                                   prevOutputPTSUs = bufferInfo.presentationTimeUs;
                                   if (mMuxer != null) {
                                       if (!mMuxerStarted) {
                                           throw new RuntimeException("muxer hasn't started");
                                       }
                                       mMuxer.writeSampleData(mTrackIndex, outputBuffer, bufferInfo);
                                   }
​
                               }
                               mMediaCodec.releaseOutputBuffer(outputBufferIndex, false);
                               bufferInfo = new MediaCodec.BufferInfo();
                               outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_S);
                           }
                       } catch (Throwable throwable) {
                           throwable.printStackTrace();
                       }
                   } else {
                       try {
                           Thread.sleep(500);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
                   }
               }
​
           }
       });
   }

PS编解码这种耗时操作要在单独的线程中完成,我们这里有个缓冲队列ArrayBlockingQueue<byte[]> yuv420Queue = new ArrayBlockingQueue<>(10);,用来接收从Camera回调中传入的byte[] YUV数据,我们又新建立了一个现成来从缓冲队列 yuv420Queue中循环读取数据交给MediaCodec进行编码处理,编码完成的格式是由 mMediaCodec = MediaCodec.createEncoderByType(MIMETYPE_VIDEO_AVC);指定的,这里输出的是目前最为广泛使用的H264格式

5.2 异步模式

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public H264MediaCodecAsyncEncoder(int width, int height) {
    MediaFormat mediaFormat = MediaFormat.createVideoFormat(MIMETYPE_VIDEO_AVC, width, height);
    mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible);
    mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, width * height * 5);
    mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);//FPS
    mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
   try {
        mMediaCodec = MediaCodec.createEncoderByType(MIMETYPE_VIDEO_AVC);
        mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        //设置回调
        mMediaCodec.setCallback(new MediaCodec.Callback() {
            @Override
             /**
             * Called when an input buffer becomes available.
             *
             * @param codec The MediaCodec object.
             * @param index The index of the available input buffer.
             */
            public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {
                Log.i("MFB", "onInputBufferAvailable:" + index);
                byte[] input = null;
                if (isRunning) {
                    if (yuv420Queue.size() > 0) {
                        input = yuv420Queue.poll();
                    }
                    if (input != null) {
                        ByteBuffer inputBuffer = codec.getInputBuffer(index);
                        inputBuffer.clear();
                        inputBuffer.put(input);
                        codec.queueInputBuffer(index, 0, input.length, getPTSUs(), 0);
                    }
                }
            }

            @Override
              /**
             * Called when an output buffer becomes available.
             *
             * @param codec The MediaCodec object.
             * @param index The index of the available output buffer.
             * @param info Info regarding the available output buffer {@link MediaCodec.BufferInfo}.
             */
            public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {
                Log.i("MFB", "onOutputBufferAvailable:" + index);
                ByteBuffer outputBuffer = codec.getOutputBuffer(index);

                if (info.flags == MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
                    info.size = 0;
                }

                if (info.size > 0) {

                    // adjust the ByteBuffer values to match BufferInfo (not needed?)
                    outputBuffer.position(info.offset);
                    outputBuffer.limit(info.offset + info.size);
                    // write encoded data to muxer(need to adjust presentationTimeUs.
                    info.presentationTimeUs = getPTSUs();

                    if (mEncoderCallback != null) {
                        //回调
                        mEncoderCallback.onEncodeOutput(H264_ENCODER, outputBuffer, info);
                    }
                    prevOutputPTSUs = info.presentationTimeUs;
                    if (mMuxer != null) {
                        if (!mMuxerStarted) {
                            throw new RuntimeException("muxer hasn't started");
                        }
                        mMuxer.writeSampleData(mTrackIndex, outputBuffer, info);
                    }

                }
                codec.releaseOutputBuffer(index, false);
            }

            @Override
            public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {

            }

            @Override
                /**
                 * Called when the output format has changed
                 *
                 * @param codec The MediaCodec object.
                 * @param format The new output format.
                 */
            public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
                if (null != mEncoderCallback) {
                    mEncoderCallback.outputMediaFormatChanged(H264_ENCODER, format);
                }
                if (mMuxer != null) {
                    if (mMuxerStarted) {
                        throw new RuntimeException("format changed twice");
                    }
                    // now that we have the Magic Goodies, start the muxer
                    mTrackIndex = mMuxer.addTrack(format);
                    mMuxer.start();

                    mMuxerStarted = true;
                }
            }
        });
        mMediaCodec.start();

    } catch (Exception e) {
        e.printStackTrace();
    }

}

6.总结

MediaCodec 用来音视频的编解码工作(这个过程有的文章也称为硬解),通过MediaCodec.createEncoderByType(MIMETYPE_VIDEO_AVC)函数中的参数来创建音频或者视频的编码器,同理通过MediaCodec.createDecoderByType(MIMETYPE_VIDEO_AVC)创建音频或者视频的解码器。对于音视频编解码中需要的不同参数用MediaFormat来指定