高性能流媒体硬解码

高性能流媒体硬解码

前言

最近学习了下视频流媒体的相关知识,并对高性能流媒体硬解码框架 hard_decode_trt 进行了简单的学习。通过 FFmpeg 和 NVDEC 配合 tensorRT_Pro 完成在 Ubuntu20.04 主机上的硬件解码操作。

本文主要分享视频流媒体的相关知识以及 hard_decode_trt 框架的简单分析,博主为初学者,欢迎交流讨论,若有问题欢迎各位看官批评指正!!!😘

1. What、Why and How

问题:什么是视频流媒体编解码?为什么需要编解码?如何去实现编解码?

1.1 What

什么是视频流媒体编解码?(from chatGPT)

视频流编解码是指将视频数据进行压缩编码和解压缩解码的过程。在这个过程中,视频数据经过压缩编码之后,可以在网络传输、存储等环节中占用更小的带宽和存储空间,从而提高传输和、存储效率。而在视频数据被接收后,需要进行解压缩解码操作,还原出原始的视频数据,以便于观看和处理。

在视频流编码中,常用的编码标准包括 H.264、H.265、VP9 等,我们常用的就是 H.264 编码格式

在视频流解码过程中,会有软解码和硬解码两种。软解码是指通过软件算法将压缩后的视频流解码成原始的视频帧数据,而硬解码则是利用硬件加速的方式来解码视频流。

在软解码中,计算机需要使用解码器软件,如 FFmpeg、VLC 等,来对视频流进行解码。解码器会对视频流进行解压缩、解码、重构等,最终将其转化为原始的视频帧数据。

硬解码则是使用专门的硬件加速器,如 GPU、DSP 等,来解码视频流(例如 NVIDIA 的 NVDEC)。相较于软解码,硬解码可以提供更高的解码速度和更低的 CPU 占用率,尤其对于高分辨率、高比特率的视频流解码效果更为明显。

1.2 Why?

为什么要学习视频流媒体编解码?

视频流媒体编解码是现代多媒体技术中的核心内容之一,掌握视频流媒体编解码技术可以实现高效的视频压缩、传输和播放,具有广泛的应用前景。如视频直播、视频会议、视频监控、视频点播。

我们先不考虑那么多,先来对实际的一个问题进行思考🧐。

假设我们在服务器上部署了一个异常事件检测模型,通过网络摄像头进行监控,要求实现:

1.实时监测摄像头中的画面,如果出现特定的异常事件,则发送通知到服务器

2.同时储存事件中发生时的一张图,以及该事件发生时前后 5 秒钟的视频画面

重点考虑:

1.对于前后 5 秒钟视频的缓冲应该如何处理?假设储存前后 5 秒的视频画面,按照 1920x1080@25fps 计算,每一帧需要 5.93MB(1920*1080*3/1024/1024) 空间,总共需要储存 250帧 = 1483.15MB

2.对于多路视频同时处理,需要尽可能高的计算速度和尽可能少的空间占用,如果按照上述储存方式的话,则 5 秒需要 1.48GB,如果多路的话则可能直接报废了😂

如果有对视频流媒体编解码有一定了解的话,我们可以自己对视频文件进行编解码,提高计算速度并减少占用空间,简单来说,我们假设监控画面场景变化较小,1 秒钟只取一个关键帧即 I 帧,剩余 24 帧为前向预测帧即 P 帧,那么

对于 1920x1080 的监控视频画面,参考的大小为:

I帧 = 165KB

P帧 = 5KB,如果运动量大可估计为 10KB

对于 GoP = 25 的监控视频,一秒钟大小为 165KB + 10KB * 24 = 405KB 也就是说,1920x1080@25fps 的 10 秒钟视频大小预计为 3.95MB

可以看到储存视频的空间大大减少,同时后续在解码时我们还可以通过硬解码大大提高速度。

对视频流媒体编解码底层的简单了解,可以加深我们对实际应用场景的理解,方便我们的开发工作。

1.3 How?

如何去学习视频流编解码?

博主是通过杜老师写的开源项目 hard_decode_trt 来学习硬件解码并配合 TensorRT

2. 离散傅里叶变换(DFT)

我们先从离散傅里叶变化出发来探讨图像、视频压缩的原理(from chatGPT)

离散傅里叶变换(Discrete Fourier Transform,DFT)是傅里叶变化在离散域的推广,是一种信号分析和处理的常用工具。它实际上是将输入序列转换为一组基函数的线性组合,这些基函数是正弦和余弦函数的离散版本。离散傅里叶变换常用于将时域信号(例如图像和视频帧)转换为频域表示,从而可以通过对频域系数的量化和编码来实现数据压缩。

在图像压缩中,DFT 通常用于实现基于变换的压缩方法,例如 JPEG 压缩。在这种方法中,图像首先被分割成 8x8 的块,然后对每个块进行 DCT (离散余弦变换)。DCT 将块中的像素转换为一组系数,其中每个系数对应于一个特定的空间频率。由于大多数自然图像具有许多相似的频率成分,DCT 系数通过可以通过量化来减小其数值范围,从而实现压缩。

在视频压缩中,DFT 同样用于频域编码方法,例如 H.264 和 HEVC。这些编码器将每个视频帧分割为多个宏块,并对每个宏块进行变换和量化。在 H.264 和 HEVC 中,变换通常是离散余弦变换(DCT)或离散正弦变换(DST),量化是通过将变换系数除以一个量化因子来实现的,编码器还使用运动估计来寻找每个宏块的最佳参考帧,并使用帧内和帧间预测来进一步减小数据量。

总的来说,离散傅里叶变换在图像和视频压缩中发挥着重要作用,通过将信号转换到频域上,可以实现对信号中重要信息的捕捉和压缩,从而实现更高效的数据传输和存储。

更多细节描述可参考:

https://www.zhihu.com/question/22611929?sort=created

https://www.cnblogs.com/wyuzl/p/7880124.html

