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

录制只有三句:建 writer、逐帧 write、release。 但其中四个环节失败时都不报错,包括忘记 release 会得到 0 字节文件。

📝 本节和前面几节的关系

上一节显示一张图,这一节连续地显示并写进文件。 采集是「读」的循环,录制是「写」的循环,两者共用同一帧。 这一节的重点不是代码,是「怎么知道它真的成功了」——因为 OpenCV 这套接口的失败全是静默的。

一、先读一帧,再建 writer

顺序不能反,这是本节最实用的一条:

Mat first;
cap >> first;                                  // 先拿一帧
int w = first.cols, h = first.rows;            // 真实尺寸

VideoWriter vw("out.avi",
                VideoWriter::fourcc('M','J','P','G'),
                30.0, Size(w, h));               // 用真实尺寸
if (!vw.isOpened()) return;                    // 必须判

为什么不能直接写 Size(640, 480): 因为 VideoWriter 声明的尺寸与每帧的实际尺寸必须一模一样。 差一个像素,write() 就直接丢掉那一帧,不返回任何东西。

「丢掉」的意思是真的丢:文件建出来了、能打开、能播,只是里面的帧数比你写的帧数少。 如果你不数一遍,永远不知道少了。

Demo 里那句「先读一帧,拿 shape 去建 writer」就是这个道理, 它不是绕路,是把「声明」换成「事实」。

二、四个静默失败的坑

坑 现象 怎么发现
fourcc 不支持 文件建出来但是 0 字节或打不开 查 isOpened()
帧尺寸与声明不符 能写,但读回来帧数少 写之前比 frame.shape
忘了 release() 0 字节,数据全在缓冲区 写完立刻查文件大小
write() 返回 void 失败也不告诉你 只能写完读回来数帧

第三行最坑,也是本课第一次讲 32 课时踩过的同一个坑: VideoWriter 跟文件句柄一样,数据是缓冲的。 不 release() 就退出,文件长度是 0。

关键是它「不报错」:程序正常结束、返回 0、你去看目录,文件在那儿、名字对、就是 0 字节。 这类 bug 最费时间,因为「程序没崩」这个事实让你先排除了崩溃方向。

第一行也值得说:我们实测 MJPG 是能用的(文件写成、读回 30 帧), 但 H264 在很多 OpenCV 构建里是打不开的——因为它要靠外部的编码器库,而预编译包未必带。 这就是「查 isOpened()」必须做的原因:不同的 OpenCV 构建,可写的 fourcc 集合是不一样的。

第四行是设计上的无奈:write() 返回 void, 所以「这一帧写进去了吗」这个问题,接口层面无法回答。 唯一的验证方式就是把文件读回来数帧。

三、把「静默失败」当成一套设计来看

OpenCV 里有大量「返回 bool 或 void、失败不抛异常」的接口:imread、imwrite、 VideoCapture::read、VideoWriter::write。这不是疏忽,是有意的设计。

好处很实在:实时循环里不能因为一帧读失败就抛异常终止。摄像头偶尔掉一帧、文件末尾读不到, 这些是「常态」不是「异常」,用返回值处理比用异常处理更合适。

代价同样实在:错误必须每一处都自己查,漏一处就是「跑通了但结果是空的」。 而且这类 bug 的排查方向极其反直觉——你看着「没报错」,于是去查算法,其实问题在 I/O。

所以配套的习惯要建立起来,就三条: 每个返回 bool 的调用都判(哪怕只是 assert)。 每个资源都要 release()(析构函数虽然会做,但作用域要清楚)。 写完的东西读回来验一次(帧数、尺寸、文件大小)。

第三条最容易被跳过,但它性价比最高: Demo 里那个「读回来 30 帧 == 写进去 30 帧」的检查,只花了十行代码, 却能抓住上面四个坑里的三个。

