SPS与PPS:序列参数集详解、图像参数集详解、如何解析SPS/PPS

聊到H.264码流,有两个东西你绕不开——SPS(序列参数集)和PPS(图像参数集)。说白了,它们就是码流的「身份证」和「说明书」。没有它们,解码器连画面尺寸都不知道,更别提怎么解码了。

我记得刚入行那会儿,有一次解析一个监控摄像头传回来的视频流,画面全是花屏。折腾了半天,最后发现是SPS里的profile_idc字段被篡改了。嗯,从那以后,我每次解析码流都会先校验SPS的完整性。

SPS:序列参数集详解

SPS里装的是整个视频序列的全局信息。什么叫序列?就是一段连续的画面,从IDR帧开始到下一个IDR帧之前。SPS告诉解码器:这段视频的分辨率是多少、帧率大概多少、用了什么编码档次。

咱们来看看SPS里最关键的几个字段:

字段名 长度(bit) 说明
profile_idc 8 编码档次,66=Baseline,77=Main,100=High
level_idc 8 编码级别,如30表示Level 3.0
seq_parameter_set_id ue(v) SPS的ID,范围0-31
log2_max_frame_num_minus4 ue(v) 决定frame_num的位数
pic_order_cnt_type ue(v) POC计算方式,0/1/2三种
num_ref_frames ue(v) 参考帧数量上限
pic_width_in_mbs_minus1 ue(v) 宽度(宏块数-1)
pic_height_in_map_units_minus1 ue(v) 高度(宏块条数-1)

这里有个坑——pic_width_in_mbs_minus1的单位是宏块,不是像素。一个宏块是16x16像素。所以实际宽度 = (pic_width_in_mbs_minus1 + 1) * 16。我见过不少新手直接拿这个值当像素用,结果画面比例全错了。

实际分辨率计算:

宽度 = (pic_width_in_mbs_minus1 + 1) × 16

高度 = (pic_height_in_map_units_minus1 + 1) × 16

注意:如果是隔行扫描,高度还要乘以2。

还有一个字段叫vui_parameters_present_flag。如果这个标志为1,后面会跟着VUI(视频可用性信息)数据块。VUI里包含色度采样格式、视频全范围标志、色彩原色等信息。我个人习惯在解析SPS时,顺手把VUI也解析出来,因为后面做色彩空间转换时会用到。

PPS:图像参数集详解

PPS是针对单张图像的参数集。它依赖于SPS——每个PPS都会引用一个SPS ID。你可以把SPS理解成「全局配置」,PPS就是「局部配置」。

PPS里最重要的字段:

字段名 长度(bit) 说明
pic_parameter_set_id ue(v) PPS的ID,范围0-255
seq_parameter_set_id ue(v) 引用的SPS ID
entropy_coding_mode_flag 1 0=CABAC,1=CAVLC
num_slice_groups_minus1 ue(v) 片组数量-1
num_ref_idx_l0_active_minus1 ue(v) 前向参考帧索引数量
num_ref_idx_l1_active_minus1 ue(v) 后向参考帧索引数量
weighted_pred_flag 1 是否启用加权预测
deblocking_filter_control_present_flag 1 去块滤波控制标志

你想想看,entropy_coding_mode_flag这个字段决定了整个图像的熵编码方式。如果是0,用CAVLC;如果是1,用CABAC。CABAC压缩率更高,但计算量也更大。我在做嵌入式设备解码器时,就因为这个标志没处理好,导致解码速度跟不上——CABAC在低端芯片上实在太慢了。

个人经验:解析PPS时,一定要先确认引用的SPS ID是否有效。我曾经遇到过PPS引用了一个不存在的SPS ID,解码器直接崩溃。所以,建议在解析PPS时做一次SPS ID的合法性校验。

如何解析SPS/PPS

解析SPS和PPS,本质上就是按照H.264标准定义的语法,逐比特读取。H.264使用了一种叫指数哥伦布编码(Exp-Golomb)的变长编码方式,简称ue(v)和se(v)。

解析流程大致如下:

  1. 定位NAL单元:从码流中找到起始码(0x00000001或0x000001),提取NAL单元数据。
  2. 判断NAL类型:读取NAL头中的nal_unit_type,7=SPS,8=PPS。
  3. 解析SPS:按SPS语法逐字段读取,注意指数哥伦布编码的解码。
  4. 解析PPS:按PPS语法逐字段读取,注意引用SPS ID。
  5. 校验与缓存:将解析结果存入结构体,供后续解码使用。

