同样的画面存成 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 秒实验
- 看字节:
-pix_fmt rgb24 -f rawvideo 出.rgb,与-pix_fmt yuv420p的比大小。 - 换格式对比:把
rgb24换成bgr24,字节一样,但红蓝通道互换了。 - 转一次试试:
-vf format=rgb24,format=yuv420p,看画质掉多少。
常见误区
- 以为 RGB 比 YUV 清晰。两者都是全采样亮度,RGB 多花的一倍字节买的是色度精度。
- 把 bgr24 当成 rgb24 用。字节数完全一样,红蓝会互换,人脸会变蓝。
- 以为转换是无损的。8 位 RGB 与 8 位 YUV 之间来回转,每次都有舍入。
自测三问(点开对答案)
1. rgb24 的每像素字节是多少?为什么是 yuv420p 的两倍? —— 3 字节。yuv420p 每像素只有 1.5 字节,因为它的色度是四分之一采样。 2. YUV 与 RGB 在内存排法上最本质的区别是什么? —— YUV 是三个平面分开存,RGB 是每个像素的三个分量交叉存。 3. yuv420p 转成 rgb24 再转回来,误差主要落在哪? —— 色度。色度本来只有一半分辨率,来回一趟等于又平均了一次,亮度几乎不变。- ☐ (本节完,下一节 5.2.4 RGB16/RGB24/RGB32)