2.1 JPEG编码

JPEG(联合图像专家小组,Joint Photographic Experts Group)

编码流程如下:

  • 1.采样

    • 颜色空间转换使用 RGB ➡ YUV,Y 通道代表细节,UV 表示颜色。细节比颜色重要,对 Y 通道轻度压缩,对 UV 可以做更多的压缩,使得人眼难以发现变化

    • 例如一个像素用一个 Y,而 4 个像素共用一个 UV,则 YUV 配比是 4:1:1

  • 2.分块

    • 对图像分为 8*8,共 nxm 个块,分别在 YUV 分量上进行 DCT(离散余弦变换) 变换
  • 3.DCT

    • 以 DCT 矩阵的方式实现加速 DCT 的运算

    • DCT 可以简化为 T B T T TBT^T TBTT,逆变换为 T T B T T^TBT TTBT,这里 T T T 为 DCT 的系数矩阵

    • 图像能量大部分集中在左上角,给后续压缩做准备

  • 4.系数量化

    • DCT 的系数值为实数,通过量化变为整数,接近0的为0,整数相比实数而言,更容易压缩。另外量化后 DCT 系数充满大量 0
  • 5.Zig-zag 扫描

    • 对于左上角第一个分量为直流分量(DC),其它 63 个为交流分量(AC),对于 DC 采用与其它块的差分编码,对于 AC 采用 Zig-zag 扫描并进行游程长度编码(RLE)

    • 由于量化后矩阵的值集中在左上角,三角形排布,因此采用 Z 字形扫描并进行 RLE

      • 例如 31,45,0,0,0,0,23,0,-30,-8,0,0,0,0,0,0,…,0
      • 编码后为:0,31,0,45,4,23,1,-30,0,-8,0,0
      • 编码后为:(0,31),(0,45),(4,23),(1,-30),(0,-8),EOB
        • EOB 为 (0,0),表示后面全都是 0 了
        • 也因此,对于 0 比较多的数据,RLE 能够极大的对数据进行压缩
  • 6.熵编码-huffman

    • 对数据进行无损的哈夫曼编码

    • 最后数据的压缩比通常可以做到十分之一左右

2.2 哈夫曼编码(Huffman Coding)

哈夫曼编码是一种常用的无损压缩算法,常用于 JPEG 图像压缩中,其基本思想是将频率较高的符号用较短的二进制码表示,而将频率较低的符号用较长的二进制码表示,以此来减少数据的存储和传输量,达到压缩的目的。(from chatGPT)

具体流程如下:

  • 1.统计源数据中各个符号出现的频率
  • 2.根据符号频率构建哈夫曼树,构建的过程中,可以通过不断选择频率最小的两个符号来构建树
  • 3.根据哈夫曼给每个符号编码,编码的规则是从根节点到叶子节点,每次走左边子树则编码为 0,每次走右边子树则编码为 1,最终得到每个符号对应的哈夫曼编码
  • 4.将源数据中的每个符号都用对应的哈夫曼编码进行替换,得到压缩后的数据。
  • 5.在传输过程中,需要将哈夫曼编码的长度也一并发送给对方,以便接收方正确地解码。

哈夫曼编码的优点是压缩效率高,而且压缩后的数据可以无损还原。在 JPEG 图像压缩中,哈夫曼编码通常和离散余弦变换(DCT)结合使用,可以进一步提高压缩效率。

现在通过代码来实现下整个哈夫曼编码流程,以下代码均 Copy 自杜老师关于哈夫曼编码的讲解

1.定义需要编码的字符串text,并对其每一个字符进行词频统计


import numpy as np
import cv2
import matplotlib.pyplot as plt

# import pygraphviz as pgv
# sudo apt-get install graphviz graphviz-dev
# pip install pygraphviz
# http://www.graphviz.org/doc/info/attrs.html

text = "AAABBBBBBBBBBBBCCCCCDDEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEFFF"
freq_map = dict()
for i in range(len(text)):
    data = text[i]
    
    if data not in freq_map:
        freq_map[data] = 0
    
    freq_map[data] += 1

freq_map = list(freq_map.items())
print(freq_map)

输出如下:

[('A', 3), ('B', 12), ('C', 5), ('D', 2), ('E', 31), ('F', 3)]

2.定义二叉树的节点类

class TreeNode:
    def __init__(self):
        self.key   = None
        self.value = None
        self.left  = None
        self.right = None

3.根据词频,构建哈夫曼树

  • a).如果词汇表中词大于 1 时继续,否则结束
  • b).查找词频表中最小的 2 个词,记为 a、b,且 a 的频率小于等于 b 的频率
  • c).将 ab 节点频率相加作为一个新的节点,记为 c,设置 a 为 c 的左孩子,b 为 c 的右孩子
  • d).将 c 加入词频表中,并且删掉词频表中的 a、b 节点
  • e).继续步骤 a)
freqs = []
for key, value in freq_map:
    item = TreeNode()
    item.key   = key
    item.value = value
    freqs.append(item)

while len(freqs) > 1:
    sorted_freqs = sorted(freqs, key = lambda x : x.value)

    a = sorted_freqs[0]
    b = sorted_freqs[1]
    
    c = TreeNode()
    c.key   = a.key   + b.key
    c.value = a.value + b.value
    c.left  = a
    c.right = b
    freqs = [c] + sorted_freqs[2:]

def print_tree(item, tab = 0, name = None):
    if item is None:
        return

    tab_string = "".join(["\t"] * tab)
    print(f"{tab_string} {name}: node = {item.key}[{item.value}]")
    print_tree(item.left, tab+1, "left")
    print_tree(item.right, tab+1, "right")

tree = freqs[0]
print_tree(tree)

输出如下:

 None: node = BCFDAE[56]
         left: node = BCFDA[25]
                 left: node = B[12]
                 right: node = CFDA[13]
                         left: node = C[5]
                         right: node = FDA[8]
                                 left: node = F[3]
                                 right: node = DA[5]       
                                         left: node = D[2] 
                                         right: node = A[3]
         right: node = E[31]

