1. 解析H.264码流中的SPS与PPS

任务目标

读取H.264裸流文件(Annex B格式),定位并解析其中的SPS(序列参数集)和PPS(图像参数集),提取分辨率、帧率等关键编码参数,并以可读形式输出。

实现方案

H.264裸流以起始码 00 00 00 01 或 00 00 01 分隔NALU。每个NALU的第一个字节低5位为类型:7表示SPS,8表示PPS。SPS中通过pic_width_in_mbs_minus1和pic_height_in_map_units_minus1计算分辨率;帧率需结合SPS中的vui_parameters(若存在)中的timing_info推导。本程序使用Python实现,不依赖第三方库,直接对二进制位流进行解析。

完整代码

import struct
import sys

def read_ue(bit_reader):
    """读取无符号指数哥伦布编码"""
    leading_zeros = 0
    while bit_reader.read_bit() == 0:
        leading_zeros += 1
        if leading_zeros > 31:
            raise ValueError("无效的指数哥伦布编码")
    if leading_zeros == 0:
        return 0
    suffix = 0
    for _ in range(leading_zeros):
        suffix = (suffix << 1) | bit_reader.read_bit()
    return (1 << leading_zeros) - 1 + suffix

class BitReader:
    def __init__(self, data):
        self.data = data
        self.byte_pos = 0
        self.bit_pos = 0  # 当前字节内已消费的位数

    def read_bit(self):
        if self.byte_pos >= len(self.data):
            raise EOFError("位流读取越界")
        byte = self.data[self.byte_pos]
        bit = (byte >> (7 - self.bit_pos)) & 1
        self.bit_pos += 1
        if self.bit_pos == 8:
            self.bit_pos = 0
            self.byte_pos += 1
        return bit

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

def find_start_codes(data):
    """返回[(起始码位置, 起始码长度)],起始码为00 00 01或00 00 00 01"""
    positions = []
    i = 0
    while i < len(data) - 3:
        if data[i] == 0 and data[i+1] == 0:
            if data[i+2] == 1:
                positions.append((i, 3))
                i += 3
                continue
            elif i+3 < len(data) and data[i+2] == 0 and data[i+3] == 1:
                positions.append((i, 4))
                i += 4
                continue
        i += 1
    return positions

def parse_sps(nal_payload):
    """解析SPS,返回参数字典"""
    br = BitReader(nal_payload)
    profile_idc = br.read_bits(8)
    constraint_flags = br.read_bits(8)
    level_idc = br.read_bits(8)
    sps_id = read_ue(br)
    
    if profile_idc in (100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134):
        chroma_format_idc = read_ue(br)
        if chroma_format_idc == 3:
            separate_colour_plane_flag = br.read_bit()
        bit_depth_luma_minus8 = read_ue(br)
        bit_depth_chroma_minus8 = read_ue(br)
        qpprime_y_zero_transform_bypass_flag = br.read_bit()
        seq_scaling_matrix_present_flag = br.read_bit()
        if seq_scaling_matrix_present_flag:
            # 简化处理:跳过缩放矩阵
            for _ in range(8 if chroma_format_idc != 3 else 12):
                if br.read_bit():
                    size = 16 if _ < 6 else 64
                    for __ in range(size):
                        read_ue(br)
    
    log2_max_frame_num_minus4 = read_ue(br)
    pic_order_cnt_type = read_ue(br)
    if pic_order_cnt_type == 0:
        log2_max_pic_order_cnt_lsb_minus4 = read_ue(br)
    elif pic_order_cnt_type == 1:
        delta_pic_order_always_zero_flag = br.read_bit()
        offset_for_non_ref_pic = read_ue(br)
        offset_for_top_to_bottom_field = read_ue(br)
        num_ref_frames_in_pic_order_cnt_cycle = read_ue(br)
        for _ in range(num_ref_frames_in_pic_order_cnt_cycle):
            read_ue(br)
    
    max_num_ref_frames = read_ue(br)
    gaps_in_frame_num_value_allowed_flag = br.read_bit()
    pic_width_in_mbs_minus1 = read_ue(br)
    pic_height_in_map_units_minus1 = read_ue(br)
    frame_mbs_only_flag = br.read_bit()
    if not frame_mbs_only_flag:
        mb_adaptive_frame_field_flag = br.read_bit()
    direct_8x8_inference_flag = br.read_bit()
    frame_cropping_flag = br.read_bit()
    crop_left = crop_right = crop_top = crop_bottom = 0
    if frame_cropping_flag:
        crop_left = read_ue(br)
        crop_right = read_ue(br)
        crop_top = read_ue(br)
        crop_bottom = read_ue(br)
    
    width = (pic_width_in_mbs_minus1 + 1) * 16
    height = (2 - frame_mbs_only_flag) * (pic_height_in_map_units_minus1 + 1) * 16
    # 裁剪校正
    crop_unit_x = 1
    crop_unit_y = 2 - frame_mbs_only_flag
    width -= (crop_left + crop_right) * crop_unit_x
    height -= (crop_top + crop_bottom) * crop_unit_y
    
    vui_present = br.read_bit()
    fps = None
    if vui_present:
        aspect_ratio_info_present_flag = br.read_bit()
        if aspect_ratio_info_present_flag:
            aspect_ratio_idc = br.read_bits(8)
            if aspect_ratio_idc == 255:  # Extended_SAR
                br.read_bits(32)
        overscan_info_present_flag = br.read_bit()
        if overscan_info_present_flag:
            br.read_bit()
        video_signal_type_present_flag = br.read_bit()
        if video_signal_type_present_flag:
            br.read_bits(4)
            colour_description_present_flag = br.read_bit()
            if colour_description_present_flag:
                br.read_bits(24)
        chroma_loc_info_present_flag = br.read_bit()
        if chroma_loc_info_present_flag:
            read_ue(br)
            read_ue(br)
        timing_info_present_flag = br.read_bit()
        if timing_info_present_flag:
            num_units_in_tick = br.read_bits(32)
            time_scale = br.read_bits(32)
            fixed_frame_rate_flag = br.read_bit()
            if num_units_in_tick > 0:
                fps = time_scale / (2.0 * num_units_in_tick)
    
    return {
        'profile_idc': profile_idc,
        'level_idc': level_idc,
        'width': width,
        'height': height,
        'fps': fps,
        'frame_mbs_only_flag': frame_mbs_only_flag
    }

