做视频理解 / 视频多模态时,几乎第一个撞上的工程问题就是抽帧:把一段 mp4 变成一串能喂给模型的图像。
看起来这是个十行 OpenCV 循环就能搞定的小事,但当视频从几十条涨到几十万条、分辨率从 480p 升到 1080p 时,抽帧会悄悄变成整条 pipeline 里最大的 CPU 黑洞——GPU 在等着推理,CPU 却卡在解码上。
这篇文章把三种常见方案从浅到深拆一遍:ffmpeg 直接抽帧、ffmpeg pipe、以及 decord。重点不在"怎么用",而在"为什么快"。理解了解码的底层流程,你才能在自己的场景里选对工具,而不是抄一段命令了事。
先说结论
| A. ffmpeg 抽帧到磁盘 | 简单、稳,但有编码 + 写盘开销 | 多 | ✗ | 离线批处理、需要落地 JPEG |
| B. ffmpeg pipe | 省掉编码和磁盘 IO,流式 | 3 次 | ✗(只能顺序读) | 单进程流式处理、不需要落盘 |
| C. decord | C++ 运行时 + 关键帧索引,支持 O(1) seek,可走 GPU 解码 | 1 次 | ✓ | 训练 / 推理取帧、随机采样、视频多模态 |

如果你只想要一句话:离线批量落盘用 A,流式处理用 B,要随机取帧或喂给模型就用 C。
下面逐个拆解底层原理。
为什么抽帧值得较真
很多人第一版抽帧是这么写的:
import cv2
cap = cv2.VideoCapture("input.mp4")
i = 0
while True:
ret, frame = cap.read()
if not ret:
break
if i % 6 == 0: # 每 6 帧取 1 帧
cv2.imwrite(f"frames/{i}.jpg", frame)
i += 1
这版有两个隐藏成本:
后面三种方案的提速,本质上都是在围绕这两点做文章:少解码不需要的帧,以及少在 Python 层搬数据。
方案 A:ffmpeg 直接抽帧到磁盘
ffmpeg -i input.mp4 -vf "fps=5" -q:v 2 frames/%06d.jpg
解码流程是这样的:
磁盘读 H.264/HEVC 码流
│
▼
┌─ ffmpeg 内部 ─────────────────────────────┐
│ 1. demuxer: 按 container 格式拆 packet │
│ 2. decoder: CPU 多线程解码 (frame-level) │ ← CPU 密集
│ 3. filter: fps 滤镜做降帧率 │
│ 4. encoder: JPEG 编码 │
│ 5. I/O: 写磁盘 │
└────────────────────────────────────────────┘
为什么比上面的 Python 逐帧循环快?
- ffmpeg 的解码器是 C 实现,直接操作码流 buffer,没有 Python 对象开销。
- -vf fps=5 让 ffmpeg 在 filter graph 层面就丢弃不需要的帧:解码器解出来的中间帧如果不需要,就直接丢掉,比"解完所有帧再裁"省了后续的编码和写盘。
- 但要注意:解码本身仍然在 CPU 上。H.264 / H.265 的熵解码(CABAC)是串行的,多线程也只能做到 slice 级别的并行,吃不满所有核心。
A 方案胜在简单可靠,输出就是磁盘上一排 JPEG。代价是每帧都要走一遍 JPEG 编码 + 磁盘写入——而这两步,恰恰是下一个方案要砍掉的。
方案 B:ffmpeg pipe(零磁盘 IO)
import subprocess
import numpy as np
cmd = [
"ffmpeg", "-i", "input.mp4",
"-vf", "fps=5",
"-f", "rawvideo", "-pix_fmt", "rgb24",
"pipe:1",
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
w, h = 1920, 1080
raw = proc.stdout.read(w * h * 3) # 读一帧 raw RGB
frame = np.frombuffer(raw, np.uint8).reshape(h, w, 3)
A 和 B 的核心差异只有一句:A 写磁盘,B 通过 pipe 传内存。
磁盘读码流
│
▼
ffmpeg 解码 + fps 滤镜
│
▼
stdout pipe(内核环形缓冲区,Linux 默认 64KB)
│
▼
Python subprocess.PIPE ──→ raw bytes ──→ numpy array
为什么比 A 更快?
瓶颈在哪?
- 管道缓冲区不大(Linux 默认 64KB,macOS 更小),高帧率下如果 Python 读得慢,会反过来阻塞 ffmpeg。
- proc.stdout.read(w * h * 3) 是阻塞调用,凑不齐完整一帧就一直卡在那儿——所以你必须确切知道 w * h * 3 这个帧大小。
- 进程间要做两次数据拷贝:ffmpeg 进程内存 → 内核管道缓冲区 → Python 进程内存,再加上 numpy 那一下,整条链路有 3 次拷贝。
B 解决了 A 的 IO 问题,但还留着两块硬骨头:只能顺序读(没法随机取第 1000 帧),以及解码永远在 CPU。这两点,正是 decord 要啃的。
方案 C:decord —— 真正的随机访问
这是最值得深入讲的一个。
decord 不是 ffmpeg 的封装命令,而是一个 C++ 运行时库,把"解码"和"按需取帧"做成了可以随机寻址的接口。
底层架构
Python API: decord.VideoReader.get_batch(indices)
│
▼
C++ Runtime (libdecord.so)
│
├── FFmpeg (demux + 码流解析)
│ demuxer 把 container 拆成 AVPacket
│ 关键帧索引表(seek index)在 open 时就建好了
│
└── 解码后端 (二选一)
│
├─ CPU: libavcodec (ffmpeg 的 CPU 解码器)
│ 逐帧解码到 NV12/YUV → 转 RGB
│
└─ GPU: NVDEC (cuviddec + nvdec)
│
▼
┌─── NVDEC 硬件解码流程 ────────────────┐
│ 1. AVPacket 从 demuxer 拿到 │
│ 2. cuviddec: CPU 侧熵解码 (CABAC) │ ← 只有这步在 CPU
│ 3. NVDEC 硬件引擎: 反量化+反变换+环路 │ ← 全在 GPU 芯片上
│ 滤波+帧重建 │
│ 4. 解码帧在 GPU 显存 (NV12 格式) │
│ 5. CUDA kernel: NV12 → RGB 色彩转换 │ ← GPU
│ 6. 直接返回 GPU tensor 或拷回 CPU │
└────────────────────────────────────────┘
两个关键设计:open 时就建好关键帧索引表,以及解码后端可以切到 GPU。前者带来随机访问,后者带来数量级的提速。
关键帧索引与按需 Seek
这是 decord(以及 ffmpeg 的 -ss)能"秒取任意帧"的原理。要理解它,先得看清 H.264 码流的结构:
H.264 码流结构:
I帧 ──→ P帧 ──→ P帧 ──→ B帧 ──→ B帧 ──→ I帧 ──→ P帧 ──→ …
|←──────── GOP 1 ────────→| |←──── GOP 2 ────→|
I 帧(关键帧): 独立可解码,不依赖任何其他帧
P 帧 / B 帧: 靠运动补偿,依赖前面(甚至后面)的帧才能解码
视频不是一帧一帧独立存的——为了压缩,绝大多数帧(P/B 帧)只记录"相对于参考帧的变化量"。这意味着你没法跳过中间帧直接解出某一帧。
decord 在 open 时会扫一遍码流,建立这样一张索引表:
frame[0] → file_offset=0x0000, keyframe=true # I帧
frame[1] → file_offset=0x1200, keyframe=false # P帧
frame[2] → file_offset=0x1400, keyframe=false # P帧
…
frame[30] → file_offset=0x8000, keyframe=true # I帧
有了这张表,随机取帧就有章可循了。
当你请求 vr[15](一个非关键帧)时:
1. 查索引表,发现 frame 15 不是关键帧
2. 往前找到最近的关键帧 → frame 0
3. seek 到 frame 0 的 file_offset(磁盘 seek,一次)
4. 从 frame 0 连续解码到 frame 15(中间帧不能跳,P/B 帧依赖前帧)
5. 只输出 frame 15 的像素,丢弃 0–14
当你请求 vr.get_batch([0, 30, 60, 90])(恰好都是 I 帧)时:
每个都是 I 帧 → 直接 seek + 独立解码,互不依赖
总耗时 ≈ 4 × 单帧解码时间(而且可以并行)
这就解释了一个很反直觉但很重要的经验法则:
等间隔取关键帧,性能最好。 如果你的采样步长恰好对齐 GOP(常见是 30 或 60 帧),那么每一帧都落在 I 帧上,零前向依赖,解码代价最低。反过来,如果步长和 GOP 错开,每取一帧都要从前一个关键帧重新解码一长串,开销会成倍上升。
decord vs ffmpeg pipe 的本质区别
| 解码方式 | libavcodec CPU 解码 | CPU 解码 或 NVDEC GPU 解码 |
| 数据拷贝 | 3 次(ffmpeg 进程 → 内核管道 → Python 进程 → numpy) | 1 次(C++ runtime → numpy / tensor 直接返回) |
| 随机访问 | 不支持(streaming 只能顺序读) | O(1) seek(关键帧索引表) |
| 帧间依赖 | 隐式处理(fps 滤镜内部丢帧) | 显式管理(get_batch 指定哪些帧要解码) |
| 色彩转换 | ffmpeg filter graph | CUDA kernel(GPU 模式)或 C 循环(CPU 模式) |
| 进程开销 | fork 一个 ffmpeg 子进程 | 无(C++ 库直接加载进当前进程) |
一句话概括:decord 把 ffmpeg pipe 那套"跨进程 + 顺序流"换成了"进程内 + 可寻址",拷贝次数和随机访问能力都因此质变。
NVDEC 为什么比 CPU 快 5–10 倍
decord 真正的杀手锏是它能把解码交给 GPU 上的 NVDEC——这是一块和 CUDA Core 相互独立的专用解码硬件。
CPU 解码 H.265 1080p: ~60 fps(顺利的情况下)
NVDEC 解码 H.265 1080p: ~400–600 fps
差距来自四点:
最后这一点,正是它对视频多模态项目最关键的价值。
一个容易被忽略的收益:零拷贝喂给模型
如果你在做视频多模态推理——比如给一个 omni 模型喂视频——常规链路是这样的:
解码 → Image.open() → numpy → torch.from_numpy() → .to(device)
↑
一次 CPU → GPU 拷贝
而用 decord 的 GPU 解码,配合:
import decord
decord.bridge.set_bridge("torch")
vr = decord.VideoReader("input.mp4", ctx=decord.gpu(0))
frames = vr.get_batch([0, 30, 60, 90]) # 直接是 CUDA tensor
解码出来的帧直接就是显存里的 CUDA tensor,整条 Image.open() → numpy → torch → .to(device) 的链路被省掉了。对吞吐敏感的推理服务来说,这省的不只是几行代码,而是每帧一次的主机—设备拷贝。
怎么选
把决策收敛成几个问题:
- 需要把帧落地成 JPEG 文件吗?(比如给下游标注、或做数据集缓存)→ 方案 A。
- 单进程流式处理,不落盘,也不需要随机取帧? → 方案 B,省掉了编码和磁盘 IO。
- 要随机采样、要按索引取帧、或者要直接喂给 GPU 上的模型? → 方案 C(decord),关键帧索引带来随机访问,NVDEC 带来数量级提速,GPU bridge 带来零拷贝。
而无论哪种方案,记住那条贯穿始终的原则:少解码你不需要的帧,少在 Python 层搬数据。 三种方案的提速,归根结底都是这两件事的不同程度的实现。


