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

QImage 的内存不是「宽 × 每像素字节」,而是「每行补齐到 4 的倍数」。 这一条不算清,画面一定是斜的,而且不报错。

📝 本节和前面几节的关系

信号槽讲完了,这一节用 Qt 显示图像——这是监控客户端的核心动作。 三个知识点:像素格式、行对齐、跨线程传递,第三个在 3.4.3 提过,这里落到具体类型。 缩放和旋转在 3.4.9/3.4.10,这一节只管「原样显示」。

一、三种格式,三种账

Qt 的图像格式有几个,常用的三个:

格式 每像素 每行字节
Format_RGB32 4 w × 4
Format_ARGB32 4 w × 4
Format_RGB888 3 补到 4 的倍数
Format_Grayscale8 1 补到 4 的倍数

前两行天然对齐(4 的倍数乘什么都是 4 的倍数),后两行要补。 所以「用 RGB32 就没事」这句话是对的——代价是每像素多一个字节,1080p 一帧多 2 MB。 这就是一个典型的取舍:省内存要处理对齐,省事就多占内存。

二、对齐补出来多少

D41 实测的几个宽度,规律很清楚:

宽度 RGB888 实需 补齐后 多出
320 960 960 0
640 1920 1920 0
1280 3840 3840 0
1920 5760 5760 0
641 1923 1924 1
1921 5763 5764 1

规律是「宽是 4 的倍数就不用补」——因为 3×4=12 本来就是 4 的倍数。 所以 1920、1280、640 全都不用补,这也是为什么这些分辨率这么常见。 而 641 这种奇数宽,每行多 1 字节,看起来微不足道。

三、差 1 字节会怎样

D41 实测:第一行两者相同,最后一行差了 33 级灰度。

因为每行的偏移量在累积:

行号 偏移错位
第 1 行 0
第 2 行 1 个像素
第 100 行 100 个像素
第 480 行 480 个像素

累积的结果就是「斜纹」——一条从左下到右上的斜线,一眼就能认出来,但很难想到是行对齐。 而且形状还是对的(reshape 出来的数组尺寸没变),所以代码不会报任何错。

正确做法只有一条:QImage::bytesPerLine() 读实际行宽,别自己乘。 这一条适用于所有带 stride 的 API(包括 FFmpeg 的 AVFrame.linesize)。

四、跨线程传图的标配

这套流程是监控客户端的标配,几乎每个项目都这么写:

步 谁做 用什么类型
解码出帧 工作线程 AVFrame
转成图像 工作线程 QImage
发信号 工作线程 排队连接
转成贴图 主线程 QPixmap
显示 主线程 QLabel/自绘

第二行和第四行是关键:QImage 能跨线程,QPixmap 不能。 所以「在工作线程里造 QPixmap」是错的,哪怕代码能跑,也是运气。 第三行的「排队」也是必须的——直连就是在工作线程里改界面,这是 3.4.3 那条规矩。

五、动手 Demo:算清行对齐,再造一张图对比两种读法

先算几张常见宽度的对齐字节数,再真造一张 641×480 的图,用正确和错误两种方式读, 比最后一行的像素值。 这一步能亲眼看到「形状一样、内容不同」。

w, h = 641, 480
need = w * 3
bpl = (need + 3) & ~3          # 补齐到 4 的倍数

img = np.zeros((h, bpl), np.uint8)
for y in range(h):
    img[y, :need] = np.arange(need) % 256

right = img[:, :need].reshape(h, w, 3)      # 正确:每行取前 need 字节
wrong = img.ravel()[: h * need].reshape(h, w, 3)   # 错误:连续硬切

print(right[-1, -1, 0], wrong[-1, -1, 0])

实跑输出:

--------------------------------------------------------------------
Qt 显示图像,行对齐是第一个坑
--------------------------------------------------------------------
**QImage 的内存不是「宽 × 每像素字节」,而是「每行都补齐到 4 的倍数」。**
**这一条不算清,画面一定是歪的。**

  格式                  每像素     每行怎么算
  --------------------------------------------------------
  Format_RGB32          4 字节     w × 4,天然对齐
  Format_ARGB32         4 字节     w × 4,天然对齐
  Format_RGB888         3 字节     补到 4 的倍数,会多
  Format_Grayscale8     1 字节     补到 4 的倍数

  **实测几张常见宽度,看多出来多少字节:**

  宽       RGB888 实需     补齐后每行     每行多出
  ----------------------------------------------------
  320      960             960            0
  640      1920            1920           0
  1280     3840            3840           0
  1920     5760            5760           0
  641      1923            1924           1
  1921     5763            5764           1

  **1920、1280、640、320 全是 0 字节**(本来就对齐),**641 那种奇数宽每行多 1 字节**。
  别小看这 1 字节:641×480 的图,一帧多出 480 个字节,**而且这 480 个字节是不连续插入的**,
  按 `w*3` 去读就整幅歪掉。
  **用 numpy 真造一张带对齐的图,再和错误读法比最后一行:**

  正确读法:每行取前 1923 字节 → 形状 (480, 641, 3)
  错误读法:把内存当连续字节流,按 1923 字节硬切 → 形状也是 (480, 641, 3)
  **形状一样,内容不一样**——这就是它不报错的原因。

  第一行两者相同(还没偏),最后一行差得最多:
    正确 128,错误 161

  **每行多 1 字节看着不多,但到第 2 行就错位 1 个像素,第 480 行错 480 个像素。**
  这就是「图像越往下越歪」的成因——**典型的斜纹**,一眼就能认出来。

  **正确做法只有一条:别自己乘,读 `QImage::bytesPerLine()`。**

最后一行:正确 128,错误 161。差 33 级不是巧合——每行多 1 字节,480 行就错位 480 个像素, 而这张图的像素值是模 256 递增的。 两个数组的形状完全一样,都是 (480, 641, 3)。这就是它不报错的原因——形状对,内容错。

bpl = (need + 3) & ~3 这个写法要记住:加 3 再抹掉低两位,就是「向上取整到 4 的倍数」。 它比 math.ceil(n/4)*4 快,也比 (n+3)//4*4 短。FFmpeg 里算 linesize 也是这么算的。

30 秒实验

  1. 把宽度从 641 换成 640,看两种读法的结果是不是一样了——对齐了就不会错位。
  2. 把像素值改成 np.arange(need) % 16,看错位后差几级——值域小了差异也小。
  3. 查一下 FFmpeg 的 AVFrame.linesize 是不是也有对齐,找出它和你算计的 bpl 有什么不同。

常见误区

  1. 以为每行字节数就是宽乘每像素。 只有 32 位格式才天然成立,24 位和 8 位都要补齐到 4 的倍数。
  2. 以为读错了会报错。 形状一样,代码照跑,只有画面是斜的——这类错误只能靠眼睛发现。
  3. 以为 QPixmap 也能跨线程。 不能,只有 QImage 可以。工作线程造 QImage,主线程转 QPixmap。
自测三问(点开对答案) 1. 641 宽的 RGB888 图,每行实际多少字节? —— 641×3 = 1923,补齐到 4 的倍数得 1924。多出的 1 字节是填充。 2. 行对齐错了会有什么表现? —— 画面出现斜纹,越往下偏移越多。形状仍然正确,代码不报任何错。 3. 跨线程传图像的标配流程是什么? —— 工作线程解码成 QImage,排队信号送主线程,主线程转 QPixmap 显示。QPixmap 不能跨线程。

下一节讲缩放:最近邻和双线性,快多少、差多少,都能算出来。

相关笔记