4.将构建的哈夫曼树绘制为图储存下来

def add_to_graph(G, parent, item, name):
    if item is None:
        return

    G.add_edge(f"{parent.key}:{parent.value}", f"{item.key}:{item.value}", label=name)
    add_to_graph(G, item, item.left, "0")
    add_to_graph(G, item, item.right, "1")

def add_graph(G, tree):
    add_to_graph(G, tree, tree.left, "0")
    add_to_graph(G, tree, tree.right, "1")

A = pgv.AGraph()
add_graph(A, tree)

A.layout(prog='dot')
A.draw('graph.png')
image = cv2.imread("graph.png")
plt.figure(figsize=(20, 20))
plt.imshow(image)

哈夫曼树如下图所示:

在这里插入图片描述

图2-1 哈夫曼树

5.根据哈夫曼树,对给定频率表中的每一个字符,分配二进制串

  • 分配的二进制串,即为该符号在哈夫曼树中的路径,往左为0,往右为1,找到该符号所需要经过的路径
  • 二进制串的特性是,出现频率越高的符号会有越短的路径,出现频率较低的符号,会有越长的路径
def find_path_code(tree, key, value, name = ""):
    
    if tree is None:
        return None
    
    if tree.key == key:
        return name
   	
    found_name = None
    if value < tree.value:
        found_name = find_path_code(tree.left, key, value, name + "0")
    
    if found_name is None:
        found_name = find_path_code(tree.right, key, value, name + "1")
    
    return found_name

freq_coded = {}

for key, value in freq_map:
    coded = find_path_code(tree, key, value, "")
    freq_coded[key] = coded

print(freq_coded)

输出如下:

{'A': '01111', 'B': '00', 'C': '010', 'D': '01110', 'E': '1', 'F': '0110'}

6.使用已经分配好的二进制串,对给定字符串进行编码

  • 因为是二进制,因此转换为 byte 需要除以 8
def encode_text(text, freq_coded):
    encoded = ""
    for word in text:
        encoded += freq_coded[word]
    return encoded

encoded = encode_text(text, freq_coded)

encoded_length = len(encoded) / 8
raw_length = len(text)
compress_rate = encoded_length / raw_length * 100

print("Encode后的表示:", encoded)
print("压缩后的长度(byte):", encoded_length)
print("压缩前的长度(byte):", raw_length)
print(f"压缩率:{compress_rate:.2f}%")

输出如下:

Encode后的表示: 01111011110111100000000000000000000000001001001001001001110011101111111111111111111111111111111011001100110
压缩后的长度(byte)13.375
压缩前的长度(byte)56
压缩率:23.88%

7.根据给定的编码后二进制串,以及哈夫曼树,对数据进行解码还原

  • 根据给定的串,在哈夫曼树中查找,直到找到叶子节点,即表示可以吐出一个字符,然后进入下一个环节继续从根节点找
def find_text_with_path(tree, path, cursor = 0):
    
    if tree.left is None and tree.right is None:
        return tree.key, cursor

    p = path[cursor]
    node = None
    if p == "0":
        node = tree.left
    else:
        node = tree.right
    
    return find_text_with_path(node, path, cursor + 1)

def decode_text(encoded_text, tree):
    output, cursor = find_text_with_path(tree, encoded_text, 0)
    while cursor < len(encoded_text):
        word, cursor = find_text_with_path(tree, encoded_text, cursor)
        output += word
    return output

print(decode_text(encoded, tree))

输出如下:

AAABBBBBBBBBBBBCCCCCDDEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEFFF

完整的示例代码如下


import numpy as np
import cv2
import matplotlib.pyplot as plt

import pygraphviz as pgv
# sudo apt-get install graphviz graphviz-dev
# pip install pygraphviz
# http://www.graphviz.org/doc/info/attrs.html


# ==================== 步骤1.统计字符串每个字符词频 ====================
text = "AAABBBBBBBBBBBBCCCCCDDEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEFFF"
freq_map = dict()
for i in range(len(text)):
    data = text[i]
    
    if data not in freq_map:
        freq_map[data] = 0
    
    freq_map[data] += 1

freq_map = list(freq_map.items())
print(freq_map)


# ==================== 步骤2.定义二叉树的节点类 ====================
class TreeNode:
    def __init__(self):
        self.key   = None
        self.value = None
        self.left  = None
        self.right = None


# ==================== 步骤3.根据词频,构建哈夫曼树 ====================
freqs = []
for key, value in freq_map:
    item = TreeNode()
    item.key   = key
    item.value = value
    freqs.append(item)

while len(freqs) > 1:
    sorted_freqs = sorted(freqs, key = lambda x : x.value)

    a = sorted_freqs[0]
    b = sorted_freqs[1]
    
    c = TreeNode()
    c.key   = a.key   + b.key
    c.value = a.value + b.value
    c.left  = a
    c.right = b
    freqs = [c] + sorted_freqs[2:]

def print_tree(item, tab = 0, name = None):
    if item is None:
        return

    tab_string = "".join(["\t"] * tab)
    print(f"{tab_string} {name}: node = {item.key}[{item.value}]")
    print_tree(item.left, tab+1, "left")
    print_tree(item.right, tab+1, "right")

tree = freqs[0]
print_tree(tree)


# ==================== 步骤4.将构建的哈夫曼树绘制为图储存下来 ====================
def add_to_graph(G, parent, item, name):
    if item is None:
        return

    G.add_edge(f"{parent.key}:{parent.value}", f"{item.key}:{item.value}", label=name)
    add_to_graph(G, item, item.left, "0")
    add_to_graph(G, item, item.right, "1")

def add_graph(G, tree):
    add_to_graph(G, tree, tree.left, "0")
    add_to_graph(G, tree, tree.right, "1")

