模糊就是「邻居平均一下」,边缘就是「这儿变太快了」。两件事都是像素之间的加减。 Canny 的两个阈值没有标准答案,参数跟着图走:30/90 抓到的点是 100/200 的 1.63 倍。 512×512 的简单流水线在这台机器上跑 3.6 毫秒,一帧预算 33 毫秒。 但那只是简单算法,神经网络是另一回事。
上一篇把图看成了数组。这一篇让机器从数组里看出「哪里糊」「哪里是边」。 教材第 14 章中间三节是【机器视觉应用】【使用 OpenCV 处理摄影机视频】 和【OpenCV 实时人脸侦测】。 这一篇全部真跑:模糊、Sobel、Canny、轮廓、两帧对比、整条流水线计时, OpenCV 4.10 装的都是真的。 只有一处是复刻:没有摄像头,所以视频那一段用同一张图的两种亮度冒充两帧。 帧的结构是真的,来源是假的。
一、模糊就是把邻居平均一下
均值模糊 blur(9×9):每个点取周围 81 个点的平均。
高斯模糊 GaussianBlur(9×9):也是取邻居,但越近的邻居权重越大。
演示里两种结果跟原图的最大差:均值 214.0,高斯 161.0。
两百多不是算错了。 那是明暗交界处——一个白点旁边是黑点,平均完落在中间,差值自然大。
平缓区域几乎不变。
两个数不一样也说明高斯更「温和」:它给远处邻居的权重小,所以边缘不会被抹得那么平。 模糊的用处比看起来大: 先模糊再找边缘,能少掉一大片噪点。 因为噪点就是「跟邻居差别很大的孤立点」,一平均就没了。
二、边缘就是「这儿变太快了」
Sobel 是求导数:邻居之间差多少。差得多就是边缘,差得少就是平缓区域。 演示里横向变化强度最大 1034,平均 62.3。 所以边缘图看起来像线描稿——它只留下变化剧烈的地方。 Canny 比 Sobel 多一层:它用两个阈值配合。强的直接算边缘,弱的看它挨不挨着强边缘。
演示里 Canny(100, 200) 找出 20,556 个边缘点,占全图 7.8%。
两个数越小,抓到的边缘越多,也越多噪点被当成边缘。这不是调优,这是取舍。
三、阈值没有标准答案
| 两个阈值 | 边缘点数 | 占比 |
|---|---|---|
| 30 / 90 | 33,594 | 12.82% |
| 100 / 200 | 20,556 | 7.84% |
| 200 / 300 | 13,594 | 5.19% |
最松的那组比中间那组多抓了 63% 的点。同一张图,换个阈值差这么多。 所以「边缘检测」没有标准答案。一张夜里拍的照片和一张白天的图,阈值不可能一样。 实际做法是:先拿几张典型图试,找到一组「够用」的,然后把它写进配置,别写死在代码里。
因为换个场景就得重调,写死之后你只能改代码、重新发布。参数是数据,不是逻辑。
四、轮廓:把线连成块
边缘是一条条线,轮廓是首尾相连的一块。 演示里找到 10 个轮廓,最大的五个面积是 129529、2506、940、623、392。 面积小于 500 的有 6 个——多半是噪点。 所以「数图里有几个东西」, 就是「数有几个面积够大的轮廓」。 工业上自动数零件就是这个路子:拍照、灰度、边缘、轮廓、数个数。
难点从来不在算法,在打光。 光打不好,边缘断断续续,轮廓就连不成块,怎么调参都数不准。 这也是为什么机器视觉的项目里,光源和相机支架的预算常常比算法贵得多。
五、视频就是一串图
演示第五段用同一张 coffee.png 和它的暗版冒充两帧:都是 (400, 600, 3),平均亮度 104 和 62。
视频没有神秘的「帧」对象,就是一秒 25 到 30 张图连着放。
OpenCV 的 VideoCapture 每次 read() 给你一张,处理完再读下一张。
所以「实时」的含意很具体:一秒 30 帧,你每帧只有 33 毫秒。
这也解释了为什么监控里做复杂算法要降分辨率或者隔帧处理——不是算法不够好,是时间不够。 还有那个「相邻帧高度相似」:两帧尺寸完全一样,只有亮度差 50。 所以才有「只处理变了的区域」这种优化,监控里省算力全靠它。
六、一条流水线有多快
演示第六段把「灰度、高斯模糊、Canny、找轮廓」串起来跑五次,取最快的一次:3.6 毫秒。 按 30 帧/秒算,一帧预算 33 毫秒,够用。 这个数只能看量级。 本机是台普通电脑,而且 OpenCV 的耗时跟图大小、机器性能、 有没有开多线程都有关系。 写法上也有讲究:先预热一次,再取五次的最快。 第一次跑总带着库加载的冷启动,平均进去就成了假数据。
它说明的实在事是:简单流水线跑 512×512 是够快的。 难的是要跑神经网络的算法——那才需要显卡。所以「机器视觉慢」这句话要看具体做什么,不能一概而论。
七、动手 Demo:模糊、边缘、轮廓:机器在看什么
同一张图,走一遍模糊、Sobel、Canny、轮廓。 再换三组 Canny 阈值看差多少,最后量一下整条流水线的耗时。
import os
import time
import cv2
import numpy as np
from PIL import Image
IMG_DIR = 'img' # 放 astronaut.png / coffee.png / chelsea.png
def load(name):
return np.array(Image.open(os.path.join(IMG_DIR, name)).convert('RGB'))
arr = load('astronaut.png')
bgr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
print(' ① 模糊就是把邻居平均一下')
print(' ' + '-' * 68)
mean = cv2.blur(bgr, (9, 9))
gauss = cv2.GaussianBlur(bgr, (9, 9), 0)
print(' 均值模糊 blur(9×9):每个点取周围 81 个点的平均')
print(' 高斯模糊 GaussianBlur(9×9):越近的邻居权重越大')
print(' 两种结果与原图的最大差:均值 %.1f,高斯 %.1f'
% (np.abs(mean.astype(int) - bgr.astype(int)).max(),
np.abs(gauss.astype(int) - bgr.astype(int)).max()))
print(' 模糊的用处比看起来大:先模糊再找边缘,能少掉一大片噪点。')
print(' 因为噪点就是「跟邻居差别很大的孤立点」,一平均就没了。')
print(' 最大差那么大(两百多)不是算错了,是边缘那些明暗交界处——一个白点旁边是黑点,平均完就落在中间,')
print(' 差值自然大。平缓区域几乎不变。')
print()
print(' ② 边缘就是「这儿变太快了」')
print(' ' + '-' * 68)
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
sx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
sy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
mag = np.sqrt(sx ** 2 + sy ** 2)
print(' Sobel 横向变化强度:最大 %.0f,平均 %.1f' % (mag.max(), mag.mean()))
edges = cv2.Canny(gray, 100, 200)
print(' Canny(100, 200) 找出的边缘点:%s 个,占全图 %.1f%%'
% (format(int((edges > 0).sum()), ','), (edges > 0).sum() * 100.0 / edges.size))
print(' Sobel 是求导数:邻居之间差多少。差得多就是边缘,差得少就是平缓区域。所以边缘图看起来像线描稿。')
print(' Canny 是两个阈值配合:强的直接算边缘,弱的看它挨不挨着强边缘。')
print(' 两个数越小,抓到的边缘越多,也越多噪点被当成边缘。')
print()
print(' ③ 换个阈值,结果差多少')
print(' ' + '-' * 68)
print()
print('| Canny 两个阈值 | 边缘点数 | 占比 |')
print('|---:|---:|---:|')
counts = []
for lo, hi in ((30, 90), (100, 200), (200, 300)):
e = cv2.Canny(gray, lo, hi)
n = int((e > 0).sum())
counts.append(n)
print('| %d / %d | %s | %.2f%% |' % (lo, hi, format(n, ','), n * 100.0 / e.size))
print()
print(' 最松的那组比中间那组多抓了 %.0f%% 的点。' % ((counts[0] * 1.0 / counts[1] - 1) * 100))
print(' 所以「边缘检测」没有标准答案,参数是跟着图走的——一张夜里拍的照片和一张白天的图,阈值不可能一样。')
print()
print(' ④ 轮廓:把边缘连成一个个形状')
print(' ' + '-' * 68)
e = cv2.dilate(cv2.Canny(gray, 60, 160), None, iterations=2)
cnts, _ = cv2.findContours(e, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
areas = sorted([cv2.contourArea(c) for c in cnts], reverse=True)
print(' 找到 %d 个轮廓' % len(cnts))
print(' 最大的五个面积:%s' % '、'.join('%.0f' % a for a in areas[:5]))
print(' 面积小于 500 的:%d 个(多半是噪点)' % len([a for a in areas if a < 500]))
print(' 边缘是一条条线,轮廓是首尾相连的一块。数「图里有几个东西」,就是数「有几个面积够大的轮廓」。')
print(' 这也是工业上自动数零件的基本做法:拍照 → 灰度 → 边缘 → 轮廓 → 数个数。')
print(' 难点从来不在算法,在打光——光打不好,怎么调都数不准。')
print()
print(' ⑤ 视频就是一串图')
print(' ' + '-' * 68)
arr2 = load('coffee.png')
dark = np.clip(arr2.astype(int) - 50, 0, 255).astype(np.uint8)
print(' 本沙箱没有摄像头,这里用同一张图的两种亮度冒充两帧')
for i, f in enumerate((arr2, dark)):
g = cv2.cvtColor(cv2.cvtColor(f, cv2.COLOR_RGB2BGR), cv2.COLOR_BGR2GRAY)
print(' 第 %d 帧 shape=%r 平均亮度 %.0f' % (i, f.shape, g.mean()))
print(' 两帧尺寸完全一样,只有亮度差 50。')
print(' 视频处理里这叫「相邻帧高度相似」,所以才有「只处理变了的区域」这种优化——监控里省算力全靠它。')
print(' 视频没有神秘的「帧」对象,就是一秒 25 到 30 张图连着放。')
print(' OpenCV 的 VideoCapture 每次 read() 给你一张,处理完再读下一张。')
print(' 所以「实时」的含意很具体:一秒 30 帧,你每帧只有 33 毫秒。')
print(' 这也解释了为什么监控里做复杂算法要降分辨率或者隔帧处理。')
print()
print(' ⑥ 整条流水线的耗时')
print(' ' + '-' * 68)
def pipe():
g0 = cv2.cvtColor(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR), cv2.COLOR_BGR2GRAY)
g0 = cv2.GaussianBlur(g0, (5, 5), 0)
return cv2.findContours(cv2.Canny(g0, 60, 160), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
pipe() # 预热一次,第一次带库加载的冷启动不能算
cost = None
for _ in range(5):
t0 = time.perf_counter()
pipe()
t = (time.perf_counter() - t0) * 1000
cost = t if cost is None else min(cost, t) # 取最快的一次,别拿毛刺当结论
print(' 512×512 全流程一遍:%.1f 毫秒(预热一次,取五次的最快)' % cost)
print(' 按 30 帧/秒算,一帧的预算是 33 毫秒 → %s'
% ('够用' if cost < 33 else '不够,得降分辨率'))
print(' 这个数只能看量级:本机是台普通电脑,而且 OpenCV 的耗时跟图大小、机器性能、')
print(' 有没有开多线程都有关系。')
print(' 但它说明了一件实在的事:简单流水线跑 512×512 是够快的,')
print(' 难的是那些要跑神经网络的算法——那才需要显卡。')
实跑输出:
--------------------------------------------------------------------
模糊、边缘、轮廓:机器在看什么
--------------------------------------------------------------------
① 模糊就是把邻居平均一下
--------------------------------------------------------------------
均值模糊 blur(9×9):每个点取周围 81 个点的平均
高斯模糊 GaussianBlur(9×9):越近的邻居权重越大
两种结果与原图的最大差:均值 214.0,高斯 161.0
模糊的用处比看起来大:先模糊再找边缘,能少掉一大片噪点。
因为噪点就是「跟邻居差别很大的孤立点」,一平均就没了。
最大差那么大(两百多)不是算错了,是边缘那些明暗交界处——一个白点旁边是黑点,平均完就落在中间,
差值自然大。平缓区域几乎不变。
② 边缘就是「这儿变太快了」
--------------------------------------------------------------------
Sobel 横向变化强度:最大 1034,平均 62.3
Canny(100, 200) 找出的边缘点:20,556 个,占全图 7.8%
Sobel 是求导数:邻居之间差多少。差得多就是边缘,差得少就是平缓区域。所以边缘图看起来像线描稿。
Canny 是两个阈值配合:强的直接算边缘,弱的看它挨不挨着强边缘。
两个数越小,抓到的边缘越多,也越多噪点被当成边缘。
③ 换个阈值,结果差多少
--------------------------------------------------------------------
| Canny 两个阈值 | 边缘点数 | 占比 |
|---:|---:|---:|
| 30 / 90 | 33,594 | 12.82% |
| 100 / 200 | 20,556 | 7.84% |
| 200 / 300 | 13,594 | 5.19% |
最松的那组比中间那组多抓了 63% 的点。
所以「边缘检测」没有标准答案,参数是跟着图走的——一张夜里拍的照片和一张白天的图,阈值不可能一样。
④ 轮廓:把边缘连成一个个形状
--------------------------------------------------------------------
找到 10 个轮廓
最大的五个面积:129529、2506、940、623、392
面积小于 500 的:6 个(多半是噪点)
边缘是一条条线,轮廓是首尾相连的一块。数「图里有几个东西」,就是数「有几个面积够大的轮廓」。
这也是工业上自动数零件的基本做法:拍照 → 灰度 → 边缘 → 轮廓 → 数个数。
难点从来不在算法,在打光——光打不好,怎么调都数不准。
⑤ 视频就是一串图
--------------------------------------------------------------------
本沙箱没有摄像头,这里用同一张图的两种亮度冒充两帧
第 0 帧 shape=(400, 600, 3) 平均亮度 104
第 1 帧 shape=(400, 600, 3) 平均亮度 62
两帧尺寸完全一样,只有亮度差 50。
视频处理里这叫「相邻帧高度相似」,所以才有「只处理变了的区域」这种优化——监控里省算力全靠它。
视频没有神秘的「帧」对象,就是一秒 25 到 30 张图连着放。
OpenCV 的 VideoCapture 每次 read() 给你一张,处理完再读下一张。
所以「实时」的含意很具体:一秒 30 帧,你每帧只有 33 毫秒。
这也解释了为什么监控里做复杂算法要降分辨率或者隔帧处理。
⑥ 整条流水线的耗时
--------------------------------------------------------------------
512×512 全流程一遍:3.6 毫秒(预热一次,取五次的最快)
按 30 帧/秒算,一帧的预算是 33 毫秒 → 够用
这个数只能看量级:本机是台普通电脑,而且 OpenCV 的耗时跟图大小、机器性能、
有没有开多线程都有关系。
但它说明了一件实在的事:简单流水线跑 512×512 是够快的,
难的是那些要跑神经网络的算法——那才需要显卡。
第一段那两个最大差(214.0 和 161.0)容易吓人。 它们不是误差,就是边缘交界处的正常差值——平缓区域几乎不变。 第三段那张表是全篇最实用的一张:同一张图,30/90 抓到 33,594 个点,200/300 只抓 13,594 个。 差一倍多。 第四段那个「面积小于 500 的 6 个」,就是噪点。数零件时先把这些小轮廓扔掉,比调参省事。 第五段是复刻:没有摄像头,用同一张图的亮版和暗版冒充两帧。 帧的结构(形状、亮度)是真的,来源是假的。 第六段那个毫秒数跟机器有关,你这台跑出来不会是 3.6。看量级就行,别当基准。
30 秒实验
- 把演示第一段的高斯核从
(9, 9)改成(3, 3),看最大差是变大还是变小。 - 把演示第四段
cv2.dilate的iterations=2改成 1,看轮廓数怎么变。 - 在演示第六段之前把
arr缩到 256×256,再跑计时, 看耗时大概变成几分之一——再想想为什么不正好是四分之一。
常见误区
- 「边缘检测调出一组最佳参数就通用了。」参数跟着图走,换场景就得重调。 把阈值写进配置,别写死在代码里。
- 「最大差两百多,模糊肯定算错了。」那是明暗交界处的正常差值。 要看平缓区域变没变,别看最大值。
- 「视频处理有专门的帧对象。
」就是一串图。
read()一次给一张,你的程序一秒要处理 25 到 30 张。
自测三问(点开对答案)
1. 为什么先模糊再找边缘,效果会更好? —— 因为噪点就是「跟邻居差别很大的孤立点」,模糊一平均就把它抹平了。 不模糊直接找边缘,噪点全被当成边缘,轮廓连不成块。 代价是真实的细节也被抹掉一点,所以要选合适的核大小。 2. Canny 的两个阈值调小会发生什么? —— 抓到的边缘点变多,噪点也更多地被当成边缘。 演示里 30/90 抓到 33,594 个点,是 100/200 那组的 1.63 倍。调大则相反,边缘变少、可能断裂。 没有通用最优值,得拿你的图去试。 3. 「实时处理」对一段 30 帧/秒的视频意味着什么? —— 意味着一帧只有 33 毫秒。 灰度、模糊、边缘这种简单流水线够用(实测 3.6 毫秒),但神经网络那种就悬了。 所以监控里常见的做法是降分辨率、隔帧处理,或者只处理变化的区域。下一篇:让机器认出「这张脸在哪」,再谈「这张脸是谁」。
- ☐ (本篇完,下一篇 14.3 人脸侦测与识别)