🎬 第二章 FFmpeg命令行初体验
📄 一句话概括

Y4M 的头就一行,六个字段,每个字段一个大写字母开头。 其中 C 字段决定了文件体积——420 和 444 差整整一倍。

📝 本节和前面几节的关系

2.7.1 讲了 Y4M 长什么样,这一节把那行头拆开逐字读。 读完这一节,任何人给你一个 Y4M 文件你都能一眼看出它的参数。

一、头里的六个字段

头是一行 ASCII,字段用空格分隔,每个字段以一个大写字母开头:

字段 含义 取值示例


W 画面宽度 160 H 画面高度 90 F 帧率,写成 分子:分母 25:1 I 隔行方式:p 逐行、t 上场、b 下场 p A 像素宽高比,写成 分子:分母 1:1 C 色度采样方式 420

两个细节值得注意:一是所有数字都不带单位,靠字段字母自己说明含义; 二是帧率写成 25:1 而不是 25——因为它要能表示 30000:1001 这种分数。

第二点不是吹毛求疵:NTSC 体系里 29.97 的真实值就是 30000/1001, 用小数写会丢精度,写成分子分母就一点不差。2.8.3 会专门讲这个数。

二、C 字段:体积就差在这一个字母上

同样是 160×90,C 换个值,一帧的字节数就不一样:

C 值 色度怎么存 一帧字节 比例


420 2×2 像素共用一个色度样本 21600 1.00× 422 2×1 像素共用一个 28800 1.33× 444 每个像素都有,不省 43200 2.00×

算一下就知道为什么:420 里 Y 是全采(宽×高),U 和 V 各只有四分之一。 所以一帧 = 宽×高 + 宽×高÷4 × 2 = 宽×高×1.5。

444 则是三个平面全采,等于宽×高×3,正好是 420 的两倍。 422 夹在中间,是宽×高×2。

420 是绝对主流:人眼对亮度敏感、对色度不敏感, 两个像素共用一个色度样本,肉眼看不出差别,体积少了三分之一。 444 只在专业调色和抠像里用——那两种活一旦丢了色度,后面补不回来。

三、帧与帧之间只有一个标记

整个文件的结构可以写成一行式子:

部分 内容 重复几次


文件头 YUV4MPEG2 加六个字段 1 次 帧标记 FRAME 加换行 每帧 1 次 帧数据 Y 平面、U 平面、V 平面紧挨着 每帧 1 份

「紧挨着」这三个字很重要:Y4M 里每个平面的每行都是紧排的, 行与行之间没有填充字节,行宽就等于画面宽度。

而解码器给的帧往往不是这样:为了对齐,一行可能分配到比宽度更长的空间。 同一个 160 宽的帧,从 mp4 解出来可能是 256 的行宽。

所以算字节数时不能用「宽×高」,得看那份数据是谁分配的、行宽是多少。 这个坑在第五章做滤镜时会反复遇到。

四、动手 Demo:头里那六个字段

手写三个不同 C 值的 Y4M,逐一读回头里的六个字段,并量出一帧的实际字节数。 目的是把「头里写什么」和「文件多大」这两件事对上。

import os
import av
import numpy as np

W, H = 160, 90

def make(cf, name):
    # C 值决定色度平面的尺寸
    if cf == u"420":
        cw, ch = W // 2, H // 2
    elif cf == u"422":
        cw, ch = W // 2, H
    else:                       # 444
        cw, ch = W, H
    head = u"YUV4MPEG2 W%d H%d F25:1 Ip A1:1 C%s\n" % (W, H, cf)
    with open(name, u"wb") as f:
        f.write(head.encode(u"ascii"))
        f.write(b"FRAME\n")
        f.write(np.full(W * H, 128, np.uint8).tobytes())
        f.write(np.full(cw * ch, 128, np.uint8).tobytes())
        f.write(np.full(cw * ch, 128, np.uint8).tobytes())
    return os.path.getsize(name)

for cf in (u"420", u"422", u"444"):
    n = u"c%s.y4m" % cf
    sz = make(cf, n)
    c = av.open(n)
    st = c.streams.video[0]
    f = next(c.decode(video=0))
    print(u"C%s  像素格式 %s  linesize %d  文件 %d 字节"
          % (cf, st.codec_context.pix_fmt, f.planes[0].line_size, sz))
    c.close()

