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 秒实验
- 把源改成 641 宽(需要重新编码),看 Y 平面的 linesize 变成多少。
- 用
reshape(h, linesize)再切片[:, :w],确认画面正过来。 - 把
int(fr.pict_type)改成str(fr.pict_type),看统计结果变成什么。
常见误区
- 以为帧数据就是「宽 × 高 一块内存」。 每行有填充,必须按 linesize 走。
- 以为
pict_type是字符。 它是整数,str()出来是"1"不是"I"。 - 以为
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。
- ☐ (本节完,下一节 04.05.03 案例:使用FFmpeg将YUV编码为H.264并封装为MP4)