H.264:从 NAL 取出一帧 YUV
规范:T-REC-H.264(代码按
T-REC-H.264-202108)解析脚本:
_code/h26x/h264.py
NAL 里没有整幅 YUV。SPS、PPS 只说明画布和读法。像素只从 type 5 的 IDR 条带里来:每个宏块带一份「怎么预测」和一份「和预测差多少」,解码时加起来,写进 Y / Cb / Cr。
.yuv 的一个像素
= clip( 预测值 + 残差 )
│ │
│ └─ 码流里的变换系数(residual)
└─ 用左边/上边已经解出的像素,按 mb_pred 的方向填出来
唯一例外是 I_PCM:宏块里直接是原始样点,不用预测、也不用残差。
哪一种 NAL 里有图
文件按起始码 00 00 01 / 00 00 00 01 切成 NAL。00 00 03 里的 03 是防竞争字节,丢掉后才是语法。第一个字节的低 5 位是 type。
| type | 内容 | 和画面的关系 |
|---|---|---|
| 7 SPS | 宽、高、裁剪、4:2:0 | 画布多大。没有像素 |
| 8 PPS | CAVLC/CABAC、QP、能否 8×8 | 系数怎么读。没有像素 |
| 5 IDR | 一条 I 条带,里面全是宏块 | 像素从这里算出来 |
| 1 | 非 IDR 条带(P/B) | 也是像素,本仓库还没解 |
| 6 SEI 等 | 附加信息 | 跳过 |
要出一张图,顺序必须是 SPS → PPS → IDR。h264.py 的 nal_unit() 只在 type 5 写 YUV。
1. SPS / PPS:先知道往哪写
像素坐标靠 SPS 定。宏块固定 16×16。
宽(像素)= (pic_width_in_mbs_minus1 + 1) × 16
高(像素)= (pic_height_in_map_units_minus1 + 1) × 16 仅帧编码
1080 不能整除 16,编码高度是 1088。frame_crop_bottom_offset 把底边裁掉才是播放器看到的 1080。解码缓冲和写出的 YUV 用 1088,显示时再裁。
chroma_format_idc = 1 是 4:2:0:Y 全尺寸,Cb、Cr 宽高各一半。见 yuv.md。
PPS 里和取像素有关的只有两件:
entropy_coding_mode_flag:0 用 CAVLC 读系数,1 用 CABACtransform_8x8_mode_flag:I 宏块的亮度残差是 4×4 还是 8×8
2. IDR 条带:像素的来源
type 5 的载荷是 slice_header + slice_data。h264_slice_header.py、h264_slice_data.py。
条带头里和像素有关的:
| 字段 | 用途 |
|---|---|
first_mb_in_slice |
从第几个宏块开始填。整帧一条就是 0 |
slice_type |
2 = I。I 帧不参考别的帧,只靠本帧左边和上边 |
slice_qp_delta |
和 PPS 的初始 QP 合成 SliceQPY,反量化系数时用 |
然后 SliceData 从 CurrMbAddr = 0 一直解到条带结束。一个宏块 16×16,落在画面上的位置:
x = (CurrMbAddr % PicWidthInMbs) × 16
y = (CurrMbAddr / PicWidthInMbs) × 16
1440×1088 就是 90×68 个宏块,共 6120 个。每个宏块往 lumaData、chromaCbData、chromaCrData 里填自己那一块。
3. 一个宏块怎么变成像素
规范 7.3.5,代码 h264_slice_mb.py。宏块在码流里是两段,重建时加在一起。
3.1 码流里实际有的
mb_type 这块怎么预测、残差是不是整块都有
mb_pred 预测方向(上边复制、左边复制、平均……)
coded_block_pattern 哪几个 8×8 真有系数。全 0 就没有残差
mb_qp_delta 这块的量化步长
residual 变换系数,不是像素
I 片的 mb_type(Table 7-11):
| 值 | 名字 | 预测从哪来 | 系数从哪来 |
|---|---|---|---|
| 0 | I_NxN |
16 个 4×4,或 4 个 8×8 | LumaLevel4x4 / LumaLevel8x8 |
| 1–24 | I_16x16 |
整块一个方向,方向编在 mb_type 里 | Intra16x16DCLevel + Intra16x16ACLevel |
| 25 | I_PCM |
没有预测 | 码流里就是 Y/Cb/Cr 样点 |
4:2:0 再读一个 intra_chroma_pred_mode,色度系数在 ChromaDCLevel、ChromaACLevel。
I_PCM 是唯一「NAL 里直接躺着像素」的情况。其它宏块的 residual 是 zigzag 顺序的系数,要反扫描、反量化、反变换,才变成和像素同尺寸的残差块。
3.2 算成像素(Parse)
以最常见的 4×4 为例。16 个子块各做一次:
系数 LumaLevel4x4[块]
→ zigzag 排回 4×4
→ 反量化、反变换 得到 residual(4×4)
左边、上边已经写入 lumaData 的像素
→ 按这个块的预测模式复制 得到 pred(4×4)
像素 = clip( pred + residual , 0, 255 )
→ lumaDataMerge 写到这块在整帧里的 x,y
8×8、16×16 同一句话,只是块更大,16×16 还要把 DC 系数先单独反变换再加回 16 个 4×4。
CBP 某一位是 0:这块残差当 0,像素就等于预测。平坦区域大部分宏块是这样,所以码流里看不到「颜色值」,颜色是从左邻、上邻推出来的。
色度同样:ChromaResidualProcess 写出 chromaCbData(蓝)和 chromaCrData(红),分辨率是亮度的一半。
4. 三个平面写成 YUV 文件
条带里的宏块都跑完后,h264.py 按这个顺序落盘(nal_unit 里 type 5 那段):
1. 全部 Y lumaData 宽 × 高
2. 全部 Cb chromaCbData 宽/2 × 高/2
3. 全部 Cr chromaCrData 宽/2 × 高/2
这就是一帧 yuv420p。尺寸用编码尺寸,例如 1440x1088。播放器里的 1440x1080 是按 SPS 裁掉底部 8 行之后的显示窗。
ffplay -f rawvideo -pixel_format yuv420p -video_size 1440x1088 _tmp/dev.yuv
ffplay -f rawvideo -pixel_format yuv420p -video_size 1440x1088 -vf crop=1440:1080:0:0 _tmp/dev.yuv
5. 代码上就是这条调用
h264.py
起始码切开,丢掉 0x03
type 7 SPS 记下宽高
type 8 PPS 记下 CABAC / 8×8
type 5 SliceHeader 记下从哪个宏块开始、QP
SliceData 按地址扫宏块
MacroBlock.__init__
mb_type / mb_pred / residual → 系数放进 LumaLevel*、Chroma*
MacroBlock.Parse
预测 + 反变换残差 → lumaData、chromaCbData、chromaCrData
把这三个 dict 按 Y、Cb、Cr 写成 .yuv