A = pgv.AGraph()
add_graph(A, tree)

A.layout(prog='dot')
A.draw('graph.png')
image = cv2.imread("graph.png")
plt.figure(figsize=(20, 20))
plt.imshow(image)


# ==================== 步骤5.根据哈夫曼树,对给定频率表中的每一个字符,分配二进制串 =======
def find_path_code(tree, key, value, name = ""):
    
    if tree is None:
        return None
    
    if tree.key == key:
        return name
   	
    found_name = None
    if value < tree.value:
        found_name = find_path_code(tree.left, key, value, name + "0")
    
    if found_name is None:
        found_name = find_path_code(tree.right, key, value, name + "1")
    
    return found_name

freq_coded = {}

for key, value in freq_map:
    coded = find_path_code(tree, key, value, "")
    freq_coded[key] = coded

print(freq_coded)


# ==================== 步骤6.使用已经分配好的二进制串,对给定字符串进行编码 =======
def encode_text(text, freq_coded):
    encoded = ""
    for word in text:
        encoded += freq_coded[word]
    return encoded

encoded = encode_text(text, freq_coded)

encoded_length = len(encoded) / 8
raw_length = len(text)
compress_rate = encoded_length / raw_length * 100

print("Encode后的表示:", encoded)
print("压缩后的长度(byte):", encoded_length)
print("压缩前的长度(byte):", raw_length)
print(f"压缩率:{compress_rate:.2f}%")


# ==================== 步骤7.根据给定的编码后二进制串,以及哈夫曼树,对数据进行解码还原 =======
def find_text_with_path(tree, path, cursor = 0):
    
    if tree.left is None and tree.right is None:
        return tree.key, cursor

    p = path[cursor]
    node = None
    if p == "0":
        node = tree.left
    else:
        node = tree.right
    
    return find_text_with_path(node, path, cursor + 1)

def decode_text(encoded_text, tree):
    output, cursor = find_text_with_path(tree, encoded_text, 0)
    while cursor < len(encoded_text):
        word, cursor = find_text_with_path(tree, encoded_text, cursor)
        output += word
    return output

print(decode_text(encoded, tree))

3. 视频流媒体前置知识

先来看看视频流媒体的一些知识,参考自杜老师的 CV 之视频流媒体以及 chatGPT,以下内容均 copy 自 CV 之视频流媒体 PPT 中的内容。

3.1 视频文件的构成

大多视频文件都是类似的形式存在:

在这里插入图片描述

图3-1 视频文件的组成

其中,

  • SPS(Sequence Parameter Set):序列参数集,SPS 包含了视频序列(一系列连续的图像帧)的参数,例如分辨率、帧率、图像采样方式、颜色空间等等。
  • PPS(Picture Parameter Set):图像参数集,PPS 包含了与特定视频帧相关的参数,例如图像类型、参考帧索引等等。
  • SPS 和 PPS 是 H.264 和 H.265 等视频编码标准中的两个关键元素。它们对于视频编码和解码非常重要,可以影响到视频的质量、压缩率、延迟等方面。
  • GoP(Group of Pictures):一个 Group 的大小,例如在上面 GoP = 6,GoP 是视频压缩中的一个重要概念。在视频编码过程中,视频帧被分为不同的组,每组包含若干个连续的视频帧。在一个 GoP 中,通常包含一个关键帧即 I 帧和多个前向预测帧即 P 帧和双向预测帧即 B 帧。场景常常不动的时候,GoP 可以给非常大,减少 I 帧频率

I、P、B帧的概念如下:

  • I帧:关键帧(Intra-coded pictures)只可含有节点宏区块,就像传统的将一张张图片作压缩
    • I 帧最大,内容最全,影响也最大,因为 P 帧和 B 帧需要参考 I 帧
    • 如果显示花屏,则等待下一个 I 帧到来,花屏基本就解除了
    • I 帧也是 IDR 帧(Instantaneous Decoding Refresh),即遇到 I 帧立即刷新显示,并且历史的解码队列清空
  • P帧:前向预测帧(Predictive pictures)含有节点宏区块或预测宏区块,相对于之前的帧,编码器不用记录下 P-frame 中没有改变的 pixel
    • P 帧只储存相对于前一帧发生改变的部分,不用记录没有改变的像素。并且会对运动进行预测。压缩率高于 I 帧
  • B帧:双向预测帧(Bi-predictive pictures)含有节点、预测和前后预测宏区块
    • B 帧具有双向预测能力,相比 P 帧压缩率更高,运动预测更准确。B 帧的数据越多压缩比越好
  • 如果没有 I 帧的存在,则 P、B 解码出的图像会只有运动部分,造成花屏等问题

3.2 视频的编码与封装

视频的编码:将视频画面序列通过一定算法压缩并编码储存,例如 H.264

音频的编码:将音频的序列通过一定算法压缩并编码储存,例如 ACC(Advanced Audio Coding)

视频的封装:将音视频、字幕等信息统一进行封装(或者交错封装),使得形成完整的多媒体文件。主要解决音视频、字幕等信息的封装、播放问题。例如播放时的画面声音同步、字幕同步对齐等。封装格式是容器,储存的内容是编码后的数据

下面是一些常见的编码格式,参考自视频编码与封装

编码格式编码方式特点
h.264(mpeg4)帧间编码网络传播最佳
mpeg2(dvd)帧间编码过时
h.265(hevc)帧间编码未普及
proes(apple)帧内编码高效&优良
dhxhd/hr(avid)帧内编码win支持最佳
cineform(gropro)帧内编码最佳

下面是一些常见的封装格式,参考自视频编码与封装

封装格式对编码的支持
Mov(apple)优良
mp4(动态图像专家组)最佳
mkv/ogg(开源)软件支持不足
wmv(Microsoft)兼容性低下
AVCHD(松下/索尼)结构复杂(通过文件的方式组织)
avi旧,不支持新编码

视频的编码和封装可用下图表示:

在这里插入图片描述

图3-2 视频的编码和封装示例

3.3 H264编码的分层

copy【H264】码流结构详解,强烈建议阅读原文!!!

从码流功能的角度可以将 H264 编码分为两层:视频编码层(VCL)和网络提取层(NAL)

  • VCL(Video Coding Layer):进行视频编解码,包括预测(帧间预测和帧内预测),即对视频序列进行编码后得到的裸数据
  • NAL(Network Abstract Layer):负责以网络所要求的恰当的方式对 VCL 数据进行打包和传送

我们需要关注 NAL 的组成单元——NALU

NALU(Network Abstract Layer-Unit):网络提取层单元,对 NAL 的封装,具有标头,头中又储存着 NAL 中的数据类型,例如 SPS、PPS 还是 IDR 等等。NALU 通常头部加入以 0x00,0x00,0x00,0x01 或者 0x00,0x00,0x01 作为起始

NALU 的格式如下图(引用自H264 PDF)所示:

在这里插入图片描述

图3-3 NALU的格式

很明显,NALU 由头和身体两个部分组成:

  • 头:一般存储标志信息,譬如 NALU 的类型。
  • 身体:存储真正的数据

关于 H264 更详细的分层结构如下图所示:

在这里插入图片描述

图3-4 H264详细分层结构
  • 第一层:比特流。该层有两种格式:Annexb 格式和 RTP 格式

  • 第二层:NAL Unit 层。包含了 NAL Header 和 NAL Body 信息

  • 第三层:Slice 层。一帧视频图像可编码成一个或者多个片,每片包含整数个宏块,即每片至少一个宏块,最多时包含整个图像的宏块。

    • 有两个概念比较模糊,切片(Slice)和宏块(macroblock),它们都是 VCL 中的概念(from chatGPT)
    • 在视频编码 VCL 中,Slice 是指将一个帧分成多个小块进行编码,每个小块称为 Slice,它是压缩后得数据流中的基本单元
    • 一个 Slice 可以由多个宏块(macroblock)组成,而宏块是视频压缩中最小的处理单元,它包含了一组连续的像素块,通常为 16x16 像素的正方形区域。宏块被编码后,就可以通过 VLC 进行压缩编码了。
  • 第四层:Slice Data 层

  • 第五层:PCM 类

  • 第六层:残差层

3.4 RTSP之RTP(Real-time Transport Protocol)

RTP 实时传输协议,约定了实时传输数据时的交互方式,是一个网络传输的协议,1996年提出在 RFC 1889 中公布

其报文结构如下图所示,参考自https://en.wikipedia.org/wiki/Real-time_Transport_Protocol

在这里插入图片描述

图3-5 RTP报文格式
  • Version:(2 bits) 是目前协议的版本号码,当前版本号是 2
  • P(Padding):(1 bit) 是用于 RTP 报文(packet)结束点的预留空间,视报文是否需要多余的填塞空间
  • X(Extension):(1 bit) 是否在使用延伸空间于报文之中
  • CC(CSRC count):(4 bits) 包含了 CSRC 数目用于修正标头(fixed header)
  • M(Marker):(1 bit) 是用于应用等级以及其原型(profile)的定义。如果不为零表示目前的资料有特别的程序解译
  • PT(Payload type):(7 bits) 是指 payload 的格式并决定将如何去由应用程序加以解释

3.5 RTSP之RTSP(Real Time Streaming Protocol)

RTSP 实时流协议,约定了具体实施时,如何进行流数据交互。RTP 只关注具体包的封装形式,不关注具体业务逻辑(例如登录、不同地址、交互、多播、开始、暂停、结束)。而 RTSP 则是基于 RTP 为流数据封装的格式之上,明确了业务逻辑的定义,即如果你要登录先发送什么再发送什么,如果你要订阅某个地址需要发送什么

一个 RTSP 包是由一个 RTP 包和 NAL 组成的,如下图所示

在这里插入图片描述

图3-6 直播中的RTSP传输

RTP 中封装了时间戳等信息,NAL 中封装了具体的视频画面信息。使用 RTP 对 NAL 数据进行封装,借由 RTSP 协议进行流传输,客户机接收到数据后,先揭开 RTP 包得到 NAL,然后送到解码器解码并播放

NAL 只是对画面进行编码,没有时间戳等信息,而 MP4 等封装格式需要考虑播放时间戳

3.6 硬件解码

在基于 NVIDIA 显卡上的硬件解码方案,是使用 NVIDIA 提供的 CUVIDAPI,最新称呼为 NVENC 编码、NVDEC 解码

解封包,主要使用 FFMPEG 实现,FFMPEG 可以解开众多封包格式例如 MP4、RTSP、RTP、FLV 等,拿到 NALU 时,即可将其送给 NVDEC 进行解码得到图像

具体见代码部分

https://github.com/shouxieai/hard_decode_trt

https://shouxieai.com/solution/trt/integ-1.21-multi-camera-decoder

4. hard_decode_trt

hard_decode_trt 项目是在 tensorRT_Pro 项目上新增了硬件解码部分

  • 需要配置 tensorRT_Pro 一样的环境
  • 新增 FFmpeg 和 NVDEC 的配置
  • make yolo -j64
  • Yolo 和硬件解码直接对接
  • make demuxer -j64
  • 仅仅解封装得到 h264 的包,并分析是什么帧
  • make hard_decode -j64
  • 硬件解码测试
  • 核心思想是吧 NALU 交替放入解码器进行解码,最后通过 timestamp 进行区分
  • 软解码(FFmpeg)和硬解码(NVDEC),分别消耗 CPU 和 GPU 资源。在多路、大分辨率下体现明显
  • 硬件解码和推理可以允许跨显卡
  • 理解并善于利用的时候,它才可能发挥最大的效果

4.1 环境配置

