📹 第三章 FFmpeg二次开发采集并预览本地摄像头
📄 一句话概括

采集和渲染速度不一样,中间必须隔一个队列。 否则谁慢谁拖死对方——这是本章最重要的一句话。

📝 本节和前面几节的关系

上一节手造 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 秒实验

  1. 把队列容量从 3 改成 1 和 30,看丢帧数和端到端帧率怎么变——容量 1 会频繁丢帧但延迟最低。
  2. 把渲染循环里加一句 time.sleep(0.05) 模拟渲染变慢,看丢帧数涨到多少。
  3. 把 finally 那句注释掉,再人为让 producer 抛异常,看主线程是不是永远卡在 q.get() 上。

常见误区

  1. 以为队列满了该丢最新。 丢最新会让画面越来越滞后,延迟一直累积。监控直播一律丢最旧。
  2. 以为采集线程异常退出主线程会知道。 不会。主线程阻塞在 get() 上,看起来像卡死。 必须 finally 送哨兵。
  3. 以为转换很贵。 实测每帧几毫秒,远小于帧预算。真正的瓶颈在采集节奏和渲染同步。
自测三问(点开对答案) 1. 为什么采集和渲染必须分两个线程? —— 采集是阻塞的(等设备出帧),渲染是定时的(等垂直同步)。 放一个线程里时长是两者相加,会被最慢的那个拖住。 2. 队列满了应该丢哪一帧?为什么? —— 丢最旧。丢最新会让画面停在过去,延迟越来越长;丢最旧才能追上实时。 3. 没有哨兵会发生什么? —— 生产端异常退出后,消费端永远阻塞在 `get()` 上,程序挂死,而且没有任何报错。

SDL 这条线走完了,下一节转 Qt:从信号开始,把「控件之间怎么通知」这件事讲透。

相关笔记