这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战
前两章我们介绍了视频的
RGB、YUV
原始格式,以及如何对摄像头数据进行采集,本章将介绍如何编码YUV
数据
一、视频编码
在前一章,介绍了如何对摄像头数据进行采集,尝试过的朋友可能会发现,采集的NV21
数据,几秒钟下来,文件就上百M
了,我们平时看的MP4
,几秒也才几M
,这是怎么回事呢
这是因为我们存储的是原始格式,是未进行压缩的数据,所以存储下来是特别大
其实不光是存储大,一般原始格式的视频,也是无法播放的,因为播放无法知晓你的一大堆数据是干什么的,播放器一般得知道视频的帧率、分辨率、码率等一些参数,才能正确的解析视频并进行解码播放
所以接下来我们先来编码摄像头采集的数据
常见的编码格式有H26x
,一般Mp4的视频编码格式是H264
,这种编码格式能够大大的减少存储大小
回顾一下上一章的视频采集过程:
- 打开摄像头
- 初始化摄像头
- 设置预览回调
- 开始预览
- 在预览回调中,获取
NV21
数据,并写入文件中 - 停止预览,释放相机
那么对于编码的话,需要在第5步进行修改,加入编码的逻辑,步骤变为:
- 打开摄像头
- 初始化摄像头
- 设置预览回调
- 开始预览
- 在预览回调中,获取
NV21
数据,转换成NV12
数据,传入编码器 - 在编码器输出中,获取编码后的数据,写入文件或者
MediaMuxer
- 停止预览,释放相机
可以注意到,我们再预览回调中,获取的是NV21
的数据,而在传入编码器时,需要的是NV12
的数据(编码器对NV12
格式的数据支持最好),所有中间有个转换的过程,关于这个转换,我们下面会有详细的讲解
还有一个是,在第6步中,我们有两个选择,如果需要使我们编码后的视频能够在任意播放器中播放出来,那么可以使用MediaMuxer
将其封装成Mp4
那么,接下来我们就上述进行具体的实现
二、YUV旋转、镜像
2.1 YUV旋转
在预览回调中,我们获取到了原始的NV21
数据,不过需要的是注意,此时的图像是被旋转过的,及时我们在初始化摄像头时对其角度进行了旋转,预览画面也显示正常,但预览回调还是被选中90
或270
度的,所有我们在将其转换成NV12
时,还需注意旋转角度问题
旋转角度
我们在初始化摄像头的时候,设置了旋转角度
camera.setDisplayOrientation(orientation = CameraUtils.getDisplayOrientation(activity, facing));
因为在NV21
转NV12
时需要用到,所以这里我们用一个变量保存起来
下面我们来进行各种角度的旋转
旋转90度
首先,假设图像是这样的
接着顺时针旋转90度,并转换成NV12的格式,可以得到
我们先来看Y
的数据
NV12
的第一行
取的是NV21
的第一列
的数据,第二行
取的是第二列
的数据,依次类推,就得到了Y
的数据
再来看UV
的数据,因为NV12
和NV21
是YUV420sp
格式的数据,所以需要将UV
看做一个整体进行旋转
NV12
的第一
和第二个
数据取的是U3
和V3
,本来存放顺序是V3
->U3
的,但因为NV12
的UV
顺序和NV21
的UV
顺序刚好相反,所以需要将二者倒过来,依次类推,就得到了上图的数据存储形式
接下来看代码如何实现
/**
* nv21转nv12,并且旋转90度
*/
private static byte[] nv21ToNv12AndRotate90(byte[] inputData, int width, int height) {
if (inputData == null) {
return null;
}
int size = inputData.length;
if (size != (width * height * 3 / 2)) {
return null;
}
byte[] outputData = new byte[size];
int k = 0;
for (int i = 0; i < width; i++) {
for (int j = height - 1; j >= 0; j--) {
outputData[k++] = inputData[width * j + i];
}
}
int start = width * height;
for (int i = 0; i < width; i += 2) {
for (int j = height / 2 - 1; j >= 0; j--) {
outputData[k++] = inputData[start + width * j + i + 1];
outputData[k++] = inputData[start + width * j + i];
}
}
return outputData;
}
代码中,有两个for
循环,第一个是处理Y
数据,而第二个是处理UV
数据
从上面的图示和代码中,我们不难发现旋转其他角度的规律
旋转180度
/**
* nv21转nv12,并且旋转180度
*/
private static byte[] nv21ToNv12AndRotate180(byte[] inputData, int width, int height) {
if (inputData == null) {
return null;
}
int size = inputData.length;
if (size != (width * height * 3 / 2)) {
return null;
}
byte[] outputData = new byte[size];
int k = 0;
for (int i = height - 1; i >= 0; i--) {
for (int j = width - 1; j >= 0; j--) {
outputData[k++] = inputData[width * i + j];
}
}
int start = width * height;
for (int i = height / 2 - 1; i >= 0; i--) {
for (int j = width - 1; j >= 0; j -= 2) {
outputData[k++] = inputData[start + width * i + j];
outputData[k++] = inputData[start + width * i + j - 1];
}
}
return outputData;
}
旋转270度
/**
* nv21转nv12,并且旋转270度
*/
private static byte[] nv21ToNv12AndRotate270(byte[] inputData, int width, int height) {
if (inputData == null) {
return null;
}
int size = inputData.length;
if (size != (width * height * 3 / 2)) {
return null;
}
byte[] outputData = new byte[size];
int k = 0;
for (int i = width - 1; i >= 0; i--) {
for (int j = 0; j < height; j++) {
outputData[k++] = inputData[width * j + i];
}
}
int start = width * height;
for (int i = width - 1; i >= 0; i -= 2) {
for (int j = 0; j < height / 2; j++) {
outputData[k++] = inputData[start + width * j + i];
outputData[k++] = inputData[start + width * j + i - 1];
}
}
return outputData;
}
以上,就包括了相机旋转的所有角度,不过,还有一点需要注意,一般,前摄的图像是相反的,也就是说,我们在处理前摄时,还需要对其进行镜像处理
2.2 YUV镜像
/**
* 镜像处理
*/
private static byte[] yuvMirror(byte[] inputData, int width, int height) {
if (inputData == null) {
return null;
}
int size = inputData.length;
byte[] outputData = new byte[size];
int k = 0;
for (int i = 0; i < height; i++) {
for (int j = width - 1; j >= 0; j--) {
outputData[k++] = inputData[width * i + j];
}
}
int start = width * height;
for (int i = 0; i < height / 2; i++) {
for (int j = width - 1; j >= 0; j -= 2) {
outputData[k++] = inputData[start + width * i + j - 1];
outputData[k++] = inputData[start + width * i + j];
}
}
return outputData;
}
镜像处理比较简单,就是将每行的数据倒序存放,但需要注意UV
是个整体,在处理的时候也不要忘记
我们再来做一个方法,根据传入的数据、尺寸、摄像头类型、旋转角度来做处理
/**
* nv21转换nv12
*/
public static byte[] cameraNv21ToNv12(
byte[] data,
int width,
int height,
int facing,
int orientation) {
byte[] outputData;
Log.d(TAG, "cameraNv21ToNv12: " + orientation + " facing:" + facing);
int rotate = orientation;
int w = width;
int h = height;
if (facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
rotate = 360 - orientation;
}
switch (rotate) {
case 90:
// 经过旋转,宽高互换
w = height;
h = width;
outputData = nv21ToNv12AndRotate90(data, width, height);
break;
case 180:
// 经过旋转,宽高不变
outputData = nv21ToNv12AndRotate180(data, width, height);
break;
case 270:
// 经过旋转,宽高互换
w = height;
h = width;
outputData = nv21ToNv12AndRotate270(data, width, height);
break;
default:
outputData = data;
break;
}
return cameraNv21ToNv12WidthFacing(outputData, w, h, facing);
}
/**
* 通过facing获取nv12数据
*/
private static byte[] cameraNv21ToNv12WidthFacing(
byte[] data,
int width,
int height,
int facing) {
if (facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
// 前摄是镜像的,所有需要做一次镜像处理
return yuvMirror(data, width, height);
}
return data;
}
从上面可以看出,我们只暴露了一个cameraNv21ToNv12()
方法给外部调用
三、MediaCodec编码
在学习了如何对YUV
进行编码前预处理后,下面就开始真正的编码
初始化MediaCodec
private void initMediaCodec() {
int width = this.height;
int height = this.width;
try {
MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar);
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
format.setInteger(MediaFormat.KEY_BIT_RATE,
width * height * 4);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
//设置压缩等级 默认是baseline
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileMain);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel3);
}
}
mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
mediaCodec.setCallback(this);
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
mediaCodec.start();
} catch (IOException e) {
e.printStackTrace();
}
}
对于MediaCodec
的使用,我们在前面音频编码已经有一定了解,不过之前我们都是使用的MediaCodec
的同步编码
方式,下面我们将使用MediaCodec
的异步编码
方式
MediaCodec
在setCallback
后,需要实现几个回调方法
-
onInputBufferAvailable()
在
输入buffer
可用时回调,此时可以在此插入数据
-
onOutputBufferAvailable()
在编码数据完成时回调,此时的数据是已经编码的
H264
数据,可以将此数据写入MediaMuxer
中 -
onError()
发生错误时返回
-
onOutputFormatChanged()
在此处,可以对输出的
MediaFormat
处理,比如调用MediaMuxer.addTrack
方法得到一个TrackId
,用于写入MediaMuxer
在初始化MediaCodec
后,我们可以对MediaMuxer
进行初始化,MediaMuxer
可以帮助我们生成Mp4,方便我们查看视频是否编码正常
private void initMediaMuxer(String path) {
if (TextUtils.isEmpty(path)) {
return;
}
File file = new File(path);
if (file.exists()) {
file.delete();
}
try {
mediaMuxer = new MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
} catch (IOException e) {
e.printStackTrace();
mediaMuxer = null;
}
}
从上面的描述来看,我们需要将摄像头和编码的操作放入子线程,而摄像头的预览数据回调和编码插入取出数据又都是异步的,那么我们应该怎么办呢
其实我们可以使用一个队列,存放预览回调的数据,然后在编码的时候,将队列的数据取出插入编码器,这样就可以达到想要的效果
对于队列,我们可以使用LinkedBlockingQueue
,它是一个线程安全的队列
private final BlockingQueue<byte[]> queue = new LinkedBlockingQueue<>(10);
预览数据处理
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
if (this.data == null) {
this.data = new byte[width * height * 3 / 2];
}
camera.addCallbackBuffer(this.data);
queue.offer(YuvUtils.cameraNv21ToNv12(this.data, width, height, facing, orientation));
}
在预览回调中,我们调用了之前所介绍的方法cameraNv21ToNv12
将nv21
数据转换成nv12
数据,并将其传入队列中
插入编码器
@Override
public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {
ByteBuffer buffer = codec.getInputBuffer(index);
buffer.clear();
int size = 0;
byte[] data = queue.poll();
if (data != null) {
buffer.put(data);
size = data.length;
}
codec.queueInputBuffer(index, 0, size, System.nanoTime() / 1000, 0);
}
在onInputBufferAvailable()
回调中,我们取出数据,并插入编码器中,注意,插入时需要传入时间戳,我们传入System.nanoTime() / 1000
即可
添加视频轨道
在写入MediaMuxer
前,我们还得先将视频的输出格式写入MediaMuxer
,这样才能回去一个TrackId
供我们写入数据
@Override
public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
if (trackIndex != -1) {
return;
}
if (mediaMuxer == null) {
return;
}
trackIndex = mediaMuxer.addTrack(format);
mediaMuxer.start();
}
获取编码数据,写入MediaMuxer
@Override
public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {
ByteBuffer buffer = codec.getOutputBuffer(index);
if (buffer != null && info.size > 0) {
if (mediaMuxer != null && trackIndex != -1) {
mediaMuxer.writeSampleData(trackIndex, buffer, info);
}
buffer.clear();
}
codec.releaseOutputBuffer(index, false);
}
这样,我们就完成了整个视频采集到编码,最后到输出Mp4
的过程
最后,别忘了释放相关资源
private void closeCamera() {
if (camera == null) {
return;
}
camera.stopPreview();
camera.release();
camera = null;
}
private void stopMediaMuxer() {
if (mediaMuxer == null) {
return;
}
mediaMuxer.stop();
mediaMuxer.release();
mediaMuxer = null;
}
private void stopMediaCodec() {
if (mediaCodec == null) {
return;
}
mediaCodec.stop();
mediaCodec.release();
mediaCodec = null;
}