首先你需要配置和 tensorRT_Pro 一样的环境,需要安装 CUDA、cuDNN、TensorRT、OpenCV、Protobuf 等软件的安装,而关于 CUDA、cuDNN、TensorRT、OpenCV、Protobuf 等软件的安装请参考Ubuntu20.04部署YOLOv5,这里不再赘述。

除此之外还需要新增 FFmpeg 和 NVDEC 的配置,可参考下面编译安装

4.2 编译FFmpeg-4.2

参考自FFMpeg学习笔记–Ubuntu20.04编译FFmpeg、FFplay和FFprobe

描述:最近在学习视频流媒体的编解码,在学习 hard_decode_trt 项目时需要使用到 FFMpeg 第三方库,因此需要编译该库方便后续使用。编译流程按照上文的操作即可,值得注意的是 hard_decode_trt 使用的 FFmpeg-4.2 版本,博主编译过 FFmpeg-5.1 版本,发现在 FFmpeg 中有一些 API 发生了改变,导致 hard_decode_trt 项目无法运行起来,所以为了让问题更加简单化,博主也是编译的 FFmpeg-4.2,其它版本的编译也可以按照上文的流程走,没有问题。

FFMpeg介绍:FFmpeg 是一款跨平台的、开源的音视频处理软件。它可以对音视频进行编解码、转码、剪辑、合并等处理,支持多种格式的音视频文件。在视频流媒体编解码中,FFmpeg 通常被用来进行解封装、编码、解码、转换等操作。它通过将原始的音视频数据流解封装成一帧一帧的数据,然后将这些数据传输给硬件编码器(例如 NVIDIA 的 NVDEC)。硬件编码器将编码后的数据传输回 FFmpeg,再由 FFmpeg 将编码后的数据封装称所需要的视频格式,例如 MP4、AVI 等。FFmpeg 提供了必要的解封装和编解码功能,使得视频编解码更加高效、简单和方便。

4.2.1 下载FFmpeg

下载 4.2 版本的 ffmpeg,指令如下:

wget http://www.ffmpeg.org/releases/ffmpeg-4.2.tar.gz

可以将链接复制到浏览器中下载,外网访问慢,建议开代理。也可以点击 here[pwd:yolo] 下载博主准备好的安装包

解压下载好的压缩包

tar -zxvf ffmpeg-4.2.tar.gz
4.2.2 编译FFmpeg

进入解压后的文件夹

cd ffmpeg-4.2

安装依赖

# 安装ffplay需要的依赖
sudo apt-get install libx11-dev xorg-dev libsdl2-2.0 libsdl2-dev

sudo apt install clang libfdk-aac-dev libspeex-dev libx264-dev libx265-dev libnuma-dev

sudo apt install yasm pkg-config libopencore-amrnb-dev libopencore-amrwb-dev

编译FFmpeg

# 查看帮助文档确定需要安装的相关参数
./configure --help

配置相关参数

./configure --disable-static --enable-shared --enable-gpl --enable-version3 --enable-nonfree --enable-ffplay --enable-ffprobe --enable-libx264 --enable-libx265 --enable-debug

注意这里博主与参考的博文设置不太一样,博主新增了 --disable-static --enable-shared 参数,让其编译成动态库且不需要静态库,方便后续 hard_decode_trt 项目的指定。

make编译

make -j24

:make 这步需要的时间一般比较长

安装

sudo make install

耐心等待安装完成即可。

安装完成后的 FFmpeg 头文件在 /usr/local/include 目录下,库文件在 /usr/local/lib 目录下,可执行文件在 /usr/local/bin 目录下

4.2.3 设置环境变量

博主并未设置,若有需求见参考博文

4.2.4 验证

查看 FFmpeg 的版本,由于博主未设置环境变量,所以先要 cd 到 /usr/local/bin 目录下

cd /usr/local/bin

ffmpeg -version

查看 FFmpeg 帮助文档

ffmpeg -h

ffmpeg -h long

ffmpeg -h full
4.2.5 卸载FFmpeg

卸载非常简单,指令如下:

# 首先进入 ffmpeg 源码编译的路径
cd ffmpeg-4.2

sudo make uninstall

执行完成后可以发现在 /usr/local/include 中 FFmpeg 的头文件没有了,在 /usr/local/lib 中 FFmpeg 的库文件没有了,在 /usr/local/bin 中 FFmpeg 的可执行文件没有了。

这个需求非常有必要,当你发现编译后的 FFmpeg 缺少某些库时,你可以通过 sudo make uninstall 卸载,然后通过 ./configure 重新配置参数编译。当你需要替换其它版本的 FFmepg 时,也可以先通过 sudo make uninstall 卸载,然后下载其它版本的 FFmpeg,通过上述操作重新编译。

4.3 NVIDIA VIDEO_CODEC_SDK

在之前我们提到过视频流硬解码需要用到视频解码的 GPU 硬件加速器引擎,而 NVIDIA 提供了一个用于视频编解码的 SDK 即 NVIDIA VIDEO_CODEC_SDK,它是一个 API 套件,包含高性能工具、样本和文档,适用于 Windows 和 Linux 的硬件加速型视频编码和解码。此 SDK 包含两个硬件加速接口:

  • 用于视频编码加速的 NVENCODE API
  • 用于视频解码加速的 NVDECODE API(旧称 NVCUVID API)

NVENC:硬件加速的视频编码

从 Kepler 这一代开始,NVIDIA GPU 包含基于硬件的编码器(简称为 NVENC),可提供基于硬件的全加速视频编码,且独立于图形性能。由于计算复杂的编码工作流完全卸载至 NVENC,图形引擎和 CPU 可以有更多的事件执行其它操作。

NVDEC:硬件加速的视频解码

NVIDIA GPU 包含基于硬件的解码器(简称为 NVDEC),可为几种热门的编解码器提供基于硬件的全加速视频解码。由于解码工作流完全卸载至 NVDEC,图形引擎和 CPU 可以有更多的事件执行其它操作。NVDEC 比实时解码速度更快,非常适合于转码应用以及视频播放应用。

