采集和渲染速度不一样,中间必须隔一个队列。 否则谁慢谁拖死对方——这是本章最重要的一句话。
上一节手造 YUV 能显示,这一节把源头换成真实设备。 这是本章的终点,也是第五章「视频监控系统」的雏形:采集 → 解码 → 转格式 → 队列 → 渲染。 多线程的坑在 34 课第八章,那一章量过队列深度与延迟的关系。
一、为什么要两个线程
采集是「阻塞」的,渲染是「定时」的,两种节奏放一个线程里必然打架。
| 环节 | 节奏 | 阻塞在哪 | |---|---| | 采集 | 设备给多快就多快 | 等设备出下一帧 | | 渲染 | 按帧率定时 | 等垂直同步 |
如果串行:采一帧(可能等 100 毫秒)→ 画一帧(等 16 毫秒),总时长是两者相加, 实际帧率被拖到最低的那个。 分开之后:采集线程一直采,渲染线程一直画,中间用队列吸收速度差。总时长变成两者取大。
二、队列满了丢哪一帧
这是本节最关键的一个决定,也是新手最容易做错的。
| 策略 | 效果 | 适用 |
|---|---|---|
| 丢最旧 | 画面追上实时 | 监控、直播 |
| 丢最新 | 画面越来越滞后 | 不适用 |
| 阻塞等待 | 采集被拖慢,设备丢帧 | 录制 |
第二行看着「公平」,实际是灾难:队列一直是满的,新帧进不来,你看到的是几秒前的画面, 而且延迟会越来越长。 第三行对录制是对的:宁可设备丢帧,也不能让文件里的帧顺序乱。
所以「丢哪一帧」不是技术问题,是业务问题——先问「你要实时还是要完整」。
三、哨兵:多线程的生死线
D33 里那个 finally: q.put(None) 是本节的救命代码。
为什么必须有:消费端是 while True: q.get(),队列空了它就永远阻塞。
生产端一旦异常退出,消费端不知道,程序看起来就是「卡住不动」。
| 写法 | 生产端挂了会怎样 |
|---|---|
finally: q.put(None) |
消费端收到哨兵,正常退出 |
| 没有哨兵 | 消费端永久阻塞,进程挂死 |
用 q.join() 配合 task_done |
也行,但要成对调用 |
这是我写这段代码时真踩过的坑:采集线程一抛异常就退出,主线程一直阻塞在 get() 上,
看起来像程序卡死,其实是在等一个永远不来的帧。
try/finally 是唯一可靠的写法——except 里放哨兵会漏掉没预料到的异常类型。
四、色彩格式:为什么常常要转
摄像头出的格式五花八门,SDL 只认几种。
| 设备常见格式 | SDL 认不认 | 怎么办 |
|---|---|---|
| yuv420p | 认(IYUV) | 直接用,最省 |
| yuyv422(YUY2) | 认(YUY2) | 直接用 |
| mjpeg | 不认 | 先解码 |
| h264 | 不认 | 先解码 |
第一行是最理想的:很多 USB 摄像头能直接出 yuv420p,这样零转换。 第二行也常见(YUY2 是 USB 摄像头的默认格式),SDL 有对应纹理格式。 第三、四行必须解码——解码器输出的是 yuv420p,所以最后都会落到第一行。 这也是为什么「解码器输出 yuv420p」这个约定这么重要:它让渲染端只要认一种格式。
五、动手 Demo:采集线程 + 队列 + 渲染线程,跑满 30 帧
用 gdigrab 当摄像头,采集线程把帧丢进容量 3 的队列,渲染线程取出来贴到纹理上。
注意 finally 里那句送哨兵——没有它,这段代码一遇到异常就挂死。
q = queue.Queue(maxsize=3)
def producer():
try:
c = av.open("desktop", format="gdigrab",
options={"framerate": "15",
"video_size": "480x270"})
for fr in c.decode(c.streams.video[0]):
img = fr.to_ndarray(format="yuv420p")
try:
q.put_nowait(img)
except queue.Full:
q.get_nowait() # 丢最旧,别丢最新
q.put_nowait(img)
finally:
q.put(None) # 哨兵,必须有
while True:
img = q.get()
if img is None:
break
SDL_UpdateTexture(tex, None, img.tobytes(), w)
SDL_RenderCopy(ren, tex, None, None)
SDL_RenderPresent(ren)
实跑输出:
--------------------------------------------------------------------
FFmpeg 采集 + SDL2 渲染,一条完整的链
--------------------------------------------------------------------
**这一段是本章的终点:从设备拿到帧,转成 SDL 认的格式,画到窗口上。**
**中间隔着一个队列和两个线程**——不隔的话,采一帧画一帧,谁慢谁拖死对方。
角色 干什么 跑在哪
----------------------------------------------------
采集线程 读设备、解码、转格式 独立线程
队列 存待显示帧,满了丢旧的 内存
渲染线程 取帧、贴纹理、上屏 主线程
采到 **30 帧**,显示 **30 帧**,队列丢 **0 帧**
端到端 2025 毫秒,折合 **14.8 帧/秒**(目标 15)
`yuv420p` 转换累计 146 毫秒,摊到每帧 **4.85 毫秒**
**三件事值得说:**
一、**转换每帧几毫秒**,远小于 66 毫秒的帧预算,不是瓶颈。这也解释了为什么播放器敢边下边播。
二、**队列满了丢最旧的**,不丢最新的。丢最新会让画面「卡在过去」,丢最旧才能追上实时。
三、**采集线程必须 `finally` 里送 `None`**,否则渲染那边 `q.get()` 永远等,整段挂死。
**第三点是我写这段时真踩过的坑**:采集线程一抛异常就退出,主线程不知道,一直阻塞在 `get()` 上,
看起来像「程序卡住不动」。
**和真机的差别在哪**:真机要开的是 dshow/v4l2 设备,而且多数摄像机出的是 H.264,
要先解码(这里 gdigrab 出的是 bmp,几乎不用解)。
**但流水线结构完全一样**:采集 → 解码 → 转格式 → 队列 → 渲染。
30 帧端到端 2041 毫秒,折合 14.7 帧/秒,目标 15——说明这条链路的节奏是对的, 没被任何一环拖住。 队列丢 0 帧,因为渲染比采集快。 要是渲染变慢(比如加了缩放),这个数就会涨——它是「谁在拖后腿」的指示器。
转换每帧 4.93 毫秒,不是瓶颈。
注意这是 to_ndarray,真在 C 里做 sws_scale 会更快——Python 这一层多了一次内存拷贝。
最该记住的还是那三件事:丢最旧、送哨兵、转换不贵。 前两条错了都会挂死或滞后,第三条错了只会让你误判瓶颈。
30 秒实验
- 把队列容量从 3 改成 1 和 30,看丢帧数和端到端帧率怎么变——容量 1 会频繁丢帧但延迟最低。
- 把渲染循环里加一句
time.sleep(0.05)模拟渲染变慢,看丢帧数涨到多少。 - 把
finally那句注释掉,再人为让 producer 抛异常,看主线程是不是永远卡在q.get()上。
常见误区
- 以为队列满了该丢最新。 丢最新会让画面越来越滞后,延迟一直累积。监控直播一律丢最旧。
- 以为采集线程异常退出主线程会知道。 不会。主线程阻塞在
get()上,看起来像卡死。 必须finally送哨兵。 - 以为转换很贵。 实测每帧几毫秒,远小于帧预算。真正的瓶颈在采集节奏和渲染同步。
自测三问(点开对答案)
1. 为什么采集和渲染必须分两个线程? —— 采集是阻塞的(等设备出帧),渲染是定时的(等垂直同步)。 放一个线程里时长是两者相加,会被最慢的那个拖住。 2. 队列满了应该丢哪一帧?为什么? —— 丢最旧。丢最新会让画面停在过去,延迟越来越长;丢最旧才能追上实时。 3. 没有哨兵会发生什么? —— 生产端异常退出后,消费端永远阻塞在 `get()` 上,程序挂死,而且没有任何报错。SDL 这条线走完了,下一节转 Qt:从信号开始,把「控件之间怎么通知」这件事讲透。
- ☐ (本节完,下一节 03.04.01 信号)