3. H.264帧类型识别与NAL单元分类

任务目标

编写一个Python程序,读取H.264裸码流(Annex B格式),逐字节解析NAL单元,输出每个NAL单元的类型名称、负载大小及起始偏移,并进一步识别出I帧、P帧、B帧(即包含SPS/PPS/IDR/非IDR slice的NAL单元)。

环境依赖

仅需Python 3.6+标准库,无第三方依赖。输入文件为H.264 Annex B格式码流(以00 00 01或00 00 00 01作为起始码)。

关键完整代码

import sys
import os
from collections import namedtuple

NALUnit = namedtuple('NALUnit', ['offset', 'size', 'nal_type', 'frame_type'])

# H.264 NAL单元类型定义(表7-1)
NAL_TYPES = {
    1: '非IDR Slice (P/B帧)', 2: 'Slice分区A', 3: 'Slice分区B',
    4: 'Slice分区C', 5: 'IDR Slice (I帧)', 6: 'SEI', 7: 'SPS',
    8: 'PPS', 9: 'AUD', 10: '序列结束', 11: '码流结束', 12: '填充'
}

# slice_type映射(表7-6):2,4,7,9 → I帧;0,3,5,8 → P帧;1,6 → B帧
SLICE_TYPE_MAP = {
    0: 'P帧', 1: 'B帧', 2: 'I帧', 3: 'P帧', 4: 'I帧',
    5: 'P帧', 6: 'B帧', 7: 'I帧', 8: 'P帧', 9: 'I帧'
}

def find_start_codes(data):
    """返回所有起始码的偏移位置(含3字节和4字节起始码)"""
    positions = []
    i = 0
    while i < len(data) - 3:
        if data[i:i+3] == b'\x00\x00\x01':
            positions.append(i)
            i += 3
        elif data[i:i+4] == b'\x00\x00\x00\x01':
            positions.append(i)
            i += 4
        else:
            i += 1
    return positions

def parse_nal_header(byte):
    """解析NAL头: forbidden_zero_bit(1) | nal_ref_idc(2) | nal_unit_type(5)"""
    nal_type = byte & 0x1F
    nal_ref_idc = (byte & 0x60) >> 5
    return nal_type, nal_ref_idc

def get_frame_type(nal_payload, nal_type):
    """从slice头中提取slice_type,判断帧类型"""
    if nal_type not in (1, 5):
        return None
    # 解析slice_header:first_mb_in_slice使用ue(v),slice_type使用ue(v)
    # 简化实现:跳过first_mb_in_slice的Exp-Golomb编码
    i = 0
    # 读取第一个ue(v) —— first_mb_in_slice
    leading_zeros = 0
    while i < len(nal_payload) and nal_payload[i] == 0:
        leading_zeros += 1
        i += 1
    if i >= len(nal_payload):
        return None
    # 跳过first_mb_in_slice的剩余部分
    code_num = (1 << leading_zeros) - 1 + nal_payload[i]
    i += 1
    # 跳过first_mb_in_slice的额外信息位
    bits_to_skip = leading_zeros
    while bits_to_skip > 0:
        byte_idx = i + (bits_to_skip - 1) // 8
        if byte_idx >= len(nal_payload):
            return None
        bits_to_skip -= 1
    i = byte_idx + 1
    
    # 读取第二个ue(v) —— slice_type
    if i >= len(nal_payload):
        return None
    leading_zeros = 0
    while i < len(nal_payload) and nal_payload[i] == 0:
        leading_zeros += 1
        i += 1
    if i >= len(nal_payload):
        return None
    slice_type = (1 << leading_zeros) - 1 + nal_payload[i]
    return SLICE_TYPE_MAP.get(slice_type, '未知')

def analyze_h264(filepath):
    with open(filepath, 'rb') as f:
        data = f.read()
    
    start_positions = find_start_codes(data)
    nal_units = []
    
    for idx, pos in enumerate(start_positions):
        # 确定NAL单元结束位置(下一个起始码或文件末尾)
        if idx + 1 < len(start_positions):
            end = start_positions[idx + 1]
        else:
            end = len(data)
        
        # 跳过起始码
        if data[pos:pos+4] == b'\x00\x00\x00\x01':
            nal_start = pos + 4
        else:
            nal_start = pos + 3
        
        if nal_start >= end:
            continue
        
        nal_type, nal_ref_idc = parse_nal_header(data[nal_start])
        payload = data[nal_start+1:end]  # 去掉NAL头后的负载
        
        frame_type = get_frame_type(payload, nal_type)
        nal_units.append(NALUnit(
            offset=pos,
            size=end - pos,
            nal_type=nal_type,
            frame_type=frame_type
        ))
    
    return nal_units

def main():
    if len(sys.argv) != 2:
        print('用法: python h264_nal_parser.py <输入文件.h264>')
        sys.exit(1)
    
    filepath = sys.argv[1]
    if not os.path.exists(filepath):
        print(f'错误: 文件不存在 - {filepath}')
        sys.exit(1)
    
    nal_units = analyze_h264(filepath)
    
    print(f'文件: {filepath}')
    print(f'总大小: {os.path.getsize(filepath)} 字节')
    print(f'NAL单元总数: {len(nal_units)}\n')
    print(f'{"偏移":>10} {"大小":>8} {"类型":>5} {"描述":>25} {"帧类型":>10}')
    print('-' * 65)
    
    for nal in nal_units:
        desc = NAL_TYPES.get(nal.nal_type, f'未知({nal.nal_type})')
        frame = nal.frame_type if nal.frame_type else '-'
        print(f'{nal.offset:>10} {nal.size:>8} {nal.nal_type:>5} {desc:>25} {frame:>10}')

if __name__ == '__main__':
    main()

运行方式

python h264_nal_parser.py input.h264

验证方法

使用已知结构的H.264测试码流验证。例如,用ffmpeg生成测试文件:

ffmpeg -f lavfi -i testsrc=duration=1:size=320x240:rate=30 -c:v libx264 -g 15 test.h264

运行解析器后,预期输出应包含:

  • 至少一个SPS(类型7)和PPS(类型8)NAL单元,位于文件开头附近;
  • 首个图像帧为IDR Slice(类型5,帧类型标记为“I帧”);
  • 后续帧为类型1(非IDR Slice),帧类型为“P帧”或“B帧”(取决于编码配置);
  • 所有NAL单元的偏移与大小之和等于文件总大小。

判定通过条件:程序能正确输出每个NAL的类型编号与名称,且I/P/B帧标记与用ffprobe查看的帧类型一致(ffprobe -show_frames test.h264)。

常见问题

  • slice_type解析失败:若码流包含多个slice或使用了FMO(灵活宏块排序),本简化解析器可能无法正确读取slice_type。此时可改用ffprobe交叉验证帧类型。
  • 字节流包含00 00 01:某些码流内部可能包含伪起始码,但Annex B格式中起始码后紧跟的字节若为0x00或0x01则可能是伪起始码,本程序未处理此情况,对标准编码器输出通常无影响。