从H.264/H.265裸码流解析SPS:手把手获取视频宽高与帧率 1. 项目概述为什么需要从码流中“挖”出视频参数做音视频开发或者处理过流媒体文件的朋友肯定都遇到过这样的场景你拿到一个视频文件或者一段网络流但它的容器信息比如MP4头、FLV Tag可能丢失、损坏或者你根本不想去解析容器只想快速地从最核心的压缩数据里拿到视频的基本信息。这时候直接去视频编码后的码流里“掏”参数就成了一个必备技能。这个项目要做的就是从H.264也叫AVC或H.265也叫HEVC的裸码流中解析出序列参数集SPS, Sequence Parameter Set并从中提取出视频的宽度、高度和帧率。这听起来像是一个简单的“解析”工作但里面藏着不少坑。SPS是一个语法结构它用指数哥伦布编码Exp-Golomb这种紧凑但有点“绕”的方式存储信息帧率更不是直接写在里面的需要结合其他信息计算。自己手写解析器既能让你彻底理解码流结构也能在那些现成库“失灵”或过于笨重的场合派上用场比如嵌入式设备、自定义协议分析或性能要求极高的场景。2. 核心概念与前置知识理解码流的“骨架”在动手写代码之前我们必须先搞清楚H.264/H.265码流是怎么组织的以及SPS到底是个什么角色。你可以把整个视频码流想象成一栋建筑而SPS就是这栋建筑的“结构蓝图”。2.1 NALU码流的基本单元无论是H.264还是H.265其码流都是由一个个NALUNetwork Abstraction Layer Unit串联而成的。每个NALU包含一个头部和一个载荷RBSP, Raw Byte Sequence Payload。头部信息通常是1或2个字节告诉我们这个NALU的类型。我们关心的SPS就是其中一种特定类型的NALU。H.264 NALU类型在NALU头部的后5位nal_unit_type。类型7代表SPS类型8代表PPSPicture Parameter Set图像参数集。H.265 NALU类型在NALU头部的第2个字节的后6位nal_unit_type。类型33代表VPSVideo Parameter Set类型34代表SPS类型35代表PPS。我们的第一步就是从连续的字节流中准确地识别和分离出类型为SPS的NALU。这里的关键是识别起始码0x000001或0x00000001并跳过NALU头部。2.2 SPS序列的“宪法”SPS里包含了决定一个视频序列如何被解码的所有全局参数。它不包含具体的像素数据但规定了这些数据该如何被理解。我们需要从中提取的关键信息有profile_idc 和 level_idc标识编码的规格档次和级别但这通常不影响宽高解析。pic_width_in_mbs_minus1 和 pic_height_in_map_units_minus1这是H.264中存储分辨率的核心字段。注意它们是以宏块Macroblock为单位的并且存储的是“值减1”。一个宏块在H.264中是16x16像素。frame_cropping帧裁剪偏移量。视频编码的宽度和高度可能是宏块大小的整数倍但实际显示区域可能小于这个值这就需要裁剪参数来调整。vui_parameters视频可用性信息。这是一个可选的语法结构但至关重要因为帧率信息就藏在VUI里。具体来说是vui_parameters下的timing_info_present_flag、num_units_in_tick、time_scale等字段。H.265的SPS结构更复杂字段名有所不同如pic_width_in_luma_samples,pic_height_in_luma_samples并且直接以亮度样本数表示宽高但解析思路是相通的定位SPS NALU按标准语法解析Exp-Golomb编码的各个字段。注意直接从文件或网络流中读取的码流可能是“Annex B”格式使用起始码分隔NALU也可能是“AVCC”格式在MP4等容器中使用长度前缀。本项目通常针对更常见的Annex B格式。如果是AVCC格式你需要先根据长度前缀读取NALU。3. 实战解析手把手拆解SPS获取宽高理论讲完了我们直接上干货。下面我将以H.264码流为例展示核心的解析步骤和代码逻辑。H.265的解析流程类似但语法表需要参照HEVC标准。3.1 第一步定位并提取SPS NALU假设我们有一个字节数组byte[] data里面是Annex B格式的H.264码流。def find_nalu_start(data, start_index0): 在数据中查找NALU起始码(0x000001或0x00000001)的位置。 i start_index data_len len(data) while i data_len: # 检查是否找到0x000001 (3字节) if i 2 data_len and data[i] 0 and data[i1] 0 and data[i2] 1: # 检查前面是否还有一个0构成4字节起始码 if i start_index and data[i-1] 0: return i - 1, 4 # 4字节起始码 else: return i, 3 # 3字节起始码 i 1 return -1, 0 # 未找到 def extract_sps_nalu(data): 从码流中提取第一个SPS NALU的RBSP数据去除了起始码和NALU头部。 sps_rbsp None i 0 while i len(data): start_pos, start_code_len find_nalu_start(data, i) if start_pos -1: break nalu_start start_pos start_code_len if nalu_start len(data): break # 获取NALU类型 (H.264) nal_header data[nalu_start] nal_unit_type nal_header 0x1F # 取后5位 # 查找下一个起始码以确定当前NALU的结束位置 next_start_pos, _ find_nalu_start(data, nalu_start) if next_start_pos -1: nalu_end len(data) # 最后一个NALU else: nalu_end next_start_pos # 提取NALU载荷包含头部 nalu_data data[nalu_start:nalu_end] if nal_unit_type 7: # SPS类型 # 获取RBSP: 去掉NALU头部(1字节)并进行防竞争字节(0x03)处理 rbsp bytearray() # 跳过NALU头部 payload nalu_data[1:] # 简单的防竞争字节移除遇到0x000003去掉0x03 j 0 while j len(payload): if j 2 len(payload) and payload[j] 0 and payload[j1] 0 and payload[j2] 3: rbsp.append(0) rbsp.append(0) j 3 else: rbsp.append(payload[j]) j 1 sps_rbsp bytes(rbsp) break # 找到第一个SPS就退出通常一个流里只有一个SPS i nalu_end # 继续查找下一个NALU return sps_rbsp这段代码完成了从原始字节流中定位SPS并提取其RBSP原始字节序列载荷的过程。注意0x000003的防竞争字节处理这是Annex B格式为了防止起始码在载荷中误出现而做的字节填充解析时必须去掉这个多余的0x03。3.2 第二步解析SPS RBSP中的指数哥伦布编码SPS中的参数大多采用无符号指数哥伦布编码ue(v)。解析它的核心是操作比特流。class BitStream: 一个简单的比特流读取工具类。 def __init__(self, data): self.data data 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)) 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): 读取n个比特组成一个整数。 val 0 for _ in range(n): bit self.read_bit() val (val 1) | bit return val def read_ue(self): 读取一个无符号指数哥伦布编码(ue(v))的值。 # 1. 读取前导0的个数 leading_zero_bits -1 b 0 while b 0: b self.read_bit() leading_zero_bits 1 # 2. 读取后面的信息位 info_bits self.read_bits(leading_zero_bits) # 3. 计算值codeNum 2^leading_zero_bits - 1 info_bits code_num (1 leading_zero_bits) - 1 info_bits return code_num def read_se(self): 读取一个有符号指数哥伦布编码(se(v))的值。 code_num self.read_ue() # 映射规则正值 (codeNum1)/2, 负值 -(codeNum/2) if code_num % 2: return (code_num 1) // 2 else: return -(code_num // 2) def more_data(self): 检查是否还有数据可读。 return self.byte_pos len(self.data)有了这个比特流工具我们就可以按照H.264标准协议ITU-T H.264建议书附录B中的语法表一步步解析SPS了。这个过程就像按照一份复杂的说明书组装家具必须严格按照顺序来。3.3 第三步按照语法表解析关键字段并计算宽高下面是解析SPS核心字段并计算宽高的关键代码段。注意这是一个高度简化的示例真实完整的SPS解析要处理几十个字段和条件判断。def parse_sps_and_get_resolution(sps_rbsp): 解析SPS RBSP返回宽度、高度及是否需要裁剪的标志。 if not sps_rbsp: return None, None, False bs BitStream(sps_rbsp) # 1. 解析profile_idc, constraint_set flags 等 profile_idc bs.read_bits(8) # 跳过 constraint_set0_flag 到 reserved_zero_5bits bs.read_bits(8) # constraint_set0_flag等 reserved level_idc bs.read_bits(8) # 2. 解析 seq_parameter_set_id seq_parameter_set_id bs.read_ue() # 3. 根据 profile_idc 处理 chroma_format_idc 等省略大量分支 # 这里假设为大多数常见情况如Main Profile chroma_format_idc 1 # 4:2:0 if profile_idc 100 or profile_idc 110 or profile_idc 122 or profile_idc 244: 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() bs.read_bit() # qpprime_y_zero_transform_bypass_flag seq_scaling_matrix_present_flag bs.read_bit() if seq_scaling_matrix_present_flag: # 循环读取 scaling_matrix这里为简化直接跳过 pass # 4. 解析 log2_max_frame_num_minus4 log2_max_frame_num_minus4 bs.read_ue() # 5. 解析 pic_order_cnt_type pic_order_cnt_type bs.read_ue() if pic_order_cnt_type 0: log2_max_pic_order_cnt_lsb_minus4 bs.read_ue() elif pic_order_cnt_type 1: bs.read_bit() # delta_pic_order_always_zero_flag bs.read_se() # offset_for_non_ref_pic bs.read_se() # offset_for_top_to_bottom_field num_ref_frames_in_pic_order_cnt_cycle bs.read_ue() for i in range(num_ref_frames_in_pic_order_cnt_cycle): bs.read_se() # offset_for_ref_frame[i] # 6. 解析 max_num_ref_frames max_num_ref_frames bs.read_ue() # 7. 解析 gaps_in_frame_num_value_allowed_flag bs.read_bit() # 8. 关键解析图像宽度和高度以宏块为单位 pic_width_in_mbs_minus1 bs.read_ue() pic_height_in_map_units_minus1 bs.read_ue() # 9. 计算基础宽高宏块数 - 像素数 # 一个宏块 16像素 width_in_mbs pic_width_in_mbs_minus1 1 height_in_map_units pic_height_in_map_units_minus1 1 # 对于帧编码frame_mbs_only_flag 为1时高度就是 map_units # 对于场编码需要乘以2。这里先假设 frame_mbs_only_flag 1 frame_mbs_only_flag 1 # 实际上需要读取 frame_mbs_only_flag # 为了流程完整我们在这里插入一个“伪读取”实际代码需要根据前面逻辑判断 # 假设我们已经读取了 frame_mbs_only_flag 并且其值为1 # frame_mbs_only_flag bs.read_bit() if frame_mbs_only_flag: height_in_mbs height_in_map_units else: height_in_mbs height_in_map_units * 2 base_width width_in_mbs * 16 base_height height_in_mbs * 16 # 10. 解析帧裁剪偏移量 frame_cropping_flag bs.read_bit() crop_left crop_right crop_top crop_bottom 0 if frame_cropping_flag: frame_crop_left_offset bs.read_ue() frame_crop_right_offset bs.read_ue() frame_crop_top_offset bs.read_ue() frame_crop_bottom_offset bs.read_ue() # 计算最终显示宽高 # 裁剪偏移量是以像素为单位吗不是以亮度样本为单位对于4:2:0水平和垂直方向需要根据chroma_format_idc调整 # 简化计算对于 chroma_format_idc1 (4:2:0)偏移量需要乘以2 sub_width_c sub_height_c 2 # 4:2:0的色度子采样 if chroma_format_idc 1: # 4:2:0 sub_width_c 2 sub_height_c 2 elif chroma_format_idc 0: # 单色 sub_width_c sub_height_c 1 elif chroma_format_idc 2: # 4:2:2 sub_width_c 2 sub_height_c 1 else: # 4:4:4 sub_width_c sub_height_c 1 crop_left frame_crop_left_offset * sub_width_c crop_right frame_crop_right_offset * sub_width_c crop_top frame_crop_top_offset * sub_height_c crop_bottom frame_crop_bottom_offset * sub_height_c # 最终显示分辨率 display_width base_width - crop_left - crop_right display_height base_height - crop_top - crop_bottom # 11. 返回结果以及是否启用了裁剪的标志 has_cropping frame_cropping_flag 1 return display_width, display_height, has_cropping实操心得上面代码为了清晰省略了大量条件判断如根据profile_idc判断是否解析色度格式等。在实际开发中强烈建议直接参考官方标准文档的语法表或者使用成熟的开源库如FFmpeg的libavcodec作为对照。自己实现一遍解析器是为了理解原理但在生产环境中直接调用avcodec_parameters_from_context等API更可靠。4. 难点攻克如何从SPS中计算帧率这是本项目最大的一个坑也是很多初学者困惑的地方。SPS里并不直接存储一个叫做“frame_rate”的字段。帧率信息存储在可选的VUIVideo Usability Information参数集中而且是以一种“分数”形式存在的。4.1 定位并解析VUI参数在SPS语法表中在解析完frame_cropping_flag等字段后会有一个vui_parameters_present_flag。如果这个标志位为1后面跟着的就是VUI参数。def parse_vui_and_get_framerate(bit_stream): 从比特流中解析VUI参数并尝试计算帧率。返回帧率fps如果无法计算则返回None。 # 注意此函数假设 bit_stream 当前指针位于 vui_parameters 的起始位置 # 1. aspect_ratio_info_present_flag aspect_ratio_info_present_flag bit_stream.read_bit() if aspect_ratio_info_present_flag: aspect_ratio_idc bit_stream.read_bits(8) if aspect_ratio_idc 255: # Extended_SAR bit_stream.read_bits(16) # sar_width bit_stream.read_bits(16) # sar_height # 2. overscan_info_present_flag if bit_stream.read_bit(): # overscan_info_present_flag bit_stream.read_bit() # overscan_appropriate_flag # 3. video_signal_type_present_flag if bit_stream.read_bit(): # video_signal_type_present_flag bit_stream.read_bits(3) # video_format bit_stream.read_bit() # video_full_range_flag if bit_stream.read_bit(): # colour_description_present_flag bit_stream.read_bits(8) # colour_primaries bit_stream.read_bits(8) # transfer_characteristics bit_stream.read_bits(8) # matrix_coefficients # 4. chroma_loc_info_present_flag if bit_stream.read_bit(): # chroma_loc_info_present_flag bit_stream.read_ue() # chroma_sample_loc_type_top_field bit_stream.read_ue() # chroma_sample_loc_type_bottom_field # 5. 关键 timing_info_present_flag timing_info_present_flag bit_stream.read_bit() fps None if timing_info_present_flag: num_units_in_tick bit_stream.read_bits(32) time_scale bit_stream.read_bits(32) fixed_frame_rate_flag bit_stream.read_bit() # 计算帧率 if time_scale 0 and num_units_in_tick 0: # 公式time_scale / (2 * num_units_in_tick) # 为什么是2倍这是H.264标准定义的时钟频率关系。 fps time_scale / (2.0 * num_units_in_tick) # 注意fixed_frame_rate_flag 指示帧率是否恒定但不影响这个基础计算。 # 6. 继续解析后面的字段nal_hrd_parameters, vcl_hrd_parameters等... # ... 这里省略后续解析代码因为不影响帧率计算 return fps在主解析函数parse_sps_and_get_resolution的最后加上对VUI的解析# ... 接前面解析完 frame_cropping_flag 的代码 ... # 12. 解析 vui_parameters_present_flag vui_parameters_present_flag bs.read_bit() fps None if vui_parameters_present_flag: fps parse_vui_and_get_framerate(bs) # 最终返回宽度高度是否裁剪帧率 return display_width, display_height, has_cropping, fps4.2 理解帧率计算公式的陷阱公式fps time_scale / (2 * num_units_in_tick)是H.264的标准算法。但这里有三个关键点容易出错单位num_units_in_tick和time_scale都是32位无符号整数。time_scale表示一秒钟被分成了多少个时间单位tick而num_units_in_tick表示每个时钟单元包含多少个基本时间单位。它们的比值定义了时钟的精度。为什么乘以2这与H.264的时钟模型有关。在VUI的时序信息中num_units_in_tick通常对应着“两个连续字段field的时间间隔”所包含的基本单位数。在逐行扫描progressive且fixed_frame_rate_flag为1的情况下一帧frame的时间间隔就等于2 * num_units_in_tick个基本单位。因此每秒的帧数就是time_scale / (2 * num_units_in_tick)。可能为None很多编码器在生成码流时并不会写入VUI时序信息timing_info_present_flag为0。这种情况下你无法从SPS中获取帧率。此时帧率需要通过其他方式确定例如解析容器层信息如MP4的mvhdbox中的timescale和duration。通过分析相邻帧的PTSPresentation Time Stamp差值来计算。依赖外部输入或默认值。注意事项fixed_frame_rate_flag是一个指示符。如果它为1表示帧率是恒定的计算出的fps就是实际帧率。如果它为0表示帧率可能变化比如VFR可变帧率此时计算出的fps更像是一个“标称帧率”或“最大帧率”实际帧间隔需要看每一帧的PTS。5. H.265HEVCSPS解析的关键差异H.265的解析整体流程与H.264相似但语法细节有不少变化。如果你需要支持HEVC以下几点是必须注意的NALU类型不同SPS的nal_unit_type是34。分辨率字段更直接H.265的SPS中直接使用pic_width_in_luma_samples和pic_height_in_luma_samples来表示以亮度样本为单位的宽和高。不需要再乘以16宏块大小。这简化了计算。# H.265 SPS 解析片段示例 pic_width_in_luma_samples bs.read_bits(16) # 注意可能需要根据条件读取 pic_height_in_luma_samples bs.read_bits(16) # 然后同样需要处理 conformance_window_flag 进行裁剪裁剪参数H.265中对应的是conf_win_left_offset,conf_win_right_offset,conf_win_top_offset,conf_win_bottom_offset。计算显示宽高时直接用总样本数减去左右/上下偏移量之和即可。VUI位置H.265的VUI参数位于SPS的vui_parameters语法结构中解析逻辑与H.264类似帧率计算公式相同fps time_scale / (2 * num_units_in_tick)。语法更复杂H.265的SPS包含更多关于Tile、波前并行处理WPP等高级特性的参数解析时需要跳过的字段更多。给你的建议是实现一个通用的解析框架然后针对H.264和H.265分别实现其SPS语法表的解析函数。可以先从H.264开始彻底搞懂再扩展到H.265。6. 常见问题与调试技巧实录在实际操作中你肯定会遇到各种奇怪的问题。下面是我踩过的一些坑和解决方法。6.1 问题一解析出来的宽高是0或者明显不对可能原因1起始码定位错误。码流中可能包含0x000000这样的数据被误认为是起始码。确保你的查找逻辑能正确处理0x000001和0x00000001并且不会在RBSP载荷中因防竞争字节0x03而错乱。可能原因2SPS NALU类型判断错误。检查NALU头部解析是否正确。H.264是取第一个字节的低5位H.265是取第二个字节的低6位。可能原因3指数哥伦布编码解析错误。这是最容易出错的地方。仔细检查你的read_ue()函数确保前导零计数和信息位读取的逻辑完全正确。可以写单元测试用标准文档中的例子验证例如codeNum3的比特串是00100。可能原因4未处理帧裁剪。计算出的base_width和base_height是正确的但显示尺寸需要减去裁剪偏移。务必检查frame_cropping_flag并正确应用偏移量计算。可能原因5profile_idc判断分支遗漏。高规格的Profile如High 4:2:2 Profile会在SPS中间插入额外的语法元素如chroma_format_idc,bit_depth_luma_minus8。如果你没有根据profile_idc跳过这些字段后续读取的比特流就会全部错位导致解析出的pic_width_in_mbs_minus1根本不是真正的宽度参数。最稳妥的方法是严格按照标准语法表实现一个完整的解析状态机。6.2 问题二帧率计算为None或值异常如5000fps帧率为None检查vui_parameters_present_flag和timing_info_present_flag。很多RTSP摄像头流或某些编码器输出的裸流就是不包含VUI时序信息的。这是正常情况需要你从容器或其他途径获取帧率。帧率异常大如几千fps检查num_units_in_tick和time_scale的值。有时编码器会写入错误的值。一个合理的帧率范围通常在1到120之间。如果计算出的值超出这个范围很可能是数据错误。可以加一个合理性校验比如if fps 120 or fps 1: fps None。帧率计算错误确认你使用了公式fps time_scale / (2.0 * num_units_in_tick)并且使用了浮点数除法。如果使用整数除法可能会得到0或1。6.3 问题三如何处理“Annex B”和“AVCC”两种格式Annex B使用0x000001或0x00000001作为NALU分隔符。常见于.ts文件、RTSP/RTP流、某些.h264/.265裸流文件。AVCC或称Length-Prefix每个NALU前有4字节或2字节的长度信息表示NALU的大小。常见于.mp4、.mov等容器。解决方法在解析前先判断格式。可以检查文件或数据的前几个字节。如果开头是0x000001很可能是Annex B。如果开头不是起始码并且前4个字节转换成的整数是一个合理的NALU大小比如几百到几万那么可能是AVCC格式。对于AVCC格式你需要循环读取读取4字节长度N - 读取接下来的N字节作为一个NALU - 继续。6.4 调试技巧与成熟工具对比这是最有效的验证方法。使用ffprobeFFmpeg工具分析你的视频文件ffprobe -v quiet -show_streams -select_streams v input.mp4查看输出的width,height,r_frame_rate平均帧率,avg_frame_rate平均帧率。使用Elecard StreamEye或CodecVisa等专业码流分析工具。它们可以图形化地展示NALU结构并直接解析出SPS中的所有字段值你可以与你程序解析的结果逐字段对比。将你的SPS NALU数据单独保存成一个文件例如sps.raw然后用FFmpeg解析ffmpeg -vcodec h264 -i sps.rawFFmpeg会输出它解析出的分辨率等信息虽然可能会报错没有视频流但SPS信息通常能正确解析。7. 完整代码整合与优化建议将上述所有步骤整合一个健壮的解析函数应该包含格式判断、错误处理、日志输出。这里给出一个整合的伪代码思路def get_video_info_from_stream(data): 从H.264/H.265裸流数据中获取视频信息。 返回: dict {‘codec‘: ‘h264‘/‘h265‘, ‘width‘: int, ‘height‘: int, ‘fps‘: float/None} # 1. 判断码流格式 (Annex B 或 AVCC) 和编码类型 (H.264 或 H.265) # 2. 根据格式提取SPS NALU的RBSP数据 # 3. 根据编码类型调用对应的SPS解析函数 # 4. 解析函数严格按照标准语法表实现返回宽、高、帧率、裁剪标志等 # 5. 进行合理性校验如宽高是否为正数帧率是否在合理范围内 # 6. 返回结果字典对于无法获取的信息设为None pass优化建议缓存结果同一个视频流的SPS通常不会改变。解析一次后可以将结果缓存起来避免重复计算。使用现成库对于生产环境除非有极致的性能或依赖要求否则推荐使用FFmpeg的libavcodec、openh264的解析器或pyavPython绑定。它们经过充分测试能处理各种边缘情况。# 使用pyav的示例 import av container av.open(‘input.h264‘) for stream in container.streams: if stream.type ‘video‘: print(f“Width: {stream.width}, Height: {stream.height}, FPS: {stream.average_rate}“)关注性能如果处理海量小文件纯Python解析可能成为瓶颈。可以考虑用C/C实现核心解析部分或使用C扩展库。这个项目虽然起点是解析SPS获取宽高和帧率但它真正带你深入了视频编码码流的内部世界。理解了这些你就能更从容地处理视频时间戳同步、码率分析、转码参数设置等更高级的任务。自己动手实现一遍下次再遇到视频参数相关的问题你就能一眼看穿本质而不是在黑盒API面前束手无策了。