取帧只有一句 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 秒实验
- 把
bilateralFilter的第三个参数(颜色 sigma)从 60 改成 150,看边缘保留和耗时的变化。 - 算一下:30 fps 一帧的预算是多少毫秒?12.12 毫秒占多少?
- 判断一下:实时预览 + 一键美颜,你会给这两处分别用什么滤波器?
常见误区
- 直接
cap.read(frame)不判返回值。 失败时frame是空 Mat,后面某个操作才崩, 报错跟摄像头无关。 - 用
cap.get(CAP_PROP_FRAME_WIDTH)当真值。 那是声明值,实际帧尺寸要看frame.cols。 - 在 MFC 里调
imshow加waitKey。 MFC 有自己的消息循环,waitKey会抢消息或卡死。 要画到自己的控件上。 - 把
bilateralFilter用在实时预览上。 12 毫秒吃掉 30 fps 预算的三分之一, 叠上采集和显示就掉帧了。预览用高斯小核。
自测三问(点开对答案)
1. 双边滤波为什么能保边? —— 它在空间距离之外又加了颜色距离的权重。 台阶两侧颜色差得多,互相权重接近零,于是平滑只在同一侧发生,不跨越边缘。 2. 为什么双边的耗时比高斯高一个数量级? —— 高斯可拆成横竖两遍一维卷积,计算量与核大小无关; 双边的权重逐像素不同,拆不开,只能做完整二维。 3. `cap.read()` 失败时会发生什么? —— 返回 `false`,`frame` 是空的,不抛异常。 所以循环里必须判返回值,否则后面会拿着空 Mat 继续算。帧能拿到了、也能处理了,下一个问题是「放到哪」。MFC 里没有 imshow,
得自己把 Mat 变成 Windows 认识的位图——这中间有三个差异。
- ☐ (本节完,下一节 05.03 MFC结合OpenCV显示图片)