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

取帧只有一句 cap.read(),磨皮只有一句 bilateralFilter。 难的是取舍:双边保边缘,但慢一个数量级。

📝 本节和前面几节的关系

上一节把环境配通了,这一节开始真正处理像素。两件事:从摄像头拿帧、把帧变好看。 第一件是接口问题,第二件是算法与成本的权衡问题。 而且这两件事在 32 课第十三章已经碰过一次,那一章是手写滤波,这一章是用现成的。

一、取帧:三行代码加一个返回约定

取一帧的全部代码:

VideoCapture cap(0);                 // 0 号设备
if (!cap.isOpened()) return;         // 不检查就会跑到后面才崩
Mat frame;
while (cap.read(frame)) {            // 返回 false 就结束
    // frame 是 BGR 的,可以直接处理
}

read 的返回约定要记牢:它返回布尔值,失败时 frame 是空的,不抛异常。 所以「不检查返回值」的代码会拿着一个空 Mat 往下走, 最后在某个像素操作里崩,报的错跟摄像头毫无关系。

属性接口有个认知陷阱:cap.get(CAP_PROP_FRAME_WIDTH) 拿到的是声明值,不是实际给的值。 声明 1920×1080、实际给 1280×720 的摄像头很常见。 唯一可靠的来源是 frame.cols 与 frame.rows。

Demo 里查到本机是 640×480、30 fps——这是「声明」,真跑起来第一帧到手才知道是不是。 所以「先读一帧、拿它的尺寸去建 writer」这个写法不是绕,是必须, 05.04 还会因为这个坑再讲一次。

还有一件 MFC 特有的:imshow 靠 waitKey 驱动消息循环,在 MFC 程序里没有 waitKey, 所以你必须把帧画到自己的控件上——这就是 05.03 那一节的内容。

二、磨皮:三种滤波器的取舍

磨皮的原理一句话:把「小起伏」抹平,保留「大台阶」。 噪声是小起伏,人脸轮廓是大台阶。

滤波器 怎么算 特点
高斯 邻域加权平均,越近权重越大 快,台阶也一起抹
中值 取邻域中位数 专治椒盐噪声,台阶较保得住
双边 距离近权重高、颜色差得多权重低 保边,但慢一个数量级

双边那一行是全部关键:它在「空间距离」之外又加了一个「颜色距离」的权重。 台阶两侧的像素颜色差得多,于是互相的权重接近零、不参与平均。 结果就是「同一块颜色内部被抹平,跨过台阶不通」。

Demo 那张表用两个数把它量出来了: 平坦区的标准差(越小越干净)、边缘梯度的保留百分比(越高越保真)。

滤波器 耗时 边缘保留 平坦区
高斯 5×5 0.22 毫秒 33% 2.0
高斯 15×15 0.68 毫秒 16% 0.7
中值 5 2.55 毫秒 85% 1.8
双边 d=9 12.12 毫秒 102% 1.4

四行读完,结论很清楚:高斯便宜但毁边缘(15×15 只剩 16%),双边贵但边缘一点不动(102%, 甚至因为去噪反而更陡),中值夹在中间。

「102%」这个数超过 100 不是错:原图有噪声,边缘的梯度里混着噪声的贡献; 滤掉噪声之后,台阶本身反而更干净、更陡。 所以「保留 102%」的意思是「边缘不但没糊,还更清楚了」。

耗时那个 55 倍差距(0.22 对 12.12)是结构性的:高斯可以拆成「先横后竖」两遍一维卷积, 计算量与核大小无关;双边的权重逐像素不同,拆不开,只能老老实实做二维。

三、工程上怎么选

12 毫秒这个数,要放到帧预算里看:30 fps 一帧的预算是 33 毫秒,双边一家就吃掉 36%。 再叠上采集、转换、显示,实时就保不住了。

所以工程上分三档: 预览、实时 → 高斯小核(0.22 毫秒,随便用)。 「美颜」按钮、保存前处理 → 双边(12 毫秒,一次而已)。 必须实时又要双边 → 降分辨率、只处理人脸区域、或者上 GPU。

「只处理人脸区域」这一招最实用:人脸检测框占画面 1/10 面积, 只对那块做双边,耗时直接降到十分之一左右, 而且效果看不出区别——因为脸以外的地方本来也不该磨。

「上 GPU」在 OpenCV 里是 UMat 那一套:把 Mat 换成 UMat, 同一个 bilateralFilter 调用就会走 OpenCL。 代码改动极小,但前提是显卡驱动带 OpenCL,而且第一次调用要编译内核,会卡一下。

最后一条经验:别在「磨得干不干净」上纠结参数,先看边缘保不保。 因为磨过头看起来像「糊」,而糊了就没法救。 磨得不够只是效果淡一点,还能调。

四、动手 Demo:磨皮美颜,三种滤波器的代价与效果

这段造一张有硬边缘、也有噪声的图(背景灰度 100、中间一个灰度 200 的圆、 外加标准差 8 的高斯噪声),然后拿四种滤波去处理它。 评价用两个数,不用眼睛:平坦区标准差和边缘梯度保留百分比。

# 造测试图:圆边就是那条必须保住的「台阶」
img = np.full((480, 640, 3), 100, np.uint8)
cv2.circle(img, (320, 240), 120, (200, 200, 200), -1)
img = np.clip(img + rng.normal(0, 8, (480, 640, 1)), 0, 255)

# 评价函数:边缘梯度 + 平坦区标准差
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
edge = np.abs(np.diff(g[240, :])).max()
flat = g[20:60, 20:60].std()

for name, fn in cases:
    out = fn(img)
    print(name, edge_of(out), flat_of(out))

实跑输出:

--------------------------------------------------------------------
磨皮美颜,三种滤波器的代价与效果
--------------------------------------------------------------------
**磨皮的原理只有一句话:把「小的起伏」抹平,保留「大的台阶」。**
**噪声是小起伏,人脸轮廓是大台阶。**

**三个常用滤波器就是这个权衡的三个档位**:
  高斯:谁离得近就多听谁的,**快,但台阶也一起抹**。
  中值:取邻域的中位数,**专治椒盐噪声,台阶保得住,但慢**。
  双边:**距离近的权重大、颜色差得多的权重小。**
  **颜色差得多就是「台阶」,于是台阶被排除在平均之外。**

**测试图**:640×480,背景灰度 100、中间一个灰度 200 的圆,**外加标准差 8 的高斯噪声。**
**圆边就是那条必须保住的「台阶」。**

  原始图:边缘处的最大梯度 **102**,平坦区的标准差 **7.8**

**这两个数就是评价标准**:**平坦区的标准差要变小**(说明噪声被抹掉了),
**边缘处的梯度要尽量不变**(说明轮廓还在)。

  滤波器         耗时 毫秒     边缘梯度           平坦区标准差     评价
  ---------------------------------------------------------------------------------
  高斯 5×5       0.32          34(留 33%)       2.0              **边缘糊了**
  高斯 15×15     1.25          16(留 16%)       0.7              **边缘糊了**
  中值 5         2.18          87(留 85%)       1.8              边缘糊了一点
  双边 d=9       14.61         104(留 102%)     1.4              边缘保住了

**表的读法**:**「平坦区标准差」越小越干净,「边缘梯度」保留百分比越高越保真。**

**高斯 5×5 是个典型的中庸**:噪声从 7.8 降到 2.0,边缘还剩 33%。**这是「够用又便宜」的那一档。**

**高斯 15×15 就是「糊」的样板**:核一大,平坦区更干净,
**但边缘的梯度被摊到十几个像素上,台阶变成斜坡。**
**美颜过度那种「塑料脸」就是这一步做过头了。**

**双边是唯一「两头都要」的**:平坦区降得比高斯 5×5 更多,
**边缘却几乎不动**——**因为它按颜色差加权,台阶两侧的像素互相「看不上」,不参与平均。**
**代价是耗时高出一个数量级。**

**这个数量级差距是结构性的,不是实现不好**:高斯可以拆成「先横后竖」两遍一维卷积,
**计算量与核大小无关**;**双边核的权重每个像素都不一样,拆不开,只能老老实实做二维。**

**所以工程上的选择很清楚**:**做预览用高斯小核,做「美颜」按钮才用双边。**
**如果非要实时双边,就得挪到 GPU 或者用导向滤波那类近似算法。**

原始图的两个数是:边缘梯度 102、平坦区标准差 7.8。 后面四行都拿这两个数当尺子。

四行的对照非常有规律: 高斯越大核越干净、越毁边缘(33% → 16%)。 中值几乎不动边缘(85%),但代价是 2.55 毫秒。 双边边缘完全不动(102%),代价是 12.12 毫秒。

「高斯 5×5 只保留 33%」这个数第一次看会吓一跳:才 5×5 的核,边缘就掉了三分之二。 原因是测试图的台阶太干净了——一个像素内从 100 跳到 200, 任何平均都会把这一个像素的落差摊到五个像素上。 真实照片的边缘本来就有两三个像素的过渡,所以掉得没这么狠。

这就是「用合成图做实验」的局限,要说清楚: 它能给出方向(谁保边、谁不保),但绝对数值不能直接搬到照片上。 真要做参数选择,得拿真人脸去试。

最后一行「双边 102%」再看一眼:它比原图还陡,因为原图的台阶上盖着噪声, 双边把噪声抹掉之后,台阶的真实落差露出来了。 这个细节说明「保边」不是「不动」,是「只平滑同一侧」。

30 秒实验

  1. 把 bilateralFilter 的第三个参数(颜色 sigma)从 60 改成 150,看边缘保留和耗时的变化。
  2. 算一下:30 fps 一帧的预算是多少毫秒?12.12 毫秒占多少?
  3. 判断一下:实时预览 + 一键美颜,你会给这两处分别用什么滤波器?

常见误区

  1. 直接 cap.read(frame) 不判返回值。 失败时 frame 是空 Mat,后面某个操作才崩, 报错跟摄像头无关。
  2. 用 cap.get(CAP_PROP_FRAME_WIDTH) 当真值。 那是声明值,实际帧尺寸要看 frame.cols。
  3. 在 MFC 里调 imshow 加 waitKey。 MFC 有自己的消息循环,waitKey 会抢消息或卡死。 要画到自己的控件上。
  4. 把 bilateralFilter 用在实时预览上。 12 毫秒吃掉 30 fps 预算的三分之一, 叠上采集和显示就掉帧了。预览用高斯小核。
自测三问(点开对答案) 1. 双边滤波为什么能保边? —— 它在空间距离之外又加了颜色距离的权重。 台阶两侧颜色差得多,互相权重接近零,于是平滑只在同一侧发生,不跨越边缘。 2. 为什么双边的耗时比高斯高一个数量级? —— 高斯可拆成横竖两遍一维卷积,计算量与核大小无关; 双边的权重逐像素不同,拆不开,只能做完整二维。 3. `cap.read()` 失败时会发生什么? —— 返回 `false`,`frame` 是空的,不抛异常。 所以循环里必须判返回值,否则后面会拿着空 Mat 继续算。

帧能拿到了、也能处理了,下一个问题是「放到哪」。MFC 里没有 imshow, 得自己把 Mat 变成 Windows 认识的位图——这中间有三个差异。

相关笔记