CopyNVIDIA 视频编解码器 SDK

由于是 SDK 不需要编译,直接下载解压拿来用就行,其下载地址为:

现在最新的 SDK 为 12.0 版本,其系统要求如下图,Linux 下显卡驱动要求为 520.56.06 或者更新,且只支持 NVIDIA Quadro,Tesla,GRID 以及 GeForce 系列产品

在这里插入图片描述

图4-1 Video Code SDK 12.0 系统要求

其历史版本可通过 https://developer.nvidia.com/video-codec-sdk-archive 下载,也可以点击 here[pwd:yolo] 下载博主准备好的安装包(提供 10.0、11.0、11.1、12.0 四个版本的 SDK,根据自己的需求下载即可) ,每个版本都有对应的显卡驱动要求,例如 11.0 要求 Linux 下显卡驱动大于等于 455.27。

下载完成后解压即可,后续可通过 makefile 文件进行其头文件和库文件的指定。

4.4 配置Makefile

主要修改

  1. 修改第 12 行,修改 protobuf 路径
lean_protobuf  := /home/jarvis/protobuf
  1. 修改第 13 行,修改 tensorRT 路径
lean_tensor_rt := /opt/TensorRT-8.4.1.5
  1. 修改第 15 行,修改 OpenCV 路径
lean_opencv    := /usr/local
  1. 修改第 16 行,修改 CUDA 路径
lean_cuda      := /usr/local/cuda-11.6
  1. 修改第 17 行,修改 FFmpeg 路径
lean_ffmpeg    := /usr/local
  1. 修改第 18 行,修改 NVDEC SDK 路径
lean_nvdec     := /home/jarvis/software/Video_Codec_SDK_11.0.10
  1. 修改第 44 行,修改链接库
avresample => swresample

博主编译完 FFmpeg 时,发现没有动态库文件 libavresample,于是修改为了 swresample

  1. 修改第 56 行,修改 -gencode=arch=compute_75,code=sm_75 显卡算力
cu_compile_flags  := -std=c++11 -m64 -Xcompiler -fPIC -g -w -gencode=arch=compute_86,code=sm_86 -O0

显卡对应的号码参考这里:https://developer.nvidia.com/zh-cn/cuda-gpus#compute

博主显卡是 GeForce RTX 3060,所以选择 GeForce 产品,查看到 GeForce RTX 3060 的计算能力为 86,故将上面显卡算力改为 86

在这里插入图片描述

图4-2 显卡算力查询

完整的 Makefile 的内容如下:


cpp_srcs := $(shell find src -name "*.cpp")
cpp_objs := $(cpp_srcs:.cpp=.o)
cpp_objs := $(cpp_objs:src/%=objs/%)
cpp_mk   := $(cpp_objs:.o=.mk)

cu_srcs := $(shell find src -name "*.cu")
cu_objs := $(cu_srcs:.cu=.cuo)
cu_objs := $(cu_objs:src/%=objs/%)
cu_mk   := $(cu_objs:.cuo=.cumk)

lean_protobuf  := /home/jarvis/protobuf
lean_tensor_rt := /opt/TensorRT-8.4.1.5
lean_cudnn     := /data/sxai/lean/cudnn8.2.2.26
lean_opencv    := /usr/local
lean_cuda      := /usr/local/cuda-11.6
lean_ffmpeg    := /usr/local
lean_nvdec     := /home/jarvis/software/Video_Codec_SDK_11.0.10

include_paths := src        \
			src/application \
			src/tensorRT	\
			src/tensorRT/common  \
			$(lean_protobuf)/include \
			$(lean_opencv)/include/opencv4 \
			$(lean_tensor_rt)/include \
			$(lean_cuda)/include  \
			$(lean_cudnn)/include \
			$(lean_ffmpeg)/include \
			$(lean_nvdec)/Interface

library_paths := $(lean_protobuf)/lib \
			$(lean_opencv)/lib    \
			$(lean_tensor_rt)/lib \
			$(lean_cuda)/lib64  \
			$(lean_cudnn)/lib \
			$(lean_ffmpeg)/lib \
			$(lean_nvdec)/Lib/linux/stubs/x86_64

link_librarys := opencv_core opencv_imgproc opencv_videoio opencv_imgcodecs \
			nvinfer nvinfer_plugin \
			cuda cublas cudart cudnn \
			nvcuvid nvidia-encode \
			avcodec avformat swresample swscale avutil \
			stdc++ protobuf dl

paths     := $(foreach item,$(library_paths),-Wl,-rpath=$(item))
include_paths := $(foreach item,$(include_paths),-I$(item))
library_paths := $(foreach item,$(library_paths),-L$(item))
link_librarys := $(foreach item,$(link_librarys),-l$(item))

# 如果是其他显卡,请修改-gencode=arch=compute_75,code=sm_75为对应显卡的能力
# 显卡对应的号码参考这里:https://developer.nvidia.com/zh-cn/cuda-gpus#compute
# 如果是 jetson nano,提示找不到-m64指令,请删掉 -m64选项。不影响结果
cpp_compile_flags := -std=c++11 -fPIC -m64 -g -fopenmp -w -O0
cu_compile_flags  := -std=c++11 -m64 -Xcompiler -fPIC -g -w -gencode=arch=compute_86,code=sm_86 -O0   # 修改 
link_flags        := -pthread -fopenmp -Wl,-rpath='$$ORIGIN'

cpp_compile_flags += $(include_paths)
cu_compile_flags  += $(include_paths)
link_flags 		  += $(library_paths) $(link_librarys) $(paths)

ifneq ($(MAKECMDGOALS), clean)
-include $(cpp_mk) $(cu_mk)
endif

pro    : workspace/pro
trtpyc : python/trtpy/libtrtpyc.so

workspace/pro : $(cpp_objs) $(cu_objs)
	@echo Link $@
	@mkdir -p $(dir $@)
	@g++ $^ -o $@ $(link_flags)

