🎬 第三章 Qt信号槽机制及图片轮播
📄 一句话概括

QImage 管像素、QPixmap 管显示——前者能在工作线程摆弄,后者只能在界面线程碰。 显示一帧要过两道手续:转格式、缩放,两道都是逐像素的活。

📝 本节和前面几节的关系

上一节把「有新帧了」这个消息送到了界面线程, 这一节解决剩下的一半:拿到了像素,怎么让它出现在窗口上。 Qt 没有「把一块内存直接贴到窗口」的接口,必须先包成 QImage 或 QPixmap。 这两个类长得像,分工完全不同。

一、QImage 与 QPixmap:一个管数据,一个管显示

记住一句话就够:QImage 是「内存里的一块像素」,QPixmap 是「交给显示后端的一张图」。

对比项 QImage QPixmap
数据在哪 普通内存,CPU 可读写 可能在显存,也可能在内存
工作线程能用吗 能 不能
能直接改像素吗 能,bits() / scanLine() 不能,要先转回 QImage
适合做什么 解码、处理、缩放 最终贴到窗口
互相转换 QPixmap::fromImage() 上传 toImage() 下载

「工作线程不能用 QPixmap」是硬约束,不是风格建议。 它背后是平台显示后端(Windows 上是 GDI / D3D),这些后端有线程亲和性——在别的线程创建, 运气好是不显示,运气不好直接崩。

所以解码线程的标准套路是:解出 AVFrame,转成 QImage,发信号把 QImage 交给界面线程, 界面线程再决定是 drawImage 还是 fromImage。

fromImage() 那行要留意:它是一次完整的上传。1080p 的 Format_RGB32 是 8 MB, 每帧都 fromImage 就是每秒上传 480 MB。 播放器里更常见的做法是用 QPainter::drawImage 直接画,或者干脆用 QOpenGLWidget 把帧当纹理。

二、像素格式:一次转换就是一笔账

FFmpeg 给的是 YUV,屏幕要的是 RGB,中间必有一次转换。 转成哪种 RGB,决定了这笔账有多贵。

Qt 格式 内存里的字节顺序 对应的 FFmpeg 格式
Format_RGB32 B, G, R, 0xFF AV_PIX_FMT_BGRA
Format_ARGB32 B, G, R, A AV_PIX_FMT_BGRA
Format_RGB888 R, G, B AV_PIX_FMT_RGB24
Format_Grayscale8 单个亮度字节 AV_PIX_FMT_GRAY8

第一行就是最大的坑: 名字叫 RGB32,内存里却是 B、G、R、A 的顺序。因为它是按 32 位整数存的,小端机上低位字节在前, 于是蓝在最低位、排最前面。

FFmpeg 那边同样在骗人:AV_PIX_FMT_RGB32 的布局也是 BGRA,两个库的命名习惯一致地反直觉。 所以「红蓝颠倒」这种 bug,八成就是把这两个名字按字面理解了。

还有一个零拷贝的构造要当心: QImage(uchar *data, w, h, bytesPerLine, fmt) 不拷贝数据,只记住指针。

它的用途是「临时包一下,马上画完」。 如果你想把它存起来、或者跨线程传,必须 .copy()——否则 AVFrame 一 unref, 那块内存就被回收或者复用,画面会变成花屏,而且每次花的位置还不一样。

三、缩放为什么贵

缩放是唯一一件「输出比输入小、却可能更慢」的事。因为每个输出像素都要去源图上取样本。

算法 Qt 里的名字 每个输出像素看几个源像素
最近邻 Qt::FastTransformation 1 个
双线性 Qt::SmoothTransformation 4 个,还要加权

看 Demo 那三个数字:1080p 只换格式 3.62 毫秒, 换成「换格式 + 缩到 640×360」反而只要 2.68 毫秒。

这不矛盾:缩到 640×360 之后输出像素只剩九分之一,虽然每个像素更贵,总账还是省了。 真正贵的是最后那步「转回 rgb24」——3.95 毫秒,因为它不缩、像素一个不少。

按 60 帧算就是另一回事了:3.62 毫秒 × 60 = 217 毫秒,一秒里 22% 的时间在算这一帧。 一路 1080p 吃掉两成核,九宫格就是两个核。

这就是播放器非要用 GPU 的原因: 同样的缩放,显卡是「顺手做了」,CPU 是「专门做一遍」。Qt 里对应的路子是 QOpenGLWidget 或者自己写 shader,QPainter 那条路注定是 CPU 在算。

CPU 路线的优化三招按性价比排: 降分辨率——预览用 640×360,比什么算法优化都管用。 用最近邻——四倍差距,画质损失在预览里看不出来。 少转一次格式——Format_RGB32 而不是 Format_RGB888,因为对齐的 32 位访问比 24 位快。

四、动手 Demo:把 1080p 缩成 360p,这一步有多贵

这段把一帧 1920×1080 的 rgb24 真做三次不同的处理:只换格式、换格式并缩放、再转回 rgb24。 然后统一换算成「每秒 60 帧要占多少核」——这个换算比毫秒数更能说明问题。

