从H.264/H.265码流手动解析SPS:获取视频宽高与帧率的底层原理与实践

📅 2026/8/5 22:47:36
从H.264/H.265码流手动解析SPS:获取视频宽高与帧率的底层原理与实践
1. 项目缘起为什么需要从码流中“抠”出宽高帧率做音视频开发或者处理过流媒体文件的朋友肯定都遇到过这个场景你拿到一个视频文件或者一段网络流第一件事就是想搞清楚它的基本信息——分辨率是多少帧率有多高是逐行扫描还是隔行这些信息是后续一切处理的基础无论是播放、转码、分析还是存储都离不开它们。最直接的办法当然是调用现成的库比如FFmpeg的avformat_find_stream_info或者MediaInfo这类工具它们会给你一个封装好的结构体里面什么都有了。但在一些特定场景下这种“黑盒”操作就不太够用了。比如你正在开发一个嵌入式设备上的播放器资源极度紧张引入完整的解封装库太重了或者你正在写一个高性能的码流分析工具需要极致的解析速度跳过不必要的解码过程又或者你处理的可能是一段不完整的、被截断的码流头部通用解析器直接报错而你需要从中“抢救”出关键信息。这时候手动解析码流中的序列参数集Sequence Parameter Set, SPS就成了一个必备技能。SPS是H.264/H.265编码规范中的一部分它包含了描述整个视频序列而不仅仅是某一帧的核心参数。宽、高、帧率、编码档次、比特深度等信息都藏在里面。直接从二进制比特流中解读SPS就像直接阅读机器的“身份证”是最底层、最直接的方式。掌握它意味着你对视频码流的理解从“使用者”深入到了“解读者”的层面。2. 理解基石H.264/H.265的码流结构与SPS在动手解析之前我们必须先搞清楚我们要解析的东西在哪儿长什么样。H.264和H.265都采用了类似的码流结构即NALUNetwork Abstraction Layer Unit单元。一个原始的H.264/H.265字节流是由一连串的NALU组成的。每个NALU由一个起始码Start Code通常是0x000001或0x00000001开头后面跟着NALU的负载数据。NALU的头部有一个字节H.264或两个字节H.265用来表示NALU的类型。我们需要关注的SPS就是一种特定类型的NALU。在H.264中SPS的nal_unit_type是7在H.265中SPS的nal_unit_type是33VPS是32PPS是34。所以解析流程的第一步永远是在字节流中定位起始码然后读取NALU类型找到类型为SPS的那个NALU。找到SPS NALU后剥掉它的NALU头部和可能的emulation_prevention_three_byte防竞争字节剩下的就是我们需要解析的SPS RBSPRaw Byte Sequence Payload数据。这部分数据是按照指数哥伦布编码Exponential-Golomb Coding进行压缩的。这是一种变长编码用于高效编码小数值在视频编码中广泛应用。因此我们的解析器核心就是一个能够从比特流中读取指数哥伦布编码值的函数。注意直接从文件或网络中读取的字节流其NALU之间就是由起始码分隔的。但在一些传输协议如RTP或封装格式如MP4中NALU可能被封装在不同的结构中可能需要先进行解封装或去除额外的头部信息才能得到原始的NALU流。本文假设我们处理的是原始的、由起始码分隔的NALU字节流。3. 核心战场SPS语法元素的逐比特解析这是整个过程中最需要耐心和细心的部分。H.264和H.265的SPS语法定义在各自的标准文档ITU-T H.264建议书和H.265建议书中是一张非常长的表格描述了每个语法元素的顺序、名称和编码方式。我们不需要实现全部但必须准确实现提取宽、高、帧率所依赖的那一条路径。下面我将以H.264的SPS解析为主线说明关键步骤并指出H.265的主要差异。假设我们已经有了一个指向SPS RBSP数据开始位置的比特流读取器它可以按位读取并提供了read_bits(n)读取n个固定位和read_ue()读取一个无符号指数哥伦布编码值等方法。3.1 解析H.264 SPS的关键路径profile_idc, constraint_set_flag, level_idc*先读取这些基本信息它们决定了后续一些语法元素是否存在以及如何解释。例如High Profile支持8x8DCT会多出一个chroma_format_idc的标识。seq_parameter_set_id通过read_ue()读取。这是SPS的ID用于被PPS引用。chroma_format_idc如果profile_idc等于100、110、122、244等说明是High Profile及以上需要读取chroma_format_idc。否则默认为1即4:2:0。chroma_format_idc决定了色度分量的采样格式。如果chroma_format_idc等于34:4:4还需要读取一个separate_colour_plane_flag表示是否将Y、U、V三个分量分开编码。bit_depth_luma_minus8 与 bit_depth_chroma_minus8同样在High Profile等特定档次下需要读取这两个值。实际的亮度/色度比特深度等于8 bit_depth_*_minus8。默认为8。pic_order_cnt_type读取log2_max_frame_num_minus4和pic_order_cnt_type。帧率计算不直接依赖它们但它们是解析过程中的必要步骤。max_num_ref_frames通过read_ue()读取。gaps_in_frame_num_value_allowed_flag读取1位。至关重要的图像尺寸信息pic_width_in_mbs_minus1: 通过read_ue()读取。图像的宽度以宏块为单位等于(pic_width_in_mbs_minus1 1) * 16。因为一个宏块是16x16的亮度像素块。pic_height_in_map_units_minus1: 通过read_ue()读取。这里需要小心这个值表示的是“映射单元”的高度减1。一个映射单元的大小由frame_mbs_only_flag决定。frame_mbs_only_flag: 读取1位。如果为1表示视频全是帧编码逐行那么一个映射单元就是一个宏块行。此时图像高度以像素为单位等于(pic_height_in_map_units_minus1 1) * 16。如果frame_mbs_only_flag为0表示视频可能包含场隔行那么一个映射单元包含两个宏块行顶场和底场。此时图像高度等于(pic_height_in_map_units_minus1 1) * 32。帧率计算的关键vui_parameters读取direct_8x8_inference_flag。读取frame_cropping_flag。如果为1则需要读取frame_crop_left_offset,frame_crop_right_offset,frame_crop_top_offset,frame_crop_bottom_offset四个ue(v)值。最终显示宽度和高度需要减去这些裁剪偏移量。即DisplayWidth (pic_width_in_mbs_minus1 1) * 16 - (frame_crop_left_offset frame_crop_right_offset) * 2注意偏移量单位是亮度像素的2倍DisplayHeight (pic_height_in_map_units_minus1 1) * (16 * (2 - frame_mbs_only_flag)) - (frame_crop_top_offset frame_crop_bottom_offset) * 2读取vui_parameters_present_flag。这是获取帧率信息的唯一入口。如果该标志为0那么SPS中没有包含帧率信息你无法从码流本身得到确切的帧率。很多实时流或某些编码器输出的码流这个标志可能就是0。3.2 解析VUI参数获取帧率如果vui_parameters_present_flag为1我们进入vui_parameters结构。按顺序解析一系列标志位如aspect_ratio_info_present_flag、overscan_info_present_flag等根据标志位决定是否跳过对应的语法元素。这是一个“条件解析”的过程必须严格按照标准表格的顺序进行。找到timing_info_present_flag。如果它为1则帧率信息存在num_units_in_tick: 读取32位无符号整数。time_scale: 读取32位无符号整数。fixed_frame_rate_flag: 读取1位。帧率fps的计算公式为time_scale / (2 * num_units_in_tick)。 为什么是2倍这是标准定义。num_units_in_tick可以理解为时钟“滴答”一次的时间单位数而time_scale是一秒包含的时间单位数。对于逐行视频一帧图像对应两个“滴答”顶场和底场时间这里是个历史遗留定义记住公式即可。fixed_frame_rate_flag如果为1表示恒定帧率为0则表示可变帧率此时计算出的帧率可视为平均帧率或最大帧率。继续解析VUI中可能存在的其他信息如NAL HRD参数、VCL HRD参数等直到结束。3.3 H.265 SPS解析的差异点H.265的SPS结构更复杂但核心逻辑相似。主要差异在于NALU类型SPS的nal_unit_type是33。语法元素更多增加了视频参数集VPS的引用、多图层、子图片等高级特性的参数。我们只关心基础信息。分辨率计算pic_width_in_luma_samples和pic_height_in_luma_samples是直接以亮度像素为单位给出的不需要再乘以16或32。这比H.264直观得多。直接读取这两个ue(v)值即可。同样存在conformance_window_flag相当于H.264的frame_cropping_flag如果为真则需要读取裁剪偏移量并从上面的宽高中减去。帧率信息依然位于vui_parameters中其存在性由vui_parameters_present_flag指示帧率计算公式与H.264完全一样time_scale / (2 * num_units_in_tick)。档次、层与比特深度general_profile_idc,general_level_idc,bit_depth_luma_minus8,bit_depth_chroma_minus8等信息的解析位置和顺序与H.264有所不同需要严格按照H.265标准表格解析。4. 实战代码结构与关键函数示例理论说再多不如一行代码。下面我用Python伪代码勾勒出解析器的核心骨架并给出最关键的指数哥伦布解码和帧率计算函数。注意这是一个高度简化的示例用于说明流程完整的实现需要处理所有条件分支和错误情况。class BitStreamReader: def __init__(self, data_bytearray): self.data data_bytearray self.bit_pos 0 # 当前字节中的位偏移 self.byte_pos 0 # 当前字节索引 def read_bit(self): # 读取1位 if self.byte_pos len(self.data): raise EOFError bit (self.data[self.byte_pos] (7 - self.bit_pos)) 0x01 self.bit_pos 1 if self.bit_pos 8: self.bit_pos 0 self.byte_pos 1 return bit def read_bits(self, n): # 读取n位返回整数 val 0 for i in range(n): val (val 1) | self.read_bit() return val def read_ue(self): 读取无符号指数哥伦布编码值 leading_zero_bits -1 b 0 # 计算前导0的个数 while b 0: b self.read_bit() leading_zero_bits 1 # 读取剩下的位 if leading_zero_bits 0: rest self.read_bits(leading_zero_bits) code_num (1 leading_zero_bits) - 1 rest else: code_num 0 return code_num def parse_h264_sps(sps_rbsp_data): 解析H.264 SPS RBSP数据返回宽、高、帧率等信息字典 bs BitStreamReader(sps_rbsp_data) info {codec: h264} # 1. 解析固定头部 info[profile_idc] bs.read_bits(8) info[constraint_set_flags] bs.read_bits(8) # 实际是6个标志位2位保留 info[level_idc] bs.read_bits(8) info[sps_id] bs.read_ue() # 2. 处理档次相关参数简化仅考虑常见情况 # ... 解析 chroma_format_idc, bit_depth 等 # 假设这里是主流8bit 4:2:0跳过相关解析 if info[profile_idc] in [100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135]: chroma_format_idc bs.read_ue() if chroma_format_idc 3: bs.read_bit() # separate_colour_plane_flag bit_depth_luma_minus8 bs.read_ue() bit_depth_chroma_minus8 bs.read_ue() # ... 可能还有其他标志 # 3. 跳过一些必要但无关的参数 info[log2_max_frame_num_minus4] bs.read_ue() pic_order_cnt_type bs.read_ue() if pic_order_cnt_type 0: info[log2_max_pic_order_cnt_lsb_minus4] bs.read_ue() elif pic_order_cnt_type 1: # 读取delta相关标志位... pass info[max_num_ref_frames] bs.read_ue() bs.read_bit() # gaps_in_frame_num_value_allowed_flag # 4. 解析图像尺寸核心 pic_width_in_mbs_minus1 bs.read_ue() pic_height_in_map_units_minus1 bs.read_ue() frame_mbs_only_flag bs.read_bit() width_in_mbs pic_width_in_mbs_minus1 1 height_in_map_units pic_height_in_map_units_minus1 1 # 计算以像素为单位的编码尺寸 coded_width width_in_mbs * 16 if frame_mbs_only_flag: coded_height height_in_map_units * 16 else: coded_height height_in_map_units * 32 info[coded_width] coded_width info[coded_height] coded_height # 5. 处理裁剪 frame_cropping_flag bs.read_bit() crop_left crop_right crop_top crop_bottom 0 if frame_cropping_flag: crop_left bs.read_ue() crop_right bs.read_ue() crop_top bs.read_ue() crop_bottom bs.read_ue() # 注意裁剪偏移量单位是2倍的亮度像素 display_width coded_width - (crop_left crop_right) * 2 display_height coded_height - (crop_top crop_bottom) * 2 else: display_width coded_width display_height coded_height info[display_width] display_width info[display_height] display_height # 6. 解析VUI参数获取帧率核心 info[fps] None info[fixed_frame_rate] False vui_parameters_present_flag bs.read_bit() if vui_parameters_present_flag: # 这里需要按顺序解析VUI的所有可能字段我们只关心timing_info # 简化流程按标准顺序读取标志位直到遇到timing_info_present_flag或结束 # 假设我们跳过了前面的aspect_ratio等无关信息实际需要根据标志位判断 # ... 这里应有一个循环或条件判断来解析vui_parameters的各个部分 # 伪代码如果遇到 aspect_ratio_info_present_flag 为1则跳过 aspect_ratio_idc 或长宽比 # 如果遇到 overscan_info_present_flag 为1则跳过 overscan_appropriate_flag # ... 以此类推 # 我们简化地假设已经解析到 timing_info_present_flag timing_info_present_flag bs.read_bit() # 注意这个读取位置在实际解析中是由前面一系列标志位动态决定的 if timing_info_present_flag: num_units_in_tick bs.read_bits(32) time_scale bs.read_bits(32) fixed_frame_rate_flag bs.read_bit() if num_units_in_tick 0 and time_scale 0: info[fps] time_scale / (2.0 * num_units_in_tick) info[fixed_frame_rate] (fixed_frame_rate_flag 1) info[num_units_in_tick] num_units_in_tick info[time_scale] time_scale return info def parse_h265_sps(sps_rbsp_data): 解析H.265 SPS RBSP数据 bs BitStreamReader(sps_rbsp_data) info {codec: h265} # H.265 SPS开头有sps_video_parameter_set_id, sps_max_sub_layers_minus1等 # 解析过程类似但语法元素顺序和名称不同 # 关键步骤读取 general_profile_idc, general_level_idc, ... # 读取 pic_width_in_luma_samples, pic_height_in_luma_samples # 读取 conformance_window_flag 和可能的裁剪偏移 # 解析 vui_parameters 获取帧率逻辑与H.264相同 # ... return info def extract_info_from_nalu_stream(byte_stream): 从原始字节流中提取所有SPS并解析 start_code_3 b\x00\x00\x01 start_code_4 b\x00\x00\x00\x01 i 0 sps_list [] while i len(byte_stream): # 查找起始码 if byte_stream[i:i4] start_code_4: start_len 4 elif byte_stream[i:i3] start_code_3: start_len 3 else: i 1 continue i start_len nal_start i # 查找下一个起始码确定当前NALU边界 while i len(byte_stream): if i4 len(byte_stream) and byte_stream[i:i4] start_code_4: break if i3 len(byte_stream) and byte_stream[i:i3] start_code_3: break i 1 nal_end i nalu_data byte_stream[nal_start:nal_end] if not nalu_data: continue # 获取NALU类型 if len(nalu_data) 0: forbidden_zero_bit (nalu_data[0] 7) 1 nal_ref_idc (nalu_data[0] 5) 3 nal_unit_type nalu_data[0] 0x1F # H.264 # 对于H.265第一个字节的后6位是NAL单元类型 # h265_nal_unit_type (nalu_data[0] 1) 0x3F # 去除防竞争字节 (0x03) rbsp_data bytearray() j 1 # 跳过NALU头部 while j len(nalu_data): if j2 len(nalu_data) and nalu_data[j] 0 and nalu_data[j1] 0 and nalu_data[j2] 0x03: rbsp_data.append(0) rbsp_data.append(0) j 3 else: rbsp_data.append(nalu_data[j]) j 1 if nal_unit_type 7: # H.264 SPS info parse_h264_sps(rbsp_data) sps_list.append(info) # elif h265_nal_unit_type 33: # H.265 SPS # info parse_h265_sps(rbsp_data) # sps_list.append(info) return sps_list5. 避坑指南与实战经验分享手动解析SPS听起来很酷但坑也不少。下面是我在实际项目中总结的几个关键点和常见陷阱坑一起始码与防竞争字节的处理起始码不一定是0x000001也可能是0x00000001。你的查找逻辑必须同时处理3字节和4字节起始码。更隐蔽的是防竞争字节emulation_prevention_three_byte。编码器为了防止在NALU负载中出现连续的0x000000或0x000001会被误认为是起始码会在连续两个0x00字节后插入一个0x03。在解析RBSP数据前必须将这些插入的0x03字节删除否则你的比特流读取位置会完全错乱。上面的示例代码包含了简单的去防竞争字节逻辑。坑二VUI参数可能不存在这是最让人头疼的情况。vui_parameters_present_flag为0意味着没有帧率、宽高比等显示信息。此时你无法从码流中得到帧率。很多摄像头、屏幕录制软件生成的流或者某些编码配置下为了节省码率或简化就不写VUI。遇到这种情况要么依赖容器信息如MP4的mvhdbox或tkhdbox中的timescale和duration要么根据应用场景使用一个默认帧率如25或30但这显然不精确。坑三裁剪偏移量的单位在H.264中frame_crop_left_offset等值的单位不是像素而是2倍的亮度像素样本。计算显示尺寸时一定要乘以2。这个细节非常容易忽略导致计算出的分辨率莫名其妙少了几行/几列。H.265中的conformance_window偏移量单位也是类似的但具体定义需查标准。坑四指数哥伦布解码的边界处理read_ue()函数的实现必须非常健壮。前导零的计数可能很大后续读取的位数也相应变多。要确保比特流读取器在读取过程中不会越界并且能正确处理码流结束的情况。不健壮的解析器遇到损坏的SPS数据很容易崩溃。坑五H.264的frame_mbs_only_flag与场编码如果frame_mbs_only_flag为0表示视频可能包含场。此时计算出的coded_height是帧高度的两倍因为一个映射单元包含顶场和底场两个宏块行。但请注意这并不意味着显示高度要除以2。这个高度已经是整个帧的高度了。这个标志主要影响的是编码和解码过程中对场的处理方式不影响最终显示的像素行数。坑六比特深度与色度格式如果你只关心8bit 4:2:0的视频绝大多数情况可以忽略chroma_format_idc和bit_depth_*_minus8的解析。但如果你要处理高比特深度10bit, 12bit或4:4:4采样的专业视频就必须正确解析这些字段因为它们会影响后续任何像素级操作对内存大小的估计。个人经验在实际开发中我通常不会从头实现完整的SPS解析器而是依赖一些经过充分测试的开源库的核心函数。例如FFmpeg的libavcodec库中的h264_parser.c和hevc_parser.c文件包含了非常完整的SPS解析实现函数如ff_h264_decode_seq_parameter_set。我的做法是在C/C项目中直接链接FFmpeg调用这些内部函数在Python等语言中可以寻找封装了这些底层实现的库如pyav它是FFmpeg的Python绑定。自己实现的主要目的是为了学习和在极度受限的环境中使用。如果你决定自己实现务必以标准文档为唯一依据并准备大量的测试用例可以用FFmpeg或MediaInfo生成各种编码参数的视频提取出SPS NALU进行对比测试。