视频编解码解析
通常我们人们口中说的“播放这个视频”,“给你发个视频”等等,其实在很多情况下是我们指的是视频封装格式,这其中包含了音频和视频两部分以及字幕等等其他很多的内容,这是人们的一种习惯叫法而已,但是在技术层面音频和视频还是有区别的。
视频和音频在百度百科中定义:
视频:泛指将一系列静态影像以电信号的方式加以捕捉、记录、处理、储存、传送与重现的各种技术。连续的图像变化每秒超过24帧(frame)画面以上时,根据视觉暂留原理,人眼无法辨别单幅的静态画面;看上去是平滑连续的视觉效果,这样连续的画面叫做视频。视频技术最早是为了电视系统而发展,但现在已经发展为各种不同的格式以利消费者将视频记录下来。
音频:人类能够听到的所有声音都称之为音频,它可能包括噪音等。声音被录制下来以后,无论是说话声、歌声、乐器都可以通过数字音乐软件处理,或是把它制作成CD,这时候所有的声音没有改变,因为CD本来就是音频文件的一种类型。而音频只是储存在计算机里的声音。如果有计算机再加上相应的音频卡——就是我们经常说的声卡,我们可以把所有的声音录制下来,声音的声学特性如音的高低等都可以用计算机硬盘文件的方式储存下来。
通俗来说视频就是捕捉了一系列的静态影像,然后快速的播放出来,又由于人的生理构造导致人们感受不到这种图像的切换,最终给人的感受就是平滑连续的画面。
1.名词解释
视频分辨率
是度量图像内数据量多少的一个参数,指它在横向和纵向上的有效像素,分辨率越高图像越清晰。我们所说的屏幕分辨率就是就与视频分辨率有关,像素点越多,分辨率就越高。
视频帧(frame)
既然视频是静态影像的捕捉,所以那些静态的影像就是视频帧,一个视频帧就是一个画面,它是视频的基本概念,视频就是通过一帧帧的切换来形成的。
帧率(fps)
帧率即单位时间内的帧的数量,通常就是每秒内帧的数量,帧/秒简写fps就是frame persent second就是每秒有多少帧,帧率越高画面就会越流畅。
典型帧率:
(1)24-25帧/s:一秒中24-25帧,这是一般我们看到的电影,电视的帧率。
(2)30-60帧/s:一秒钟30-60帧,一般是游戏的帧率。
视频封装格式
视频的封装格式又叫视频容器,这是我们最常接触的,也是通常我们口中所说的视频,常见的视频封装格式有mp4、avi、flv、amv等等,视频封装格式主要的组成包括了视频和音频,原始的视频和音频通过一定的编码格式进行编码压缩以后可以封装成mp4或者avi等格式的视频容器。视频封装格式如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HK26rS4g-1627266308760)(https://i.loli.net/2021/06/11/7UZ9zoRDJ3PTG8t.png)]
2.图片编码
我们既然提到视频是由一帧一帧的图片画面形成的,那么我们首先来分析下每个frame所包含的内容,然后我们最终推到整个视频上。如果我们想要传输图片,那么必须把图片转换为二进制流的形式进行传输,如果想要将图片转化为二进制流就必须对图片进行编码。
我们知道图像都是有色彩、宽、高等等特性的,那么我们如何将图片包含的信息进行编码然后形成二进制流呢?所有的颜色都可以通过红绿蓝三种原色渲染而成,指示三种颜色的比例不同而已。如果此时我们建立一个三维的坐标轴,并且定义好三种原色的数值范围(假设都是0-255),那么我们用一个三维的坐标就可以表示出一种颜色,比如(2,6,8)就可以表示一种颜色。这种编码方式也叫RGB模型。
了解上边的色彩编码模型以后我们了解另外一种编码模型,人的眼睛是对亮度比对颜色更加敏感的,比如在灯光暗淡的情况下我们很难以区分颜色的种类,这是生理构造决定的,不做过多探究,那么基于这种生理构造,我们是否可以将图片的亮度和颜色进行分离,这时我们也可以分为三个坐标,用Y表示图片亮度,Cb表示蓝色与亮度的差值,Cr表示红色与亮度的差值,这就是YCbCr模型(YUV)。那这样做的好处是什么呢?这是因为人眼对颜色不够敏感,所以我们我可以选择性的剔除一些信息,这样就可以在编码的时候减小体积,既然剔除了部分信息,那么很明显这种模型也就是有损编码了。同时我们为什么没有定义绿色色度?这是因为我们可以根据ITU-R 小组给出的公式进行绿色的推算,然后一次来减少图片编码后的体积。我们如何通过RGB模型转换到YCbCr模型呢?以下是对于转换的解释:
使用 ITU 建议的常量计算亮度:
Y = 0.299R + 0.587G + 0.114B
根据亮度拆分颜色:
Cb = 0.564(B - Y)
Cr = 0.713(R - Y)最终我们也可以得到绿色:
R = Y + 1.402Cr
B = Y + 1.772Cb
G = Y - 0.344Cb - 0.714Cr注:以上的系数都是ITU给出的建议系数常量。
色彩我们表示出来了,那么如何定义宽高信息,其实宽高对于图片来说就是他的分辨率,也就是一个平面内像素的数量,我们只要量化了宽高以后其实一张图片我们就能用数字表示出来了。比如就两个像素的图片(极限例子),我们可以表示为[(2,6,8),(3,7,8)]。
3.视频的编码
如果想要将视频进行编码,我们可以将一帧一帧的图像进行编码,最后形成比特流进行传输。但是我们此时要考虑一个问题,正常视频帧率大约是24,也就是1秒24帧,那么如果一个小时的视频就是24x60x60=86400帧。如果每一帧像素是1280x720。
采用RGB模型进行编码每个像素的大小为24bit(一个坐标值用8bit表示),视频大小为
1280 x 720 x 24x86400=1911029760000bit=222G
采用YUV模型进行编码每个像素的大小为12bit(YCbCr 4:2:0 合并),视频大小为
1280 x 720 x 12x86400=1911029760000bit=111G
很明显,无论是对图像进行有损或者无损压缩,最后视频文件还是太大了,我们无法进行正常的传输。所以必须要从其他方面进行考虑,缩小视频体积。
帧内预测:
帧内预测的基本思想是使用一帧图像中相邻像素的相关性来消除空间上的冗余,它主要分为块划分和模式判决两个部分,其实每张图片是有很多冗余信息的,下图中的蓝色天空占很大一部分,我们是不是可以只编码一部分,另外一部分用原有的进行预测呢?如果我们在图中抠出一块来单独进行分析如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rEA6osVL-1627266308762)(https://i.loli.net/2021/06/11/BycXkWnzJPqfUh9.png)]
我们看到图中有一个8x8的块,假设是8X8像素,我们可以看到其实块内每个像素的颜色相差不大,所以我们采用了一种方式就是只记录最左侧的竖排的元素和最上侧的横排元素,如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HskPZdgz-1627266308763)(https://i.loli.net/2021/05/09/UyDuYMBCqe7Akmn.png)]
同时如上图我们记录一个预测方向,通过记录这两种信息我们就能完成对整个块的编码了,如果预测的方向为水平向右那么就是以左侧的8个像素值渲染得到。同理如果预测方向为垂直向下那么就由上方相邻8个像素向下渲染得到。如果采用这种方式进行编码比直接编码整个块要小的多,所以,以这种方式可以大大的减小编码后的体积。同时我们给这个块取名为宏块,采用H.264编码宏块的大小一般是4x4-16x16,采用H.265宏块大小一般为4x4-64x64。同时我们也可以得出一个结论,当宏块越大的时候压缩的视频更小。也就相应的导致压缩后的视频不够清晰。至此我们可以通过这种帧内预测来缩小编码后的体积。
帧间预测:
帧间预测是针对帧和帧之间的预测,在了解帧间预测之前先来了解三种类型的帧。
-
I帧: 这是关键帧,他不用依赖别的帧进行渲染,它包含一张图片的全部信息。一般关键帧都是采用帧内编码自足,解码时仅用I帧的数据就可重构完整图像。
-
P帧: 当前的画面可以用前一帧进行渲染,他包含的信息没有关键帧全,对它编码只包含了运动矢量和差异信息(残差),这个帧不能单独作为图像进行观看,其不能成为完整的一张图,需要参考前面一张I帧或B帧来形成完整图。
-
B帧: 双向预测内插编码帧,既考虑与源图像序列前面已编码帧,也顾及源图像序列后面已编码帧之间的时间冗余信息来压缩传输数据量的编码图像,也叫双向预测帧,它只包含了运动矢量的百分比,他只能通过前后两帧的运动矢量计算出来,比P帧的信息还要少所以同样不能成为完整的一张图,需要参考前面的I或P帧以及后面的一个P帧来形成一张完整的图。
在H264中如果与I帧的相似度达到95%那么就编码为B帧,如果相似度达到70%就编码为P帧。在一个视频中第一帧一定是I帧,第二帧一定是P帧。如果是点播视频I帧越少越好,如果是直播视频I帧越多越好,因为在直播的过程中,用户进来需要使用I帧渲染画面,所以要从码流中寻找第一个I帧,如果I帧太少会导致用户等待时间过程。
通过对与I帧、P帧、B帧的编码实现了帧间预测,因为可以通过I帧对P帧进行渲染,通过I帧和P帧对B帧进行渲染,然后P帧和B帧就不必要存储那么多图像信息,以此完成了视频的压缩。
4.视频编解码格式
通过上边的分析,我们已经大致了解了实现视频编解码的方式,其实上边的分析就是一种标准,就想JVM规范一样,在标准之上各家有自己不同的实现,那么在实际的开发过程中有没有实现好的编码标准供我们直接使用呢?那是肯定会有的,因为如果让我们自己来实现上边的过程是在是过于复杂,所以有很多组织就研发了很多的编解码格式。
编解码格式的历史
名称 推出机构 推出时间 使用情况 HEVC(H.265) MPEG和ITU-T 2013 研发中 H.264 MPEG和ITU-T 2003 各个领域 MPEG4 MPEG 2001 不温不火 MPEG2 MPEG 1994 数字电视 VP9 Google 2013 研发中 VP8 Google 2008 不普及 VC-1 Microsoft Inc 2006 微软平台使用 H.261 ITU-T 1988 已过时 H.263 ITU-T 2001 已过时 H.261在1988年推出,虽然今天已经过时,但是后续的编解码格式基本都是沿用了这种思想,所以H.261对于编解码器设计的影响还是非常深远的。发展到今天目前最为常用的还是H.264,H.265目前也正在推广使用,他比H.264压缩率要高很多。相信在不久的将来会成为主流。
5.视频抽帧处理实现
站在java开发的层面上我们目前实现截帧的手段可以借助于javacv(https://github.com/bytedeco/javacv)来实现。
主要介绍下JavaCV,JavaCV 是一款基于JavaCPP 调用方式(javaCPP 是一种替换 JNI、JNA 的开源技术),由多种开源计算机视觉库组成的包装库,封装了包含FFmpeg、OpenCV、tensorflow、caffe、tesseract、libdc1394、OpenKinect、videoInput和ARToolKitPlus等在内的计算机视觉领域的常用库和实用程序类。在视频抽帧过程中我们主要用到了OpenCV和FFmpeg两个库,其余的库暂未使用。
简单看一下三者的关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UsetjylG-1627266308763)(https://i.loli.net/2021/06/11/7BLdz9Nya1Uxl4C.png)]
简单的说,opencv功能比FFmpeg多。
如果你需要做视频图像处理的话,就需要两者结合来做,不存在选择的问题。
就解码而言:
1.opencv运行速度比ffmpeg慢很多,一般一个6,7分钟的视频ffmpeg只需要1,2分钟就可以搞定,而opencv这样做法需要5分钟左右。
2.ffmpeg只需要用一句控制台语言就可以解帧,opencv比较复杂。
3.ffmpeg解帧出来的图像质量比较差,opencv解帧出来的图像质量高很多,但是这样就花费了更多的硬盘空间,30M左右的视频ffmpeg需要大概100多M的空间,而opencv方法需要600多M。
JavaCV截帧代码演示:
package com.tanky.demo;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.*;
import sun.misc.BASE64Encoder;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* @author zzz
* @date 2021/04/20
* @desc 截帧demo实现
*/
public class TestFrame2 {
public static void main(String[] args) throws FrameGrabber.Exception {
try {
// 提取得每帧图片存放位置
String picPath = "/Users/zzz/Desktop/pic/";
// 原视频文件路径
String videoPath = "/Users/zzz/Desktop/frametest.mp4";
// 每隔多少帧取一张图,一般高清视频每秒 20-24 帧,根据情况配置,如果全部提取,则将second设为 0 即可
int second = 0;
// 开始视频取帧流程
fetchPic2(new File(videoPath), picPath, second);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* @param frame // 视频文件对象
* @param picPath // 图片存放路径
* @param count // 当前取到第几帧
* @param second // 每隔多少帧取一张,一般高清视频每秒 20-24 帧,根据情况配置,如果全部提取,则将second设为 0 即可
*/
public static void writeToFile(Frame frame, String picPath, int count, int second, int angel) {
if (second == 0) {
// 跳过间隔取帧判断
} else if (count % second != 0) {
// 提取倍数,如每秒取一张,则: second = 20
return;
}
File targetFile = new File(picPath + count + ".jpg");
System.out.println("创建了文件:" + picPath + count + ".jpg");
String imgSuffix = "jpg";
Java2DFrameConverter converter = new Java2DFrameConverter();
System.out.println(frame.imageHeight);
System.out.println(frame.imageWidth);
BufferedImage srcBi = converter.getBufferedImage(frame);
int imageWith = srcBi.getWidth();
int imageHeight = srcBi.getHeight();
// // 对截取的帧进行等比例缩放
int width = 800;
int height = (int) (((double) width / owidth) * oheight);
// BufferedImage bi = new BufferedImage(owidth, oheight, BufferedImage.TYPE_3BYTE_BGR);
// bi.getGraphics().drawImage(srcBi.getScaledInstance(owidth, oheight, Image.SCALE_SMOOTH), 0, 0, null);
// try {
// ImageIO.write(bi, imgSuffix, targetFile);
// } catch (Exception e) {
// e.printStackTrace();
// }
// ByteArrayOutputStream bStream = new ByteArrayOutputStream();
// try {
// ImageIO.write(srcBi, imgSuffix, targetFile);
// } catch (IOException e) {
// throw new RuntimeException("bufImg读取失败:" + e.getMessage(), e);
// }
//设置宽高
BufferedImage bi;
int rotateWidth = imageWith;
int rotateHeight = imageHeight;
//要进行图片的旋转
if (angel != 0) {
if (angel == 90 || angel == 270) {
rotateWidth = imageHeight;
rotateHeight = imageWith;
}
bi = new BufferedImage(rotateWidth, rotateHeight, BufferedImage.TYPE_3BYTE_BGR);
Rectangle reactRes = new Rectangle(new Dimension(rotateWidth, rotateHeight));
Graphics2D g2 = bi.createGraphics();
g2.translate((reactRes.width - imageWith) / 2, (reactRes.height - imageHeight) / 2);
g2.rotate(Math.toRadians(angel), imageWith / 2, imageHeight / 2);
g2.drawImage(srcBi, null, null);
} else {
bi = new BufferedImage(rotateWidth, rotateHeight, BufferedImage.TYPE_3BYTE_BGR);
bi.getGraphics().drawImage(srcBi.getScaledInstance(rotateWidth, rotateHeight, Image.SCALE_SMOOTH), 0, 0, null);
}
try {
ImageIO.write(bi, imgSuffix, targetFile);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取视频时长,单位为秒
*
* @param file
* @return 时长(s)
*/
public static Long getVideoTime(File file) {
Long times = 0L;
try {
FFmpegFrameGrabber ff = new FFmpegFrameGrabber(file);
ff.start();
times = ff.getLengthInTime() / (1000 * 1000);
ff.stop();
} catch (Exception e) {
e.printStackTrace();
}
return times;
}
//===================================每隔几秒取一次===================================================
/**
* 获取指定视频的帧并保存为图片至指定目录
*
* @param file 源视频文件
* @param picPath 截取帧的图片存放路径
* @throws Exception
*/
public static void fetchPic2(File file, String picPath, int second) throws Exception {
// 获取视频文件
//FFmpegFrameGrabber ff = new FFmpegFrameGrabber("https://wos2.58cdn.com.cn/nOpKjIhGfnOt/startaggingbucket/xxaqb_aqyfb_aqznb_dsy_1623034501416_ef39c6f8869043e0a0fed19eb8202fa2.mp4");
FFmpegFrameGrabber ff = new FFmpegFrameGrabber("https://vd3.bdstatic.com/mda-kdunygke7d51rj30/v1-cae/sc/mda-kdunygke7d51rj30.mp4");
// 显示视频长度(秒/s)
//System.out.println(getVideoTime(file));
// 调用视频文件播放
ff.start();
System.out.println(ff.getLengthInTime());
// System.out.println(ff.getTimestamp());
// ff.setTimestamp(3344444);
// System.out.println(ff.getTimestamp());
//视频帧数长度
int length = ff.getLengthInAudioFrames();
System.out.println(ff.getImageWidth());
System.out.println(ff.getImageHeight());
System.out.println(length);
System.out.println(ff.getFrameRate());
// 图片帧数,如需跳过前几秒,则在下方过滤即可
// ff.setImageWidth(800);
// ff.setImageHeight(480);
System.out.println(ff.getImageWidth());
System.out.println(ff.getImageHeight());
String rotateStr = ff.getVideoMetadata("rotate");
int rotateAngle = 0;
if (rotateStr != null) {
rotateAngle = Integer.parseInt(rotateStr);
}
int i = 0;
Frame frame = null;
int count = 0;
int ftp = ff.getLengthInFrames();
double fps = ff.getFrameRate();
System.out.println("总帧数: " + ftp + " 帧率: " + 1 / fps + " 总时间(秒): " + (ftp / fps));
while (true) {
frame = ff.grabImage();
System.out.print(i + ",");
if (frame == null) {
break;
}
if (frame.image != null&&frame.keyFrame) {
System.out.println(i);
// 生成帧图片
writeToFile(frame, picPath, count, second, rotateAngle);
}
ff.setTimestamp(ff.getTimestamp());
count++;
System.out.println(ff.getTimestamp());
}
}
}