🐍 第14章 影像处理与人脸辨识
📄 一句话概括

模糊就是「邻居平均一下」,边缘就是「这儿变太快了」。两件事都是像素之间的加减。 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 秒实验

  1. 把演示第一段的高斯核从 (9, 9) 改成 (3, 3),看最大差是变大还是变小。
  2. 把演示第四段 cv2.dilate 的 iterations=2 改成 1,看轮廓数怎么变。
  3. 在演示第六段之前把 arr 缩到 256×256,再跑计时, 看耗时大概变成几分之一——再想想为什么不正好是四分之一。

常见误区

  1. 「边缘检测调出一组最佳参数就通用了。」参数跟着图走,换场景就得重调。 把阈值写进配置,别写死在代码里。
  2. 「最大差两百多,模糊肯定算错了。」那是明暗交界处的正常差值。 要看平缓区域变没变,别看最大值。
  3. 「视频处理有专门的帧对象。 」就是一串图。 read() 一次给一张,你的程序一秒要处理 25 到 30 张。
自测三问(点开对答案) 1. 为什么先模糊再找边缘,效果会更好? —— 因为噪点就是「跟邻居差别很大的孤立点」,模糊一平均就把它抹平了。 不模糊直接找边缘,噪点全被当成边缘,轮廓连不成块。 代价是真实的细节也被抹掉一点,所以要选合适的核大小。 2. Canny 的两个阈值调小会发生什么? —— 抓到的边缘点变多,噪点也更多地被当成边缘。 演示里 30/90 抓到 33,594 个点,是 100/200 那组的 1.63 倍。调大则相反,边缘变少、可能断裂。 没有通用最优值,得拿你的图去试。 3. 「实时处理」对一段 30 帧/秒的视频意味着什么? —— 意味着一帧只有 33 毫秒。 灰度、模糊、边缘这种简单流水线够用(实测 3.6 毫秒),但神经网络那种就悬了。 所以监控里常见的做法是降分辨率、隔帧处理,或者只处理变化的区域。

下一篇:让机器认出「这张脸在哪」,再谈「这张脸是谁」。

相关笔记