📹 第四章 H.264/H.265视频编码并存储
📄 一句话概括

AVFrame 是解码的输出、编码的输入,它的每个字段都有明确含义。 最容易被忽略的是 linesize——它是所有「画面歪了」问题的源头。

📝 本节和前面几节的关系

上一节的案例里,帧只是「装着像素的容器」。这一节把容器拆开看。 行对齐这件事在 03.04.08(Qt 显示图像)已经踩过一次,这里是同一个根因。

一、九个常用字段

字段 含义 坑
width/height 画面尺寸 和 linesize 不是一回事
format 像素格式 编码器只收 yuv420p 等有限几种
pts 显示时间戳 单位是时间基,不是秒
pict_type 帧类型 整数:1=I 2=P 3=B
key_frame 是否关键帧 不等价于 IDR
color_range 黑白电平范围 全范围/有限范围,影响发灰
data/planes 各平面数据 每行有填充,不能整块读

第四行那个「整数」要注意:pict_type 不是字符串,str(1) 得到的是 "1" 而不是 "I", 比字符串会一直不相等。 这个坑不报错,只是统计出来「I 帧 0 个」——我第一次就踩了。

二、linesize:画面歪了的根源

一帧图像在内存里不是「紧挨着排」的,每行末尾可能有填充字节。

平面 linesize 真实宽 差
0(Y) 384 320 64 字节填充
1(U) 192 160 32 字节填充
2(V) 192 160 32 字节填充

填充是为了内存对齐(SIMD 指令要求地址按 16/32/64 对齐),它不参与显示。 所以「按 width 读整行」会读到填充,把填充当成像素画出去,画面就歪了—— 歪的程度和填充量成正比。

正确读法是用 linesize 当步长:每行只取前 width(或 width/2)个字节,剩下的一律跳过。 这条规矩不止 h264 有,所有像素格式都有——包括 Qt 的 QImage。

三、和 AVFrame 相关的五个 API

API 作用 什么时候调
av_frame_alloc 分配一个空壳 一次
av_frame_get_buffer 按格式分配真实内存 一次
av_frame_ref 引用计数 +1 要共享一帧时
av_frame_unref 引用计数 -1、清空内容 每帧用完
av_frame_free 释放壳和内存 结束时一次

ref 和 unref 是配对的:ref 一次就多一个持有者,引用计数到 0 才真释放。 监控是长跑程序,漏一次 unref 就是每帧漏几十 KB, 1080p 一帧 3 MB,几个小时就是几百 GB——所以这两个函数必须成对出现。

四、IDR 和 I 帧不是一回事

I 帧能独立解码,但 key_frame 为真不等于「能从这里重开」。 只有 IDR 帧才会「清空参考帧列表」,从它开始重新计数。

概念 能独立解 能清空参考帧
I 帧 是 不一定
IDR 帧 是 是

所以 seek 要找准 IDR,不是随便找个 I 帧——找错了画面会花一下再恢复, 用户看到的就是「拖进度条之后花屏零点几秒」。 x264 默认每个 GOP 开头都是 IDR,所以平时不用操心;开了 open-gop 之后两者才会分开。

五、动手 Demo:AVFrame 的字段,一个都不能猜

Demo 解一帧出来,把九个字段全打一遍,再单独把三个平面的 linesize 列出来。 pkt_dts 这个字段在本机读出来是空——这件事下一章(04.06.01)会再解释一次。

c = av.open("in.h264")
fr = next(c.decode(video=0))
print("尺寸", fr.width, fr.height, "格式", fr.format.name)
print("pts", fr.pts, "类型", int(fr.pict_type))
for i, p in enumerate(fr.planes):
    print("平面", i, "linesize", p.line_size,
          "宽", fr.width if i == 0 else fr.width // 2)

实跑输出:

--------------------------------------------------------------------
AVFrame 的字段,一个都不能猜
--------------------------------------------------------------------
**AVFrame 是解码的输出、编码的输入,它的每个字段都有明确含义。**
**这一节把常用的九个字段全打一遍。**

  字段            值                  说明
  --------------------------------------------------------------
  width/height   320 × 240           画面尺寸
  format          yuv420p             像素格式
  pts             None                显示时间戳(时间基单位)
  pkt_dts         —                   解码顺序时间戳
  pict_type       1                   1=I 2=P 3=B
  key_frame       True                是不是关键帧
  color_range     0                   黑白电平范围

  **`linesize` 这一项要单列**,它是所有「画面歪了」问题的源头:

  平面      linesize      宽        关系
  ------------------------------------------------------
  0         384           320       不等,有对齐填充
  1         192           160       不等,有对齐填充
  2         192           160       不等,有对齐填充

  **这三行全都不相等**——320 宽的 Y 平面,linesize 是 384,多出 64 字节。
  **那 64 字节是给 SIMD 对齐留的填充,不参与显示。**
  **所以「按 width 读整行」会读到填充,画面就歪了**——正确读法是用 `linesize` 当步长。
  **这不是 h264 的特征,是所有像素格式的通则**,和 3.4.8 讲的 QImage 行对齐是同一件事。

**几个容易搞错的字段:**

  字段            误解                          真相
  --------------------------------------------------------------------------------
  pts             毫秒                          时间基单位,要乘 `time_base`
  pkt_dts         和 pts 一样                   FFmpeg 8 已删,读出来是空
  key_frame       等价于 I 帧                   大体是,但 IDR 才是真随机访问点

  **第二行是本机实测的结果**:这个字段在 FFmpeg 8 里已经被移除,
  `getattr(fr, "pkt_dts", ...)` 拿到的是空值。
  **想看解码顺序只剩一条路:回包上看 `packet.dts`**,04.06.01 会给一张包表的实测。
  **第三行值得展开**:I 帧能独立解码,但**只有 IDR 帧才能「清空参考帧、从这里重开」**。
  **所以 seek 要找准 IDR,不是随便找个 I 帧**——找错了画面会花一下再恢复。

三行 linesize 全都不等于宽,这就是填充存在的证据。 如果你写 img = np.frombuffer(plane, np.uint8).reshape(h, w),画面就会斜——因为它按 w 切行, 而真实步长是 linesize。 pts 这一行读出来是 None,因为这是 h264 裸流,本来就不存时间戳。 换成 MP4 再解就有值了——这也是 04.05.03 的一个副产品。

30 秒实验

  1. 把源改成 641 宽(需要重新编码),看 Y 平面的 linesize 变成多少。
  2. 用 reshape(h, linesize) 再切片 [:, :w],确认画面正过来。
  3. 把 int(fr.pict_type) 改成 str(fr.pict_type),看统计结果变成什么。

常见误区

  1. 以为帧数据就是「宽 × 高 一块内存」。 每行有填充,必须按 linesize 走。
  2. 以为 pict_type 是字符。 它是整数,str() 出来是 "1" 不是 "I"。
  3. 以为 key_frame 为真就是 IDR。 只有 IDR 能清空参考帧,seek 要找的是 IDR。
自测三问(点开对答案) 1. 为什么 `linesize` 会大于宽? —— 为了内存对齐,方便 SIMD 指令。**填充字节不参与显示**,所以按宽读会歪。 2. `av_frame_ref` 和 `av_frame_unref` 是什么关系? —— 引用计数加减一。**ref 加、unref 减,减到 0 才释放**。长跑程序漏 unref 会吃满内存。 3. I 帧和 IDR 帧的区别是什么? —— 都能独立解码,**但只有 IDR 会清空参考帧列表**。所以 seek 要找 IDR。

下一节把编码和封装合起来:编完直接封成 MP4。

相关笔记