🎬 第五章 MFC+OpenCV视频采集及播放
📄 一句话概括

Mat 与 Windows 位图有三个差异:通道顺序、行的顺序、行对齐。 只有第三个差异需要算,而它算错了就是「画面斜着一道道」。

📝 本节和前面几节的关系

上一节把帧处理好了,这一节把它画出来。 imshow 在 MFC 里不能用,得自己调 StretchDIBits 或 SetDIBitsToDevice。 这两个函数要的是一块「DIB」,而 Mat 跟 DIB 差三件事——差一件事就出错,出三种不同的错。

一、三个差异,两个是好消息

差异 OpenCV 的 Mat Windows DIB
通道顺序 B, G, R B, G, R(一致)
行的顺序 第一行在最上面 第一行在最下面
行对齐 紧凑,无填充 每行按 4 字节对齐

第一行是好消息,而且很多人不知道:DIB 要的就是 BGR,跟 Mat 一致。 所以「MFC 显示 OpenCV 图像」根本不需要 cvtColor。

但网上大量示例代码里有这一句:cvtColor(src, dst, COLOR_BGR2RGB); 然后显示。 结果是颜色反了——因为「转成 RGB 再交给要 BGR 的接口」,等于转了两次。 这类错误的表现是「脸色发蓝」,而且因为人脸色本来就偏黄,第一眼很容易以为是白平衡问题。

第二行「倒着存」更隐蔽:DIB 的坐标原点在左下角, biHeight 为正表示「自下而上」。两种改法:把它写成负值(表示自上而下), 或者自己 flip(frame, frame, 0) 一次。

忘了这一步的表现是「上下颠倒」,而且它比颜色错误更容易被忽略——因为很多测试图上下对称, 一换成有人脸的图就立刻看出来。

二、行对齐:唯一需要算的一个

Windows 的位图要求每行字节数是 4 的倍数。 Mat 不保证这一点,它只保证行之间紧挨着。

所以两边差的是「每行末尾补几个空字节」,公式很简单:stride = (宽 × 3 + 3) // 4 × 4。

图像宽度 每行原始 对齐后 每行补 480 行共补
640 1920 1920 0 0 字节
641 1923 1924 1 480 字节
642 1926 1928 2 960 字节
643 1929 1932 3 1440 字节
645 1935 1936 1 480 字节

640 那一行全是 0,因为 640×3 = 1920 正好被 4 整除。 这就是为什么几乎所有示例代码都写 640 宽——它把这个坑藏起来了。

你把摄像头分辨率换成 641×480,画面就开始「斜着一道道」。 这个现象有名字,叫 stride 错位:程序按 1923 字节一行去读,实际一行是 1924 字节, 每读一行就错开一个字节,几百行之后整幅图就斜了。

修法有两种,取舍不同: 在 Mat 里补空字节:新建一个 stride × 高 的缓冲,逐行拷贝。多一次拷贝,但下游不用管。 告诉接口真实 stride:StretchDIBits 没有 stride 参数,所以只能走第一条; SetDIBitsToDevice 也一样。这就是为什么必须自己补。

640 宽还有个附带好处:它同时也满足「16 字节对齐」,SIMD 指令能用上更快的路径。 选分辨率的时候,挑 4 的倍数是习惯性的好做法。

三、cvtColor 往往是白做的

既然 DIB 要 BGR,那 cvtColor 就是纯浪费。 但「浪费多少」要量,不然说服不了自己。

Demo 量了四组:640×480 的 BGR→RGB 只要 0.03 毫秒, 1920×1080 却要 1.62 毫秒——像素只多九倍,耗时多了六十多倍。

多出来的部分不是算法变复杂了,是两个物理原因:1080p 那帧六兆多,塞不进二级缓存, 每次都要走内存;而且每次调用都要新分配一块六兆的输出缓冲。

这条经验可以推广到所有像素操作:「小图上的耗时」不能按像素数线性放大到大图, 因为缓存和内存分配会额外收一笔。 这跟 29 课第 6 章讲的「缓存行与台阶」是同一件事。

顺便说 BGR→GRAY 为什么反而比 BGR→RGB 便宜:输出只有三分之一大小, 而且灰度转换是一组乘加,RGB 换位只是搬字节。 一个少写内存、一个多写内存,差距就出来了。

所以这一节的三条实践规则: 不要 cvtColor(DIB 要 BGR)。 不要 flip,把 biHeight 写成负的。 必须补 stride,这一步躲不掉。

四、动手 Demo:OpenCV 的 Mat 与 Windows 位图的三个差异

这段把三个差异列成表,再把 stride 逐宽度算一遍,最后量一下 cvtColor 到底多贵。 「每行补几个字节」那一列是全节的核心: 640 是 0,641 是 1,642 是 2,643 是 3,645 又回到 1。

for w in (640, 641, 642, 643, 645):
    raw = w * 3                      # 每行原始字节
    stride = (raw + 3) // 4 * 4      # 向上取整到 4 的倍数
    pad = stride - raw
    print(w, raw, stride, pad, pad * 480)

# 顺便量 cvtColor:这一步在 MFC 显示里其实是白做的
for tag, img in ((640, im1), (1920, im2)):
    t0 = time.perf_counter()
    for _ in range(200):
        cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    print(tag, (time.perf_counter() - t0) / 200 * 1000)

实跑输出:

--------------------------------------------------------------------
OpenCV 的 Mat 与 Windows 位图的三个差异
--------------------------------------------------------------------
**`imshow` 能显示,是因为 OpenCV 自己把 Mat 转成了位图。**
**在 MFC 里没有 `imshow`,这一步要自己写**,**于是三个差异全暴露出来。**

  差异         OpenCV 的 Mat      Windows DIB
  ---------------------------------------------------------
  通道顺序     B, G, R            B, G, R(一致)
  行的顺序     第一行在最上面     **第一行在最下面**
  行对齐       紧凑,无填充       **每行按 4 字节对齐**

