🎬 第五章 FFmpeg命令行实现音视频转码
📄 一句话概括

同样的画面存成 rgb24,字节是 yuv420p 的两倍:每像素 24 位对 12 位。 换来的是「不用查表、直接算」的方便。

📝 本节和前面几节的关系

上一节存的是 YUV,这一节存 RGB。两者字节差一倍,差别就在「色度有没有减半」。 这一节还会看清一个容易忽略的点:两种格式在内存里的排法完全不同。

一、两种格式在内存里长得不一样

同一帧,两套排法:

项 yuv420p rgb24


平面数 3 个 1 块交错 平面尺寸 160×90/80×45/80×45 90×160×3 取一个像素 三个平面各取一处 三个字节挨着 每像素 12 位 24 位

第三行是最实用的区别:RGB 里一个像素的三个分量是连着的, 所以 arr[y, x] 一次就拿到三个值;YUV 里要跳三个平面去取。

代价就是第二行那个「90×160×3」:内存按两倍准备,拷贝也是两倍。 做实时处理时,这个两倍经常就是「能不能跑满帧率」的分界线。

二、字节数还是能手算

RGB 也没有压缩,所以一样是「宽×高×每像素字节×帧数」:

项 公式 值


单帧字节 宽×高×3 43200 50 帧合计 单帧×帧数 2160000 实测文件 — 2160000

第二行与第三行分毫不差,和上一节的 YUV 是同一个道理。 两个数一比就是两倍:43200 对 21600,正好是 24 位对 12 位。

所以「解成 RGB 再处理」这句话的真实成本是明确的: 文件大一半、内存多一倍、每帧多拷一倍数据。省不掉,只能认。

三、来回转一圈,掉的是色度

把 yuv420p 转成 rgb24 再转回来,逐通道比误差:

通道 平均误差 最大误差


Y 亮度 0.68 3 U/V 色度 8.84 33

亮度几乎没动,误差全在色度上——和 5.2.2 里 420 的道理是同一个: 色度本来只有一半分辨率,转成 RGB 再转回来,等于又把它平均了一遍。

所以「转来转去」这件事要少做。转一次肉眼看不出来, 但每转一次色度就糊一点,压了再转、转了再压,几次下来就能看出来。

四、动手 Demo:裸存 rgb24,再看两套内存排法与往返误差

先把画面按 rgb24 裸存,对一遍字节数;再取一帧,看平面尺寸、数组形状,以及往返一趟的误差。 重点在第③段:误差到底落在亮度上还是色度上。

import av, os, numpy as np

c1 = av.open(u"o.rgb", u"w", format=u"rawvideo")
s1 = c1.add_stream(u"rawvideo", rate=25)
s1.width, s1.height, s1.pix_fmt = 160, 90, u"rgb24"
c0 = av.open(u"s5.mp4")
n = 0
for f in c0.decode(video=0):
    n += 1
    for o in s1.encode(f.reformat(width=160, height=90, format=u"rgb24")):
        c1.mux(o)
for o in s1.encode(None):
    c1.mux(o)
c1.close()
c0.close()
print(u"帧数 %d  应占 %d  实测 %d"
      % (n, 160 * 90 * 3 * n, os.path.getsize(u"o.rgb")))

c0 = av.open(u"s5.mp4")
f0 = next(c0.decode(video=0))
c0.close()
r = f0.reformat(format=u"rgb24").to_ndarray()
print(u"平面尺寸 %s" % [u"%dx%d" % (p.width, p.height) for p in f0.planes])
print(u"YUV 数组形状 %s" % (f0.to_ndarray().shape,))
print(u"RGB 数组形状 %s" % (r.shape,))

back = f0.reformat(format=u"rgb24").reformat(format=u"yuv420p")
d = np.abs(f0.to_ndarray().astype(np.int16) - back.to_ndarray().astype(np.int16))
print(u"亮度 平均 %.2f 最大 %d" % (d[:90].mean(), d[:90].max()))
print(u"色度 平均 %.2f 最大 %d" % (d[90:].mean(), d[90:].max()))

实跑输出:

--------------------------------------------------------------------
5.2.3 提取视频的 RGB 像素数据
--------------------------------------------------------------------

**① rgb24 裸存,字节数同样能手算**

  项            公式          值
  ------------------------------------
  单帧字节      宽×高×3       43200
  50 帧合计     单帧×帧数     2160000
  实测文件      —             2160000

**同一个画面,rgb24 是 yuv420p 的两倍:每像素 24 位对 12 位。**
**所以「解码成 RGB 再处理」这条路,内存与带宽都要按两倍准备。**

**② 两种格式在内存里的样子不一样**

  项                      值
  ---------------------------------------------
  原格式                  yuv420p
  平面数                  3
  平面尺寸                160×90/80×45/80×45
  转 RGB 后形状           90×160×3
  YUV 的 ndarray 形状     135×160

**YUV 是「分开存」:三个平面各自排,色度平面只有一半宽高,所以 to_ndarray **
**把它们竖着接成一整块,得到 135 行的数组(90+45+45)。**
**RGB 是「交叉存」:每个像素的三个分量挨着放,所以数组是「高×宽×3」。**

**③ 来回转一圈,掉的是哪一路**

  通道          平均误差     最大误差
  ------------------------------------
  Y 亮度        0.68         3
  U/V 色度     8.84         33

**亮度这一趟几乎没动,误差全落在色度上——和 420 的道理是同一个:**
**色度本来就只有一半分辨率,转成 RGB 再转回来,等于又把它平均了一遍。**
**所以「转来转去」这件事要少做:每转一次,色度就模糊一点,肉眼还不太看得出来。**

f0.to_ndarray() 对 yuv420p 返回的是一整块「三平面竖着接起来」的数组, 所以前 90 行是亮度,后 45 行是两个色度平面。索引一定要想清楚再切。

往返误差不是「代码有 bug」,而是 rgb24 的 8 位精度与色度的二分之一采样一起造成的。 只要中间经过一次 rgb24,就一定有这个量级的舍入。

想测得更干净,可以用 reformat(format=u"gbrp") 保持平面结构, 那个格式与 yuv444p 一样是三平面全采样,转起来信息损失最小。

30 秒实验

  1. 看字节:-pix_fmt rgb24 -f rawvideo 出.rgb,与 -pix_fmt yuv420p 的比大小。
  2. 换格式对比:把 rgb24 换成 bgr24,字节一样,但红蓝通道互换了。
  3. 转一次试试:-vf format=rgb24,format=yuv420p,看画质掉多少。

常见误区

  1. 以为 RGB 比 YUV 清晰。两者都是全采样亮度,RGB 多花的一倍字节买的是色度精度。
  2. 把 bgr24 当成 rgb24 用。字节数完全一样,红蓝会互换,人脸会变蓝。
  3. 以为转换是无损的。8 位 RGB 与 8 位 YUV 之间来回转,每次都有舍入。
自测三问(点开对答案) 1. rgb24 的每像素字节是多少?为什么是 yuv420p 的两倍? —— 3 字节。yuv420p 每像素只有 1.5 字节,因为它的色度是四分之一采样。 2. YUV 与 RGB 在内存排法上最本质的区别是什么? —— YUV 是三个平面分开存,RGB 是每个像素的三个分量交叉存。 3. yuv420p 转成 rgb24 再转回来,误差主要落在哪? —— 色度。色度本来只有一半分辨率,来回一趟等于又平均了一次,亮度几乎不变。

相关笔记