🎬 第九章 FFplay源码剖析及音视频同步
📄 一句话概括

sws_scale 一件事干两件活:换像素格式,顺带改尺寸。 它在实战里常常比解码还贵,而省掉它的办法只有一个:别转,让显卡转。

📝 本节和前面几节的关系

09.04 拿到了 AVFrame,但那个帧的格式是解码器说了算的,不一定是渲染器想要的。 这一节讲这一层「翻译」,第九章里它是唯一一个「纯计算、没有状态」的环节——所以也是最容易量清楚的。

一、七个参数,两个用途

sws_getContext 的参数看着多,其实分两组:

组 参数 作用
来源 宽、高、像素格式 输入长什么样
目标 宽、高、像素格式 输出要什么样
算法 flags 缩放用哪种插值

两组参数一填,你就同时指定了「格式转换」和「尺寸缩放」。 所以「转换」和「缩放」在 FFmpeg 里是一个动作,不是两个。

这跟很多人的直觉不同:在图像库里 cvtColor 和 resize 是两个函数, 在 FFmpeg 里是一个。 理解这一点,你才不会去写「先转格式再缩放」的两步走——那等于算了两次。

flags 那一栏值得单独说: SWS_BILINEAR 是默认,快而够用;SWS_BICUBIC 更清晰但更慢;SWS_FAST_BILINEAR 最快但最糊。 ffplay 会根据「是否缩小」自动挑一个,这个细节说明作者对性能很在意。

二、line_size 不等于宽度

这是本节最容易让人算错一个数的地方。frame.linesize[0] 常常大于 frame.width:

概念 含义 典型值
width 一行的有效像素数 320
linesize[0] 一行的实际字节数 320 或 384

为什么会有差:因为 CPU 和 SIMD 指令喜欢「按 32 或 64 字节对齐」的内存, 所以每行末尾会补几个字节的空白。

算错会怎样:把 width 当步长去读,每行都会偏一点,整个画面斜着花掉。 这是「YUV 数据看起来像噪声」的经典原因之一。

更麻烦的是它不固定:同一个分辨率,不同编码器、不同对齐设置,linesize 可能不一样。 所以「按 320 算」这种硬编码,换台机器就崩。

三、为什么不要用 CPU 转 RGB

Demo 把五种目标格式的耗时列了一遍,结论比预期强烈得多:

「不转」(yuv420p)是最便宜的,而转成 rgb24 贵了几十倍。

为什么差这么多:转 RGB 要对每个像素做一次矩阵乘, 还要把色度上采样(4:2:0 的色度只有亮度的四分之一)。 而「不转」只是把三块平面读出来,几乎不计算。

所以省掉这一步的价值,就是几十倍的时间。 怎么省:让渲染器直接吃 YUV 纹理, 色彩转换交给显卡在采样时顺手做——GPU 做这件事几乎不要钱,因为它本来就在做纹理采样。

这也解释了本课第六章的一个实验结论:用 SDL_UpdateYUVTexture 时「没省时间」, 因为软件渲染器会把转换搬进渲染器里做——省的是「一次内存拷贝」,不是「一次转换」。 要真省下来,得用硬件渲染器。

四、gray 为什么反而最贵

Demo 里有一行特别反直觉:转成 gray 的字节数比 yuv420p 还少,耗时却最长。

「数据量小就快」在这里不成立。 决定快慢的是「走哪条转换路径」,不是「搬了多少字节」。

这条规律可以推广:优化转换不是「少转几个像素」,而是「别转」。 要灰度,就让解码器出灰度,要 RGB,就让显卡去转。 在 CPU 上「少搬一点」,收益基本为零。

这类「反直觉但可解释」的结果,是本课坚持实跑的原因: 如果只看文档,你会以为 gray 便宜;真跑一遍,才知道它贵在路径上。

五、2026 现状:硬解与 GPU 转换

书上那一套是「软解 + 软件转换 + 软件渲染」,全在 CPU 上。 现在更常见的组合是下面这两种:

| 组合 | 做法 | 代价 | |---|---| | 硬解 + 硬转 | 解码在 GPU,转格式也在 GPU | 兼容性差,格式支持有限 | | 软解 + 硬转 | CPU 解码,显卡做 YUV 采样 | 最常用,兼容性好 |

第二行是现在的标准做法,因为它把「兼容性」和「性能」折中得最好: 解码交给 CPU(什么格式都能解),转换交给显卡(免费)。

这也意味着 sws_scale 在现代播放器里应该「尽量少被调用」。 它只剩两个用途:一是缩放(分辨率不匹配时),二是实在没法交给显卡的老设备。

所以这一节的知识点要这样摆:先知道它怎么用,再知道它贵,最后知道它该被绕过。 三步都走完,才算真懂。

六、动手 Demo:图像格式转换——这一步为什么常被当成瓶颈

同一个帧,转成五种格式,量五种耗时。 这组数字能解释两件事:为什么播放器宁可要 YUV 纹理,以及为什么「字节数少」不等于「快」。