def parse_pps(nal_payload):
    """解析PPS,返回关键参数"""
    br = BitReader(nal_payload)
    pps_id = read_ue(br)
    sps_id = read_ue(br)
    entropy_coding_mode_flag = br.read_bit()
    bottom_field_pic_order_in_frame_present_flag = br.read_bit()
    num_slice_groups_minus1 = read_ue(br)
    if num_slice_groups_minus1 > 0:
        slice_group_map_type = read_ue(br)
        # 简化:仅记录类型,不展开映射
    num_ref_idx_l0_default_active_minus1 = read_ue(br)
    num_ref_idx_l1_default_active_minus1 = read_ue(br)
    weighted_pred_flag = br.read_bit()
    weighted_bipred_idc = br.read_bits(2)
    pic_init_qp_minus26 = read_ue(br)
    pic_init_qs_minus26 = read_ue(br)
    chroma_qp_index_offset = read_ue(br)
    deblocking_filter_control_present_flag = br.read_bit()
    constrained_intra_pred_flag = br.read_bit()
    redundant_pic_cnt_present_flag = br.read_bit()
    
    return {
        'pps_id': pps_id,
        'sps_id': sps_id,
        'entropy_coding_mode_flag': entropy_coding_mode_flag,
        'weighted_bipred_idc': weighted_bipred_idc
    }

def extract_nal_units(data, start_positions):
    """从起始码位置提取NALU列表 [(nal_type, payload)]"""
    nalus = []
    for idx, (pos, sc_len) in enumerate(start_positions):
        nal_start = pos + sc_len
        if idx + 1 < len(start_positions):
            nal_end = start_positions[idx+1][0]
        else:
            nal_end = len(data)
        # 去除防竞争字节 0x03
        raw = bytearray(data[nal_start:nal_end])
        cleaned = bytearray()
        zero_count = 0
        for b in raw:
            if zero_count >= 2 and b == 3:
                zero_count = 0
                continue
            cleaned.append(b)
            if b == 0:
                zero_count += 1
            else:
                zero_count = 0
        if len(cleaned) == 0:
            continue
        nal_type = cleaned[0] & 0x1F
        nalus.append((nal_type, bytes(cleaned[1:])))
    return nalus

def main(filepath):
    with open(filepath, 'rb') as f:
        data = f.read()
    
    starts = find_start_codes(data)
    if not starts:
        print("未找到起始码,文件可能不是H.264裸流")
        return
    
    nalus = extract_nal_units(data, starts)
    sps_list = []
    pps_list = []
    for nal_type, payload in nalus:
        if nal_type == 7:
            sps_list.append(parse_sps(payload))
        elif nal_type == 8:
            pps_list.append(parse_pps(payload))
    
    if not sps_list:
        print("未找到SPS")
        return
    if not pps_list:
        print("未找到PPS")
        return
    
    sps = sps_list[0]
    pps = pps_list[0]
    print(f"=== SPS 参数 ===")
    print(f"Profile: {sps['profile_idc']}, Level: {sps['level_idc']}")
    print(f"分辨率: {sps['width']} x {sps['height']}")
    if sps['fps']:
        print(f"帧率: {sps['fps']:.2f} fps")
    else:
        print("帧率: 未在SPS中声明(可能为可变帧率)")
    print(f"帧编码方式: {'逐行' if sps['frame_mbs_only_flag'] else '支持隔行'}")
    print(f"\n=== PPS 参数 ===")
    print(f"PPS ID: {pps['pps_id']}, 关联SPS ID: {pps['sps_id']}")
    print(f"熵编码模式: {'CABAC' if pps['entropy_coding_mode_flag'] else 'CAVLC'}")

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("用法: python h264_parser.py <文件路径>")
        sys.exit(1)
    main(sys.argv[1])

运行方式

python h264_parser.py test.h264

验证方法

使用已知参数的H.264裸流文件(如用ffmpeg生成:ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 -c:v libx264 -profile:v high test.h264),运行程序后应输出:

  • 分辨率显示为 1280 x 720(与编码设置一致);
  • 帧率显示为 30.00 fps(若SPS中声明了timing_info);
  • PPS关联的SPS ID为0(默认值)。

若文件不含VUI信息,帧率显示“未在SPS中声明”,属正常情况。可通过修改ffmpeg命令添加 -x264-params vui_timing_info=1 强制写入帧率信息进行验证。