4. H.265帧类型识别与NAL单元分类

任务目标

编写一个Python程序,读取H.265/HEVC裸码流(Annex B格式),逐字节解析NAL单元头,识别NAL类型,并根据VPS/SPS/PPS及关键帧规则区分I帧、P帧、B帧。

核心实现

import os
import sys

# HEVC NAL类型定义
NAL_TYPES = {
    0: 'TRAIL_N', 1: 'TRAIL_R', 2: 'TSA_N', 3: 'TSA_R',
    4: 'STSA_N', 5: 'STSA_R', 6: 'RADL_N', 7: 'RADL_R',
    8: 'RASL_N', 9: 'RASL_R', 10: 'BLA_W_LP', 11: 'BLA_W_RADL',
    12: 'BLA_N_LP', 13: 'IDR_W_RADL', 14: 'IDR_N_LP', 15: 'CRA_NUT',
    16: 'IRAP_VCL22', 17: 'IRAP_VCL23', 18: 'IRAP_VCL24', 19: 'IRAP_VCL25',
    20: 'IRAP_VCL26', 21: 'IRAP_VCL27', 22: 'IRAP_VCL28', 23: 'IRAP_VCL29',
    24: 'IRAP_VCL30', 25: 'IRAP_VCL31', 32: 'VPS', 33: 'SPS',
    34: 'PPS', 35: 'AUD', 36: 'EOS', 37: 'EOB', 38: 'FD',
    39: 'PREFIX_SEI', 40: 'SUFFIX_SEI', 41: 'AP', 42: 'UNSPEC42',
    43: 'UNSPEC43', 44: 'UNSPEC44', 45: 'UNSPEC45', 46: 'UNSPEC46',
    47: 'UNSPEC47', 48: 'UNSPEC48'
}

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
            elif data[i+2] == 0 and data[i+3] == 1:
                positions.append((i, 4))
                i += 4
            else:
                i += 1
        else:
            i += 1
    return positions

def parse_nal_header(byte1, byte2):
    """解析2字节NAL头,返回(nal_type, temporal_id, layer_id)"""
    forbidden = (byte1 & 0x80) >> 7
    nal_type = (byte1 & 0x7E) >> 1
    layer_id = ((byte1 & 0x01) << 5) | ((byte2 & 0xF8) >> 3)
    temporal_id = byte2 & 0x07
    return nal_type, temporal_id, layer_id

def classify_frame(nal_type):
    """根据NAL类型判断帧类型"""
    if nal_type in (13, 14):  # IDR_W_RADL, IDR_N_LP
        return 'I帧'
    elif nal_type == 15:      # CRA_NUT
        return 'I帧(CR)'
    elif nal_type in (0, 1):  # TRAIL_N/R
        return 'P/B帧(需参考前帧判断)'
    elif nal_type in (2, 3):  # TSA_N/R
        return 'P/B帧(TSA)'
    elif nal_type in (4, 5):  # STSA_N/R
        return 'P/B帧(STSA)'
    elif nal_type in (6, 7):  # RADL_N/R
        return 'B帧(RADL)'
    elif nal_type in (8, 9):  # RASL_N/R
        return 'B帧(RASL)'
    elif nal_type in (16, 17, 18, 19, 20, 21, 22, 23, 24, 25):
        return 'IRAP帧'
    else:
        return None  # 非VCL

def analyze_hevc(filepath):
    with open(filepath, 'rb') as f:
        data = f.read()

    starts = find_start_codes(data)
    if not starts:
        print('未找到起始码,文件可能不是Annex B格式')
        return

    nal_list = []
    for idx, (pos, sc_len) in enumerate(starts):
        nal_start = pos + sc_len
        if idx + 1 < len(starts):
            nal_end = starts[idx + 1][0]
        else:
            nal_end = len(data)

        if nal_end - nal_start < 2:
            continue

        byte1 = data[nal_start]
        byte2 = data[nal_start + 1]
        nal_type, temporal_id, layer_id = parse_nal_header(byte1, byte2)

        # 跳过填充字节(0x00)后的NAL
        if nal_type == 0 and byte1 == 0 and byte2 == 0:
            continue

        nal_list.append({
            'offset': pos,
            'size': nal_end - pos,
            'type_id': nal_type,
            'type_name': NAL_TYPES.get(nal_type, f'UNKNOWN_{nal_type}'),
            'temporal_id': temporal_id,
            'layer_id': layer_id
        })

    # 输出NAL统计
    print(f'文件: {filepath}')
    print(f'总大小: {len(data)} 字节, NAL单元数: {len(nal_list)}\n')
    print(f'{"序号":<6}{"偏移":<10}{"大小":<8}{"类型ID":<8}{"NAL类型":<18}{"时域ID":<8}{"帧分类"}')
    print('-' * 80)

    vcl_count = 0
    for i, nal in enumerate(nal_list):
        frame_class = classify_frame(nal['type_id'])
        if frame_class:
            vcl_count += 1
        print(f'{i:<6}{nal["offset"]:<10}{nal["size"]:<8}'
              f'{nal["type_id"]:<8}{nal["type_name"]:<18}'
              f'{nal["temporal_id"]:<8}{frame_class or "-"}')

    print(f'\nVCL NAL单元数: {vcl_count}')

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

运行与验证

  1. 准备输入:使用任意H.265裸码流文件(如用ffmpeg生成测试文件):
    ffmpeg -f lavfi -i testsrc=duration=2:size=320x240:rate=30 -c:v libx265 -x265-params log-level=error test.h265
  2. 运行程序:python hevc_nal_parser.py test.h265
  3. 通过条件:
    - 输出中应包含VPS(32)、SPS(33)、PPS(34)各至少一个;
    - 至少有一个IDR_W_RADL(13)或IDR_N_LP(14)类型的I帧;
    - 存在TRAIL_R(1)类型的P/B帧;
    - 所有NAL的偏移量递增且不重叠。

判定说明

HEVC不像H.264那样通过slice_type直接区分P/B帧。本程序通过NAL类型识别帧类型:IDR和CRA为I帧;RADL/RASL为B帧;TRAIL/TSA/STSA可能是P帧或B帧,需进一步解析slice_segment_header中的slice_type字段(值为0=B,1=P,2=I)才能精确区分。如需精确区分P/B,可在识别到TRAIL/TSA/STSA后,用解析器(如h265nal库)读取slice_type。