f = av.VideoFrame.from_ndarray(img, format="rgb24")

t0 = time.perf_counter()
y = f.reformat(format="yuv420p")          # 只换格式
t1 = time.perf_counter()

s = f.reformat(width=640, height=360,      # 换格式 + 缩放
               format="yuv420p")
t2 = time.perf_counter()

r = s.reformat(format="rgb24")             # 再转回来给屏幕
t3 = time.perf_counter()
print("%.2f %.2f %.2f 毫秒" %
      ((t1-t0)*1e3, (t2-t1)*1e3, (t3-t2)*1e3))

实跑输出:

--------------------------------------------------------------------
把 1080p 缩成 360p,这一步有多贵
--------------------------------------------------------------------
**Qt 里显示图像要做两件事:把帧变成 QImage、再把 QImage 缩放画到控件上。**
**这两步都是逐像素的活,分辨率一大就藏不住。**

  造一帧 1920×1080 的 rgb24:**6220800 字节**。

  动作                          耗时 毫秒     每秒 60 帧要占多少
  ------------------------------------------------------------------
  转成 yuv420p(不缩放)        3.62          22% 的核
  转 yuv420p 并缩到 640×360     2.68          16% 的核
  再转回 rgb24(给屏幕)        3.95          24% 的核

**三个数字对应三笔账**:
  不缩放只换格式:只是色彩矩阵,像素数不变。
  缩放:像素数少九倍,但要重采样,单位成本更高。
  **转回 rgb24:这一步是给屏幕的,必须做,只能靠 GPU 省。**

**这就是为什么播放器都要用 GPU**:CPU 做这些事,一路 1080p 就吃掉小半个核;
**九宫格监控就是九个半核,任何普通机器都扛不住。**

**Qt 这边的正确做法**:**`QImage` 用 `Format_RGB32`,少一次格式转换。**
**缩放用 `Qt::FastTransformation`(最近邻),不是平滑插值。**
**画质差一点,速度快几倍——预览场景这笔交易很划算。**

三个动作,三笔不同的账: 只换格式:像素数不变,只是色彩矩阵运算。 换格式加缩放:输出少了九倍,但每个要重采样。 转回 rgb24:像素一个不少,只换格式,3.95 毫秒。

最反直觉的是第二行比第一行快。 原因是「缩放把工作量提前砍掉了」:后面所有步骤都只处理 640×360 的像素。

这条经验可以推广:在流水线里,越早降分辨率越省。 解码出来就缩,比「解完、转格式、再缩」便宜得多。 FFmpeg 的 sws_scale 支持一步完成「格式 + 尺寸」转换,就是为了这个。

最后那列「占多少核」要这么读: 22% 不是「占满一个核的 22%」,是「一秒里 22% 的时间在做这件事」。单线程程序里,这两者等价; 多线程下就变成了「至少要有一个线程一直忙着」。

30 秒实验

  1. 把 Format_RGB32 换成 Format_RGB888,用 QElapsedTimer 量一下差多少。
  2. 算一下:1920 × 1080 × 3 字节 = 6220800 字节,一秒 60 帧是多少 MB?
  3. 判断一下:把 QImage 用信号从解码线程发给界面线程,像素会被拷贝几次?

常见误区

  1. 在工作线程构造 QPixmap。 它绑着平台显示后端,非界面线程用会崩或静默失败。 工作线程只碰 QImage。
  2. 用 QImage(ptr, w, h, fmt) 包住 AVFrame 的数据长期持有。这个构造函数不拷贝, AVFrame 一释放缓冲就没了。要 .copy()。
  3. 每帧都 QPixmap::fromImage。 每次都是完整上传,1080p 就是 8 MB, 60 fps 下每秒 480 MB 白跑。用 drawImage 或纹理。
  4. 以为 Format_RGB32 的内存顺序是 R、G、B。 小端机上它是 B、G、R、A,红蓝会颠倒。 这种 bug 看起来像「颜色偏了」,其实是格式选错了。
自测三问(点开对答案) 1. 为什么 `QPixmap` 不能在工作线程用? —— 它背后是平台显示后端(Windows 上是 GDI / D3D),这些后端有线程亲和性, 必须在创建它们的线程里使用。`QImage` 只是普通内存,没有这个限制。 2. `QImage` 那个带 `uchar*` 的构造函数会拷贝数据吗? —— 不会,只记住指针和行宽。缓冲必须在 `QImage` 用完之前一直有效,否则就是花屏。 跨线程或长期持有必须 `.copy()`。 3. 缩到 640×360 为什么可能比只换格式还快? —— 因为缩放把后续所有步骤的像素数砍到九分之一。每个像素更贵,但总像素少得多,总账还是省。

一张图会画了,但轮播不是「画一张」,是「按时换一张」。下一节看换的时机——轮播的卡顿几乎全都出在「该换的时候下一张还没准备好」。

相关笔记