for fmt in ("yuv420p", "rgb24", "bgr24", "nv12", "gray"):
    t0 = time.perf_counter()
    for fr in frames:
        a = fr.to_ndarray(format=fmt)   # 走 sws_scale
    dt = (time.perf_counter() - t0) / len(frames)

实跑输出:

--------------------------------------------------------------------
图像格式转换——这一步为什么常被当成瓶颈
--------------------------------------------------------------------
**ffplay 里 `sws_scale` 被调用的次数跟解码一样多**——**每个要显示的视频帧都得过一次**,
**而且一次要处理几万到几十万个像素。**
**PyAV 的 `to_ndarray` 和 `reformat` 走的就是同一个 `sws_scale`。**

  目标格式     每帧耗时       单帧字节数     相对原帧
  -------------------------------------------------------
  yuv420p      0.052 毫秒     115200         1.00 倍
  rgb24        1.697 毫秒     230400         32.92 倍
  bgr24        1.412 毫秒     230400         27.40 倍
  nv12         1.299 毫秒     115200         25.20 倍
  gray         3.069 毫秒     76800          59.56 倍

**第一行 `yuv420p` 是「不转」的情况,它是最便宜的**。
**因为没有重采样,只是把已有的三块平面读出来。**

**转成 `rgb24` 贵了不止一点**,**因为每个像素要做一次 YUV 到 RGB 的矩阵乘**,
**还要做色度上采样(4:2:0 的色度只有亮度的四分之一)。**

**这就是「为什么不要用 CPU 转 RGB」的量化依据**:**这一整列时间,在真正的播放器里是可以省掉的。**
**只要让渲染器直接吃 YUV,色彩转换由显卡在采样时顺手做。**

**最后一行 `gray` 是最反直觉的一行**:**它的字节数比 `yuv420p` 还少(只保留 Y 平面)**,
**耗时却是最贵的一个。**
**所以「数据量小就快」在这里不成立**——**决定快慢的是「走哪条转换路径」,不是「搬了多少字节」。**

**这条规律放到工程上就是一句话**:**优化转换不是「少转几个像素」,而是「别转」**。
**要灰度就让它灰度解码,要 RGB 就让显卡去转**,**在 CPU 上「少搬一点」几乎没有收益。**

第一行是「不转」的基准,它只把三块平面读出来,几乎不算。 所以它最快,也是唯一「不该被优化掉」的那一行。

后面三行的倍数都在几十倍,这个量级足够解释「为什么播放器一转 RGB 就卡」。 注意这是 320×240——换成 1080p,像素多二十倍,这几行的耗时也要涨二十倍。

gray 那一行是全表最反直觉的:字节数最少,耗时最长。 所以「看字节数估时间」这个习惯在这里会骗人。

这张表的用处不是记数字,是记排序:「不转」远快于「转」, 而「转成哪种」之间的差异是次要的。 所以优化的第一步永远是「能不能不转」,不是「转哪个更便宜」。

最后交代一个测不到的东西:这里量的是 CPU 路径。 真机上如果用硬件渲染器直接吃 YUV,这一整列时间会消失——但那需要一块能跑起来的显卡和一块能显示的画面,沙箱都没有。 所以本节能证明的是「CPU 转换很贵」,证明不了「GPU 转换免费」——后者是硬件原理推出来的,不是这里量出来的。

30 秒实验

  1. 把帧改成 1280×720,看 rgb24 的耗时涨了几倍,是不是跟像素数成正比。
  2. 算一下:1080p、30 帧每秒,如果每帧转换要 2 毫秒,每秒要花多少 CPU 时间?
  3. 判断一下:linesize 大于 width 时,按宽度当步长读会看到什么现象?

常见误区

  1. 以为转换和缩放是两个函数。 sws_getContext 一次就同时指定了格式和尺寸。
  2. 把 width 当步长读数据。 必须用 linesize,否则每行偏一点,画面斜着花掉。
  3. 以为字节数少就快。 gray 字节最少却最慢,快慢取决于走哪条转换路径。
  4. 以为软件渲染下用 YUV 纹理就省了转换。 软件渲染器会把转换搬进渲染器,省的是拷贝不是计算。
自测三问(点开对答案) 1. `sws_getContext` 同时指定了哪两件事? —— 像素格式转换和尺寸缩放。所以「先转换再缩放」是算了两次。 2. `linesize` 为什么可能大于 `width`? —— 因为每行末尾要补齐到 32 或 64 字节对齐,方便 SIMD 指令访问。 3. 为什么「转 gray」反而比「转 rgb24」慢? —— 因为耗时取决于走哪条转换路径,不是数据量。这也说明优化方向应是「别转」而不是「少转」。

图像这条线走完了,接下来是声音。音频转换比图像更容易出错, 因为「输入样本数」和「输出样本数」不是一对一的,而且它还有一个「内部残留」的问题。

相关笔记