**通道顺序这一行是个好消息**:**`StretchDIBits` 要的就是 BGR,跟 Mat 一致**,
**所以「MFC 显示 OpenCV 图像」不需要 `cvtColor`。**
**很多人照着网上代码先转 RGB 再显示,结果蓝脸**——**转换那一步是多余的,而且还转错了方向。**

**第二行「倒着存」更隐蔽**:DIB 的坐标原点在左下角,
**所以要么把 `biHeight` 写成负值表示「从上到下」**,要么自己 `flip` 一次。
**忘了这一步的表现是「画面上下颠倒」**,而且只在某些图上明显——人脸一倒就很容易看出来。

**第三行要算,算完就知道为什么必须补空字节**:

  图像宽度     每行原始字节     对齐后每行     每行补     480 行共补
  ----------------------------------------------------------------------
  640          1920             1920           0          0 字节
  641          1923             1924           1          480 字节
  642          1926             1928           2          960 字节
  643          1929             1932           3          1440 字节
  645          1935             1936           1          480 字节

**640 那一行全是 0**,因为 640×3 = 1920 正好是 4 的倍数。
**这就是为什么绝大多数示例代码都用 640 宽**——**它把对齐问题藏起来了**,
**你换一张 641 宽的图,画面就开始斜着一道道。**

**「斜着一道道」这个现象有名字,叫 stride 错位**:程序按 1923 字节一行去读,实际一行是 1924 字节,
**每读一行就错开一个字节,几百行之后整幅图就斜了。**

**顺便量一下 `cvtColor` 到底多贵**(它是很多人下意识要加的一步):

  640×480 BGR→RGB:**0.03 毫秒**(每秒 30 帧就占 0.1% 的核)
  640×480 BGR→GRAY:**0.06 毫秒**(每秒 30 帧就占 0.2% 的核)
  1920×1080 BGR→RGB:**1.62 毫秒**(每秒 30 帧就占 4.9% 的核)
  1920×1080 BGR→GRAY:**0.75 毫秒**(每秒 30 帧就占 2.2% 的核)

**像素只多了九倍,耗时却多了 62 倍。**
**多出来的那部分不是算法变复杂了,是两个物理原因**:
  1080p 那帧六兆多,**塞不进二级缓存**,每次都要走内存。
  **每次调用都要新分配一块输出缓冲**,六兆的分配比一兆贵得多。

**这才是「小分辨率下看着无害的数」的真实含义**:**它不是线性放大到 1080p,是放大得更多。**

**而这一整件事在 MFC 显示那条路上完全是白做的**——DIB 要的就是 BGR。
**「能不转就不转」比「转得快」更重要。**

那张 stride 表的规律是「向上取整到 4 的倍数」:1923 补 1、1926 补 2、1929 补 3、 1935 补 1(因为 1936 已经是 4 的倍数)。

「480 行共补」那一列要看清量级:641 宽的话整幅图多出 480 个字节, 看起来微不足道——但正是这 480 个字节,让「按 1923 去读」的代码整幅图都错位。

错位的视觉效果很特殊:不是模糊、不是花屏,是「斜着的条纹」。 因为每行的起点比上一行偏了一个字节,几百行累积下来就形成一道斜线。 看到这种条纹,第一反应就该是 stride。

cvtColor 那四行数据是本节的第二个知识点:0.03 毫秒对 1.62 毫秒,六十多倍。 演示的意义不是「记住这个倍数」,而是「别用手机时代的直觉去估大图的成本」。

这四行还有个小陷阱:第一组测了 200 次一共 6 毫秒,计时器分辨率都开始影响结果了。 所以「太快的操作」要多跑几次,否则测出来的是噪声。

30 秒实验

  1. 把宽度改成 641 算一遍 stride,看「每行补」是几——再用肉眼找一下斜条纹在哪。
  2. 算一下:1280×720 的 BGR 图,每行原始字节是多少?是 4 的倍数吗?
  3. 判断一下:把 biHeight 写成负值和自己 flip 一次,哪个更快?为什么?

常见误区

  1. 先 cvtColor(BGR2RGB) 再交给 StretchDIBits。 DIB 要 BGR,转了两次,脸色发蓝。 这一步是多余的。
  2. 忘了 DIB 是自下而上。 画面上下颠倒,而且对称的测试图看不出来。 把 biHeight 写成负值最省事。
  3. 按 宽×3 当 stride。 宽度不是 4 的倍数时每行少算填充,结果是「斜着一道道」。 用 (宽*3+3)/4*4。
  4. 在小图上量出来的耗时线性放大去估大图。 缓存和内存分配会额外收一笔, 1080p 往往比「九倍」更贵。
自测三问(点开对答案) 1. Mat 与 Windows DIB 差哪三件事? —— 通道顺序(都是 BGR,一致)、行的顺序(DIB 自下而上)、行对齐(DIB 每行 4 字节对齐)。 只有后两个要处理。 2. stride 怎么算?为什么 640 宽不用管? —— `stride = (宽 × 3 + 3) // 4 × 4`。 640×3 = 1920 正好是 4 的倍数,填充为 0,所以示例代码都爱用 640。 3. 为什么 `cvtColor` 在 MFC 显示里是白做的? —— 因为 `StretchDIBits` 要的就是 BGR,跟 Mat 一致。多转一次反而把红蓝搞反,脸色发蓝。

能显示一张图了,最后一步是「连续地显示」——也就是采集与录制。下一节的四个坑,全部是「静默失败」那一类。

相关笔记