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 秒实验
- 把
Format_RGB32换成Format_RGB888,用QElapsedTimer量一下差多少。 - 算一下:1920 × 1080 × 3 字节 = 6220800 字节,一秒 60 帧是多少 MB?
- 判断一下:把
QImage用信号从解码线程发给界面线程,像素会被拷贝几次?
常见误区
- 在工作线程构造
QPixmap。 它绑着平台显示后端,非界面线程用会崩或静默失败。 工作线程只碰QImage。 - 用
QImage(ptr, w, h, fmt)包住AVFrame的数据长期持有。这个构造函数不拷贝,AVFrame一释放缓冲就没了。要.copy()。 - 每帧都
QPixmap::fromImage。 每次都是完整上传,1080p 就是 8 MB, 60 fps 下每秒 480 MB 白跑。用drawImage或纹理。 - 以为
Format_RGB32的内存顺序是 R、G、B。 小端机上它是 B、G、R、A,红蓝会颠倒。 这种 bug 看起来像「颜色偏了」,其实是格式选错了。
自测三问(点开对答案)
1. 为什么 `QPixmap` 不能在工作线程用? —— 它背后是平台显示后端(Windows 上是 GDI / D3D),这些后端有线程亲和性, 必须在创建它们的线程里使用。`QImage` 只是普通内存,没有这个限制。 2. `QImage` 那个带 `uchar*` 的构造函数会拷贝数据吗? —— 不会,只记住指针和行宽。缓冲必须在 `QImage` 用完之前一直有效,否则就是花屏。 跨线程或长期持有必须 `.copy()`。 3. 缩到 640×360 为什么可能比只换格式还快? —— 因为缩放把后续所有步骤的像素数砍到九分之一。每个像素更贵,但总像素少得多,总账还是省。一张图会画了,但轮播不是「画一张」,是「按时换一张」。下一节看换的时机——轮播的卡顿几乎全都出在「该换的时候下一张还没准备好」。
- ☐ (本节完,下一节 03.03 Qt实现图片轮播)