python/trtpy/libtrtpyc.so : $(cpp_objs) $(cu_objs)
	@echo Link $@
	@mkdir -p $(dir $@)
	@g++ -shared $^ -o $@ $(link_flags)

objs/%.o : src/%.cpp
	@echo Compile CXX $<
	@mkdir -p $(dir $@)
	@g++ -c $< -o $@ $(cpp_compile_flags)

objs/%.cuo : src/%.cu
	@echo Compile CUDA $<
	@mkdir -p $(dir $@)
	@nvcc -c $< -o $@ $(cu_compile_flags)

objs/%.mk : src/%.cpp
	@echo Compile depends CXX $<
	@mkdir -p $(dir $@)
	@g++ -M $< -MF $@ -MT $(@:.mk=.o) $(cpp_compile_flags)
	
objs/%.cumk : src/%.cu
	@echo Compile depends CUDA $<
	@mkdir -p $(dir $@)
	@nvcc -M $< -MF $@ -MT $(@:.cumk=.o) $(cu_compile_flags)

demuxer : workspace/pro
	@cd workspace && ./pro demuxer
	
hard_decode : workspace/pro
	@cd workspace && ./pro hard_decode

yolo : workspace/pro
	@cd workspace && ./pro yolo

debug :
	@echo $(includes)

clean :
	@rm -rf objs workspace/pro python/trtpy/libtrtpyc.so python/build python/dist python/trtpy.egg-info python/trtpy/__pycache__
	@rm -rf workspace/single_inference
	@rm -rf workspace/scrfd_result workspace/retinaface_result
	@rm -rf workspace/YoloV5_result workspace/YoloX_result
	@rm -rf workspace/face/library_draw workspace/face/result
	@rm -rf build
	@rm -rf python/trtpy/libplugin_list.so

.PHONY : clean yolo alphapose fall debug

4.5 运行

Makefile 文件修改好后,执行在终端运行指令即可,主要有三个案例

  • make demuxer -j64
    • 解封装得到 h264 的包,并分析是什么帧

运行结果如下图所示:

在这里插入图片描述

图4-3 FFmpeg解封装

这个案例演示了如何使用 FFmpeg 库来对 mp4 格式视频文件进行解封装,并输出视频文件中的 NALU 数据。

在输出时,首先输出了 Extra Data(码流的第一个 NALU),其代表了 H.264 的序列参数集 (SPS) 和图像参数集 (PPS) 信息。然后对每个 NALU 数据包进行了循环处理,包括获取 NALU 的帧类型、NALU 数据的长度和时间戳。

  • make hard_decode -j64
    • 硬件解码测试

运行效果如下图所示:

在这里插入图片描述

图4-4 硬件解码测试

这个案例演示了如何利用 FFmpeg 和 NVIDIA 的 NVDEC 进行硬件解码测试,可以在输入视频文件中解析并解码出视频流数据,并将解码后的视频帧以图像的形式存储下来。具体实现过程包括以下几个步骤:

  1. 创建 FFmpeg 解封装器,打开输入视频文件
  2. 创建 NVIDIA 硬件解码器,获取解码器的配置信息
  3. 获取输入视频流数据,并将其送入解码器进行解码
  4. 解码后的数据为 YUV-NV12 格式,需要将其转换为 BGR 格式的图像,以便可以将其存储成图像文件
  5. 将解码后的视频帧以图像的形式存储下来

与第一个案例相比,该案例使用 NVDEC 进行硬件解码,其速度更快,而且可以充分利用 GPU 资源,提高解码效率。

  • make yolo -j64
    • Yolo和硬件解码直接对接

运行结果如下图所示:

在这里插入图片描述

图4-5 Yolo和硬解码对接

这个案例演示了如何利用 FFmpeg 和 NVDEC 对视频进行硬件解码,并将解码后的视频帧送入 YOLO 检测器进行检测,并输出检测结果。

首先,创建了一个 YOLO 检测器对象,并通过调用 create_ffmpeg_demuxercreate_cuvid_decoder 函数创建了 FFmpeg 解封包器和 NVIDIA 硬件解码器对象。接着,使用解封包器获取视频的头信息,并将其通过解码器进行解码。然后,在一个循环中,获取每一帧视频数据并解码,将解码后的视频帧作为输入,送入 YOLO 检测器进行检测,并输出检测结果。最后,将检测结果可视化后保存为图片。

从日志输出中,我们可以看到软件解码事件为 158.87ms,硬件解码事件为 310.50ms,为什么硬件解码还会比软件解码时间更长呢?

硬件解码通常比软件解码更快,但在这种情况下,可能是因为硬件解码加速器 NVDEC 不太适用于单路视频、小分辨率这样的情况。因此,在这种情况下,软件解码可能比硬件解码更快。当涉及到多路、高分辨率视频时,硬件加速器的优势更为明显。

我们可以做一个简单的试验,下面是对一个高分辨率的单路视频流进行检测,可以看到软件解码时间为 1056.77ms,硬件解码时间为 1096.46ms,二者差不多

在这里插入图片描述

图4-6 Yolo和硬解码对接(大分辨率下)

同时在 workspace/soft 和 workspace/hard 目录下分别储存着对应软硬件解码检测的结果,如下所示:

在这里插入图片描述

图4-7 检测效果图

结语

本篇博客主要和大家分享了一些视频流媒体相关的知识,并配置运行了 hard_decode_trt 硬件解码框架,对视频编码解码有了一个大概的认识,了解了编解码可以通过硬件来完成加速工作,当需要推理多路、大分辨率视频时效果非常显著。由于初学,博主在这里只做了最简单的实现,并没有对代码进行深究,需要各位看官自行去了解了😄。

感谢各位看到最后,创作不易,读后有收获的看官请帮忙点个👍

下载链接

参考

  • 8
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱听歌的周童鞋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值