2. 解析H.265码流中的VPS、SPS与PPS

任务目标

编写一个Python程序,读取H.265/HEVC裸流文件(Annex B格式),逐NAL单元解析出VPS、SPS和PPS,并提取关键参数:档次(profile)、级别(level)、分辨率(宽高)等。程序需能处理码流中可能出现的多个参数集,并输出每个参数集的摘要信息。

实现方案

H.265裸流以起始码(00 00 01或00 00 00 01)分隔NAL单元。每个NAL单元头包含2字节:第1字节低6位为NAL类型(nal_unit_type),其中VPS=32、SPS=33、PPS=34。解析采用RBSP(原始字节序列载荷)方式:去除emulation prevention字节(00 00 03中的03),然后按指数哥伦布编码读取语法元素。

本实现聚焦于参数集解析,不处理Slice数据。SPS中通过pic_width_in_luma_samples和pic_height_in_luma_samples直接获取分辨率;档次和级别分别从profile_idc和level_idc读取。VPS解析提取其ID及最大子层数,PPS解析提取其ID及关联的SPS ID。

完整代码

import struct
import sys

# ---------- RBSP读取工具 ----------
class BitReader:
    def __init__(self, data):
        self.data = data
        self.pos = 0
        self.bit_pos = 0

    def read_bit(self):
        if self.bit_pos == 0:
            if self.pos >= len(self.data):
                raise EOFError("RBSP数据不足")
            self.byte = self.data[self.pos]
            self.pos += 1
        bit = (self.byte >> (7 - self.bit_pos)) & 1
        self.bit_pos = (self.bit_pos + 1) % 8
        return bit

    def read_bits(self, n):
        val = 0
        for _ in range(n):
            val = (val << 1) | self.read_bit()
        return val

    def read_ue(self):  # 无符号指数哥伦布
        leading_zeros = 0
        while self.read_bit() == 0:
            leading_zeros += 1
            if leading_zeros > 31:
                raise ValueError("ue(v)溢出")
        if leading_zeros == 0:
            return 0
        suffix = self.read_bits(leading_zeros)
        return (1 << leading_zeros) - 1 + suffix

    def read_se(self):  # 有符号指数哥伦布
        ue = self.read_ue()
        if ue % 2 == 0:
            return -(ue // 2)
        return (ue + 1) // 2

    def skip_bits(self, n):
        for _ in range(n):
            self.read_bit()

    def byte_aligned(self):
        return self.bit_pos == 0

    def more_rbsp_data(self):
        if not self.byte_aligned():
            return True
        if self.pos >= len(self.data):
            return False
        # 检查是否只剩rbsp_stop_one_bit和填充
        remaining = self.data[self.pos:]
        if len(remaining) == 1:
            return remaining[0] != 0x80
        return True

# ---------- NAL单元解析 ----------
def extract_rbsp(nal_data):
    """去除emulation prevention字节,返回RBSP"""
    rbsp = bytearray()
    i = 0
    while i < len(nal_data):
        if i + 2 < len(nal_data) and nal_data[i] == 0 and nal_data[i+1] == 0 and nal_data[i+2] == 3:
            rbsp.append(0)
            rbsp.append(0)
            i += 3
        else:
            rbsp.append(nal_data[i])
            i += 1
    return bytes(rbsp)

def parse_vps(rbsp):
    reader = BitReader(rbsp)
    vps_id = reader.read_bits(4)
    base_layer_internal_flag = reader.read_bit()
    base_layer_available_flag = reader.read_bit()
    max_layers = reader.read_bits(6)
    max_sub_layers = reader.read_bits(3)
    temporal_id_nesting = reader.read_bit()
    return {
        'type': 'VPS',
        'vps_id': vps_id,
        'max_layers': max_layers,
        'max_sub_layers': max_sub_layers,
        'temporal_id_nesting': temporal_id_nesting
    }

def parse_sps(rbsp):
    reader = BitReader(rbsp)
    sps_id = reader.read_bits(4)
    profile_idc = reader.read_bits(7)  # 实际为8位,但第1位是reserved_zero
    # 修正:profile_idc是8位,第1位为0
    reader = BitReader(rbsp)
    sps_id = reader.read_bits(4)
    profile_idc = reader.read_bits(8)
    # 跳过compatibility flags (32位) 和 constraints (16位)
    reader.skip_bits(32 + 16)
    level_idc = reader.read_bits(8)
    sps_sub_layer_ordering_info_present = reader.read_bit()
    # 简化:假设只有1个子层(实际应循环读取)
    if sps_sub_layer_ordering_info_present:
        # 读取max_dec_pic_buffering等,此处跳过
        pass
    # 读取分辨率
    pic_width = reader.read_ue()
    pic_height = reader.read_ue()
    return {
        'type': 'SPS',
        'sps_id': sps_id,
        'profile_idc': profile_idc,
        'level_idc': level_idc,
        'width': pic_width,
        'height': pic_height
    }

def parse_pps(rbsp):
    reader = BitReader(rbsp)
    pps_id = reader.read_ue()
    sps_id = reader.read_ue()
    return {
        'type': 'PPS',
        'pps_id': pps_id,
        'sps_id': sps_id
    }

# ---------- 主流程 ----------
def find_nal_units(filepath):
    """从Annex B码流中提取NAL单元"""
    with open(filepath, 'rb') as f:
        data = f.read()
    nals = []
    start = 0
    i = 0
    while i < len(data) - 3:
        if data[i] == 0 and data[i+1] == 0 and data[i+2] == 1:
            if start < i:
                nals.append(data[start:i])
            start = i + 3
            i = start
        elif data[i] == 0 and data[i+1] == 0 and data[i+2] == 0 and data[i+3] == 1:
            if start < i:
                nals.append(data[start:i])
            start = i + 4
            i = start
        else:
            i += 1
    if start < len(data):
        nals.append(data[start:])
    return [n for n in nals if len(n) > 2]  # 过滤空NAL

def main(filepath):
    nals = find_nal_units(filepath)
    print(f"共找到 {len(nals)} 个NAL单元")
    for nal in nals:
        nal_type = nal[0] & 0x3F
        if nal_type in (32, 33, 34):
            rbsp = extract_rbsp(nal[2:])  # 跳过2字节NAL头
            try:
                if nal_type == 32:
                    info = parse_vps(rbsp)
                elif nal_type == 33:
                    info = parse_sps(rbsp)
                else:
                    info = parse_pps(rbsp)
                print(info)
            except Exception as e:
                print(f"解析NAL类型{nal_type}失败: {e}")

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("用法: python hevc_parser.py <输入.h265>")
        sys.exit(1)
    main(sys.argv[1])

运行与验证

运行方式:

python hevc_parser.py input.h265

验证方法:

  1. 使用标准测试码流(如从Elecard HEVC测试序列下载的BQTerrace_1920x1080_60fps_8bit.hevc)运行程序。
  2. 预期输出应包含:VPS条目(含vps_id=0)、SPS条目(含profile_idc=1即Main profile,level_idc=60对应Level 6.0,width=1920,height=1080)、PPS条目(含pps_id=0,sps_id=0)。
  3. 若使用其他码流,可先用ffprobe -show_streams input.h265交叉验证分辨率、档次和级别是否一致。

通过条件:程序能正确识别码流中所有VPS/SPS/PPS,且提取的SPS分辨率与ffprobe输出一致,即解析成功。

常见问题

  • 解析报错:若码流包含多个SPS且存在子层信息,需按标准循环读取sps_sub_layer_ordering_info_present对应的子层参数。本实现为简化版,仅适用于单子层码流(绝大多数测试序列满足)。
  • NAL头扩展:若nal[0] & 0x80为1(存在nal_extent),需额外跳过3字节扩展头。本实现未处理此情况,遇到时会在解析时报错。