下面是一个简化的SPS解析代码示例:

typedef struct {
    uint8_t profile_idc;
    uint8_t level_idc;
    uint32_t pic_width;
    uint32_t pic_height;
    uint8_t chroma_format_idc;
    uint8_t bit_depth_luma;
} SPS_t;

int parse_sps(uint8_t* data, int size, SPS_t* sps) {
    int bit_offset = 0;
    // 跳过NAL头(1字节)
    bit_offset += 8;
    
    // profile_idc
    sps->profile_idc = read_bits(data, &bit_offset, 8);
    
    // constraint_set0_flag ~ constraint_set3_flag
    read_bits(data, &bit_offset, 1); // constraint_set0_flag
    read_bits(data, &bit_offset, 1); // constraint_set1_flag
    read_bits(data, &bit_offset, 1); // constraint_set2_flag
    read_bits(data, &bit_offset, 1); // constraint_set3_flag
    
    // reserved_zero_4bits
    read_bits(data, &bit_offset, 4);
    
    // level_idc
    sps->level_idc = read_bits(data, &bit_offset, 8);
    
    // seq_parameter_set_id
    read_ue(data, &bit_offset);
    
    // 根据profile_idc决定是否解析chroma_format_idc
    if (sps->profile_idc == 100 || sps->profile_idc == 110 ||
        sps->profile_idc == 122 || sps->profile_idc == 244 ||
        sps->profile_idc == 44 || sps->profile_idc == 83 ||
        sps->profile_idc == 86 || sps->profile_idc == 118 ||
        sps->profile_idc == 128) {
        sps->chroma_format_idc = read_ue(data, &bit_offset);
        // ... 后续解析
    }
    
    // 解析分辨率
    read_ue(data, &bit_offset); // log2_max_frame_num_minus4
    read_ue(data, &bit_offset); // pic_order_cnt_type
    // ... 跳过一些字段
    uint32_t width_in_mbs = read_ue(data, &bit_offset) + 1;
    uint32_t height_in_mbs = read_ue(data, &bit_offset) + 1;
    
    sps->pic_width = width_in_mbs * 16;
    sps->pic_height = height_in_mbs * 16;
    
    return 0;
}

注意:指数哥伦布编码的读取函数(read_ue)需要自己实现。它的原理是:先数前导0的个数,然后读取后面的数据位。具体实现可以参考H.264标准文档的9.1节。

解析PPS的流程类似,但字段更少。我一般会把SPS和PPS的解析结果缓存起来,用一个哈希表管理。因为一个码流里可能有多个SPS和PPS,解码时需要根据slice头里的pic_parameter_set_id找到对应的PPS,再通过PPS找到对应的SPS。

下面这张图展示了SPS、PPS和Slice之间的引用关系:

SPS/PPS/Slice 引用关系图 SPS 序列参数集 ID: 0 PPS 图像参数集 ID: 0 → SPS ID: 0 Slice 片数据 PPS ID: 0 引用 引用 解析流程说明 1. 从码流中提取NAL单元,识别SPS(nal_unit_type=7)和PPS(8) 2. 解析SPS,获取分辨率、档次、级别等全局参数 3. 解析PPS,获取熵编码模式、参考帧数量等图像参数 4. 解码Slice时,通过PPS ID找到PPS,再通过SPS ID找到SPS

解析SPS/PPS时,有几个细节需要特别注意:

  • 起始码对齐:NAL单元数据是从起始码之后开始的,不要包含起始码本身。
  • 防止竞争条件:在SPS/PPS更新时,如果解码线程正在使用旧的参数集,需要加锁保护。
  • 参数集缓存:建议用数组或哈希表缓存所有解析过的SPS和PPS,因为后续的Slice可能会引用任意一个。
  • 异常处理:遇到非法字段值时,不要直接崩溃,应该尝试跳过或使用默认值。

调试技巧:如果你在解析码流时遇到花屏或解码失败,第一步就是打印SPS和PPS的解析结果。看看分辨率是不是0?profile_idc是不是合法?我遇到过好几次,问题就出在SPS解析时比特流偏移没算对,导致后面所有字段都错位了。

最后说一句,解析SPS/PPS是H.264码流处理的基石。你后面做封装格式(MP4、TS、FLV)时,很多元数据都是从SPS/PPS里提取的。比如MP4的avcC box,里面就完整保存了SPS和PPS的原始数据。所以,把这一步吃透了,后面的路就好走了。