实跑输出:

--------------------------------------------------------------------
Y4M 格式规范——头里那六个字段
--------------------------------------------------------------------

**① 头就一行,空格分隔,每个字段一个大写字母开头**

  字段     含义                               本例的值
  --------------------------------------------------------
  W        画面宽度                           160
  H        画面高度                           90
  F        帧率,写成 分子:分母               25:1
  I        隔行方式,p 逐行 t 上场 b 下场     p
  A        像素宽高比,写成 分子:分母         1:1
  C        色度采样方式                       420

**实际读到的头是这一行:**

  YUV4MPEG2 W160 H90 F25:1 Ip A1:1 C420

**注意两个细节:一是所有数字都不带单位,靠字段字母自己说明;**
**二是帧率写成 25:1 而不是 25——因为它允许 30000:1001 这种分数。**

**② 帧与帧之间只隔一行 FRAME**

**结构简单到可以背下来:头 + (FRAME + 一帧数据) 重复 N 次。**
**没有索引,没有时间戳,要读第几帧就得从头数——这一点和 mp4 正好相反。**

**③ C 字段决定体积,差多少能算出来**

  C 值     色度怎么存             一帧字节     比例
  ------------------------------------------------------
  420      2 × 2 像素共用一个     21600        1.00×
  422      2 × 1 像素共用一个     28800        1.33×
  444      每像素都有,不减       43200        2.00×

**420 是主流:人眼对色度不敏感,两个像素共用一个色度样本就够了。**
**444 只在专业调色与抠像里用,体积是 420 的两倍。**

**④ 一个容易忽略的事:Y4M 里的 linesize 是紧排的**

**刚才读回来的帧,Y 平面的 linesize 正好等于宽度 160,一行不多。**
**而 mp4 解出来的同一尺寸帧,linesize 是 256——因为那个内存是解码器按对齐要求分配的。**
**同一个分辨率、同一个像素格式,两边的 linesize 可能不同。**
**所以算字节数时不能用「宽 × 高」,得看那一份数据是谁分配的。**

注意 make 里那三行 if——C 值一变,色度平面的宽高就跟着变。 这就是 420 和 444 体积差一倍的全部原因,没有别的机关。

f.planes[0].line_size 这一项值得盯着看:三个文件读回来都是 160。 因为 Y4M 里没有对齐填充,行宽就等于画面宽度。

而这个 160 恰恰是从 mp4 解出来的帧给不出的——那个通常是 256。 同一个分辨率、同一个像素格式,两边的 line_size 可能完全不同。

头里那个 A1:1 也别忽略:它是像素宽高比。 写成 1:1 表示方形像素;PAL 的 DV 素材这里是 16:15,画面会被横向拉宽。 2.8.2 会算这个比例。

30 秒实验

  1. 改一个字段:把 F25:1 改成 F30000:1001,读回来确认帧率精度没丢。
  2. 换隔行:把 Ip 改成 It,看 ffmpeg 报什么——很多编码器不吃隔行输入。
  3. 量一行:打印 f.planes[0].line_size 和 f.width,确认 Y4M 里两者相等。

常见误区

  1. 把帧率写成 F25。Y4M 要求写成 分子:分母 的形式,25:1 才合法。
  2. 以为 line_size 一定等于宽度。只有 Y4M 这种紧排格式才是。解码器分配的帧通常带对齐填充。
  3. 忽略 A 字段。像素宽高比不是 1:1 时,画面显示比例跟分辨率数字不一样,这就是「看起来被拉扁」的根源。
自测三问(点开对答案) 1. Y4M 头里的帧率为什么写成 `25:1` 而不是 `25`? —— 因为要能精确表示分数帧率。NTSC 体系的 29.97 真实值是 30000/1001,写成小数会丢精度。 2. 一帧 420 的数据量为什么是宽×高×1.5? —— Y 平面全采占宽×高,U 和 V 各占四分之一,合起来是宽×高×(1 + 0.25 + 0.25) = 宽×高×1.5。 3. 为什么同一个分辨率的帧,line_size 可能不一样? —— 因为内存里的帧通常按对齐要求分配,一行可能比画面宽度长(比如 160 宽分配到 256)。Y4M 这种紧排格式才严格等于宽度。

相关笔记