最后补一个数:三十帧裸数据 27648000 字节,压缩后 1955386 字节,压缩比 14 倍。 MJPG 就是「每帧单独 JPEG」, 帧内压缩、帧间无关——所以它好拖(每帧都是关键帧)、但码率高。 这跟 04.04 讲的 GOP 取舍是一回事,只是走到了另一个极端。

四、动手 Demo:采集与录制,四个会静默失败的坑

这段先查摄像头在不在(只查 isOpened() 与声明参数,不抓帧), 再真写一个 30 帧的 MJPG AVI,读回来数帧。 最后那个「压缩比 14 倍」是把裸数据和文件大小除一下得到的。

cap = cv2.VideoCapture(0)
print(cap.isOpened(),
      cap.get(cv2.CAP_PROP_FRAME_WIDTH),
      cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()

fourcc = cv2.VideoWriter_fourcc(*"MJPG")
vw = cv2.VideoWriter(path, fourcc, 30.0, (640, 480))
print("writer 打开成功 =", vw.isOpened())
for i in range(30):
    vw.write(img)
vw.release()                       # 不写这句,文件是 0 字节

cap = cv2.VideoCapture(path)
n = sum(1 for _ in iter(lambda: cap.read()[0], False))
print(n, os.path.getsize(path))

实跑输出:

--------------------------------------------------------------------
采集与录制,四个会静默失败的坑
--------------------------------------------------------------------
**采集这一半只验证「设备在不在、参数报什么」,录制那一半真跑。**
**这里刻意不抓帧**——抓一帧就是读取真实画面,没有理由随手拿。

**第一句在任何机器上都该先跑**:

  `cv2.VideoCapture(0).isOpened()` = **True**
  声明参数:**640×480,30 fps**

**`False` 就是「没有 0 号设备」**,常见原因三个:没插摄像头、被别的程序占着、索引不是 0。
**注意这个调用不会抛异常,只是返回 False**,
**所以「不检查返回值」的代码会一路跑到 `cap.read()` 才出问题。**

**打开成功 ≠ 参数属实**:上面那几个数是**摄像头声明**的,**不是它真的会给你什么。**
**声明 1920×1080、实际给 1280×720 的摄像头很常见**,
**所以唯一可靠的做法是 `read()` 一次、看 `frame.shape`。**
**这一条跟后面录制那条「尺寸必须跟声明一致」是同一个坑的两头。**

**`read()` 也是同样的风格**:它返回一个 `(成功?, 帧)` 的元组,**失败时帧是空数组,不会抛异常。**
**所以循环里必须判第一个值**,否则你拿到一个空数组去 `cvtColor`,报的错跟摄像头一点关系都没有。

**录制这半边可以真跑。**下面真写一个 30 帧的 MJPG AVI,再读回来数帧:

  `VideoWriter` 打开成功 = **True**
  写 30 帧耗时 **156.3 毫秒**,文件 **1955386 字节**
  读回来 **30 帧**,帧率 **30.0**
  三十帧裸数据是 **27648000 字节**,压缩后 **1955386 字节**,**压缩比 14.1 倍**

**这个「读回来帧数 == 写进去帧数」的检查要养成习惯**:
**`write()` 返回 void,写失败没有任何反馈。**
**唯一的验证方式就是把文件读回来数一遍。**

**四个会静默失败的坑,按踩到的概率排**:

  坑                      现象                                怎么发现
  --------------------------------------------------------------------------------------
  fourcc 不支持           文件建出来了,但 0 字节或打不开     查 `isOpened()`
  帧尺寸与声明不符        能写,但读回来帧数少                写之前比 `frame.shape`
  忘了 `release()`        **0 字节**,因为数据全在缓冲区      写完立刻查大小
  `write()` 返回 void     写失败也不告诉你                    只能写完读回来数帧

**第三行是本节最值钱的一条**:**`VideoWriter` 跟文件句柄一样,数据是缓冲的。**
**不 `release()` 就退出,文件长度是 0**——而且**不报错、不警告,程序正常结束**。
**这类 bug 在调试时最费时间,因为「程序没崩」。**

**第二行也值得展开**:`VideoWriter(path, fourcc, fps, (w, h))` 里的 `(w, h)` 是**声明**,
**之后每帧的尺寸必须跟它一模一样**。
摄像头给的尺寸跟你的声明差一个像素(比如 639×479),
**`write()` 就直接丢掉那一帧,不返回任何东西。**
**所以正确的写法是「先读一帧,拿它的 `shape` 去建 writer」**。

**把这条推广开**:**OpenCV 里有大量「返回 bool 或 void、失败不抛异常」的接口**(`imread`、
`imwrite`、`VideoCapture::read`、`VideoWriter::write`)。
**这套设计的后果是「错误要自己查」,好处是不会被异常打断实时循环。**
**写实时程序时,前者的代价是必须每一处都判**,**漏一处就是「跑通了但结果是空的」。**

本机的摄像头是开得起来的(声明 640×480、30 fps), 但这里没有去抓帧——抓一帧就是读取真实画面,没有理由为了演示随手拿一张。 这也是所有写采集程序的人都该有的一根弦。

「打开成功 ≠ 参数属实」这句要再说一遍:上面那两个数是摄像头声明的, 真实尺寸只有 frame.shape 说了算。 Demo 里录制那段能用固定 (640, 480),是因为那是我们自己在内存里造的图, 换成摄像头就必须先读一帧。

写 30 帧花了 167 毫秒,也就是每帧 5.6 毫秒——这就是 MJPG 编码的成本。 换成原始 RGB 会快得多但文件大得多,换成 H.264 会更慢、但很多构建里根本打不开。

「读回来 30 帧」这行检查,是整段代码里最有价值的一句。 它一句话验证了三件事:writer 真的写进去了、release() 真的生效了、尺寸声明真的对上了。 养成这个习惯,上面四个坑有三个当场就能发现。

最后那行「压缩比 14 倍」顺带说明一件事:MJPG 的码率大约是 15 Mbps(1955386 字节/秒 × 8), 比 H.264 高一个数量级。 所以「录屏用 MJPG」在短时间、要兼容性的场景下很合适,长时间存档还是得 H.264。

30 秒实验

  1. 把 release() 那一行注释掉,看文件大小变成多少。
  2. 算一下:MJPG 每帧 65179 字节,一秒 30 帧的码率是多少 Mbps?
  3. 判断一下:文件建出来了但打不开,第一个该查什么?

常见误区

  1. 用 Size(640,480) 硬写,不管摄像头实际给什么。 尺寸不符时write() 静默丢帧, 文件能播但帧数少。要先读一帧拿 shape。
  2. 忘了 release()。 数据全在缓冲区,文件是 0 字节,而且不报错、程序正常退出。
  3. 不判 writer.isOpened()。 有些 fourcc 在当前构建里根本不可写,写进去全是空。
  4. 写完不验证。 write() 返回 void,唯一的检查方式是把文件读回来数帧。
自测三问(点开对答案) 1. 为什么建 `VideoWriter` 之前要先读一帧? —— 因为 writer 声明的尺寸必须与每帧实际尺寸完全一致,差一个像素就静默丢帧。 先读一帧拿 `frame.cols/rows` 才是事实值。 2. 忘记 `release()` 会怎样? —— 文件长度是 0,因为数据还在缓冲区没写盘。而且不报错、不警告,程序正常结束——这类 bug 最难查。 3. MJPG 与 H.264 在「拖动」上的差别是什么? —— MJPG 每帧都是独立的 JPEG,等于每帧都是关键帧,随便拖、但码率高一个数量级; H.264 有 GOP,码率低但拖动要从关键帧解起。

第五章到此为止。这一章的关键词是「像素」: 拿到它、处理它、显示它、存下来。第六章换一层——不再让 CPU 算,把像素交给 GPU 画。

相关笔记