ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

从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都采用了类似的码流结构,即NALU(Network Abstraction Layer Unit)单元。

一个原始的H.264/H.265字节流,是由一连串的NALU组成的。每个NALU由一个起始码(Start Code,通常是0x0000010x00000001)开头,后面跟着NALU的负载数据。NALU的头部有一个字节(H.264)或两个字节(H.265)用来表示NALU的类型。

我们需要关注的SPS,就是一种特定类型的NALU。在H.264中,SPS的nal_unit_type是7;在H.265中,SPS的nal_unit_type是33(VPS是32,PPS是34)。所以,解析流程的第一步,永远是:在字节流中定位起始码,然后读取NALU类型,找到类型为SPS的那个NALU

找到SPS NALU后,剥掉它的NALU头部(和可能的emulation_prevention_three_byte防竞争字节),剩下的就是我们需要解析的SPS RBSP(Raw 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的关键路径

  1. profile_idc, constraint_set_flag, level_idc*:先读取这些基本信息,它们决定了后续一些语法元素是否存在以及如何解释。例如,High Profile支持8x8DCT,会多出一个chroma_format_idc的标识。

  2. seq_parameter_set_id:通过read_ue()读取。这是SPS的ID,用于被PPS引用。

  3. chroma_format_idc:如果profile_idc等于100、110、122、244等,说明是High Profile及以上,需要读取chroma_format_idc。否则,默认为1(即4:2:0)。chroma_format_idc决定了色度分量的采样格式。

    • 如果chroma_format_idc等于3(4:4:4),还需要读取一个separate_colour_plane_flag,表示是否将Y、U、V三个分量分开编码。
  4. bit_depth_luma_minus8 与 bit_depth_chroma_minus8:同样,在High Profile等特定档次下,需要读取这两个值。实际的亮度/色度比特深度等于8 + bit_depth_*_minus8。默认为8。

  5. pic_order_cnt_type:读取log2_max_frame_num_minus4pic_order_cnt_type。帧率计算不直接依赖它们,但它们是解析过程中的必要步骤。

  6. max_num_ref_frames:通过read_ue()读取。

  7. gaps_in_frame_num_value_allowed_flag:读取1位。

  8. 至关重要的图像尺寸信息

    • 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
  9. 帧率计算的关键: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结构。

  1. 按顺序解析一系列标志位,如aspect_ratio_info_present_flagoverscan_info_present_flag等,根据标志位决定是否跳过对应的语法元素。这是一个“条件解析”的过程,必须严格按照标准表格的顺序进行。

  2. 找到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则表示可变帧率,此时计算出的帧率可视为平均帧率或最大帧率。

  3. 继续解析VUI中可能存在的其他信息,如NAL HRD参数、VCL HRD参数等,直到结束。

3.3 H.265 SPS解析的差异点

H.265的SPS结构更复杂,但核心逻辑相似。主要差异在于:

  • NALU类型:SPS的nal_unit_type是33。
  • 语法元素更多:增加了视频参数集(VPS)的引用、多图层、子图片等高级特性的参数。我们只关心基础信息。
  • 分辨率计算
    • pic_width_in_luma_samplespic_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:i+4] == start_code_4: start_len = 4 elif byte_stream[i:i+3] == start_code_3: start_len = 3 else: i += 1 continue i += start_len nal_start = i # 查找下一个起始码,确定当前NALU边界 while i < len(byte_stream): if i+4 <= len(byte_stream) and byte_stream[i:i+4] == start_code_4: break if i+3 <= len(byte_stream) and byte_stream[i:i+3] == 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 j+2 < len(nalu_data) and nalu_data[j] == 0 and nalu_data[j+1] == 0 and nalu_data[j+2] == 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_list

5. 避坑指南与实战经验分享

手动解析SPS听起来很酷,但坑也不少。下面是我在实际项目中总结的几个关键点和常见陷阱:

坑一:起始码与防竞争字节的处理起始码不一定是0x000001,也可能是0x00000001。你的查找逻辑必须同时处理3字节和4字节起始码。更隐蔽的是防竞争字节(emulation_prevention_three_byte)。编码器为了防止在NALU负载中出现连续的0x0000000x000001(会被误认为是起始码),会在连续两个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_idcbit_depth_*_minus8的解析。但如果你要处理高比特深度(10bit, 12bit)或4:4:4采样的专业视频,就必须正确解析这些字段,因为它们会影响后续任何像素级操作对内存大小的估计。

个人经验:在实际开发中,我通常不会从头实现完整的SPS解析器,而是依赖一些经过充分测试的开源库的核心函数。例如,FFmpeg的libavcodec库中的h264_parser.chevc_parser.c文件,包含了非常完整的SPS解析实现(函数如ff_h264_decode_seq_parameter_set)。我的做法是,在C/C++项目中直接链接FFmpeg,调用这些内部函数;在Python等语言中,可以寻找封装了这些底层实现的库(如pyav,它是FFmpeg的Python绑定)。自己实现的主要目的是为了学习和在极度受限的环境中使用。如果你决定自己实现,务必以标准文档为唯一依据,并准备大量的测试用例(可以用FFmpeg或MediaInfo生成各种编码参数的视频,提取出SPS NALU进行对比测试)。

返回列表