1. 从一个被噪声毁掉的边缘检测工程说起
前阵子接手一个工业视觉的小活儿:相机固定在工位上拍金属件表面,要做缺陷轮廓提取。图拍出来肉眼看着还行,一跑边缘检测,结果边缘碎得像被谁用剪刀剪过,密密麻麻全是短小毛刺,同一个零件的轮廓在相邻两帧里的位置能差好几个像素。当时第一反应是阈值调错了,把低阈值往上抬、高阈值往上抬,调了一下午,毛刺少了一点,但真正的缺陷边也跟着消失了。后来把中间结果导出来一看,问题根本不在阈值——传感器在弱光下的随机噪声被梯度算子成倍放大了,8 位灰度图上±3 个灰阶的抖动,经过 Sobel 之后能变成 ±20 以上的梯度响应,跟真实边缘混在一起,根本分不开。
那次的解决方案很朴素:在梯度计算之前先做一次高斯滤波。高斯滤波(Gaussian Filter)做的事情说穿了就一句话——用一个小尺寸的、权重按钟形曲线分布的窗口在图像上滑动,每个像素的新值取邻域的加权平均,越靠近中心的邻居话语权越大,越远的越没资格说话。它把高频的随机抖动压下去,同时尽量不去动低频的整体亮度结构。听起来跟均值滤波没什么本质区别,但真上手用起来,两者在"边缘被糊成什么样""后续算法还能不能收敛"上的差别,远比教科书上画的那两条曲线要明显。
这篇东西不打算写成教科书。我把它拆成几块:先讲清楚为什么要用高斯而不是别的平滑手段,再把公式背后的两个参数(核半径和标准差)掰开揉碎讲明白它们怎么互相牵制,然后从零手写一遍一维和二维的实现,包括核的生成、边界怎么补、怎么用分离卷积把计算量从平方级压成线性级,再讲定点整数化在嵌入式场景下的做法。最后聊几个文档里基本不会提的坑:核大小取偶数会怎样、彩色图分通道处理为什么可能出问题、大核在大图上的性能账怎么算。不管你是刚学图像处理、还在纠结卷积核怎么填的学生,还是已经在项目里天天调GaussianBlur却一直没搞懂那几个参数含义的工程师,应该都能从里面捞到点能直接用的东西。
1.1 噪声为什么总藏在高频里,而边缘也藏在同一片地方
先把"频率"这件事在图像上说清楚。图像的频率不是什么玄学,它描述的是相邻像素之间灰度变化的剧烈程度。一片均匀的天空区域,相邻像素灰度几乎一样,这是低频;一片密集的栅栏、一排细密的螺纹,相邻像素灰度来回跳,这是高频。传感器噪声的成因是热噪声、读出噪声、量化误差,它的特点是在空间上完全不相关——当前像素偏亮还是偏暗,跟它旁边那个像素偏亮还是偏暗没有任何关系。这种"完全不相关"在频域上就表现为能量均匀铺满整个高频段,看起来像一层细密的雪花。
麻烦的地方在于,图像里真正有价值的细节——边缘、角点、细小划痕——同样是灰度剧烈变化的地方,同样落在高频段。噪声和边缘在频域上是重叠的,你没法画一条线说"这条线以上是噪声、以下是信号",然后一刀切掉。这就是所有平滑滤波器的根本困境:它只能在"压噪声"和"保边缘"之间做取舍,不存在两全其美的解。高斯滤波的选择是偏向压噪声那一侧,用边缘的轻微模糊换取梯度的干净。这也是为什么在 Canny 边缘检测的标准流程里,高斯平滑被放在第一步,而且它的参数会直接影响后面双阈值的选择——你把 σ 调大,梯度幅值整体会被削平,原来的高阈值就偏高了,边缘会断;σ 调小,噪声又回来了。这三个参数从来都不是独立的。
顺带提一句,如果噪声是椒盐型(孤立的纯黑纯白点),高斯滤波其实不是最优解,它会把一个孤立亮点摊成一小片灰雾,效果不如中值滤波干脆。高斯擅长对付的是加性高斯白噪声,这也是它名字的由来之一,同时也是实际传感器噪声最接近的模型。
1.2 同样是低通滤波,为什么工程上几乎默认选高斯
理想情况下,我们想要的是一个"砖墙式"的低通:截止频率以内原样通过,以外彻底归零。但这个理想滤波器在空域上对应的是 sinc 函数,它在正负无穷上都有值,你必须截断它才能做成有限大小的核。一旦截断,频域上就会出现吉布斯现象——在边缘附近产生一圈过冲和下冲的波纹,学名叫振铃。图像上的表现就是:一条原本干净的边,两边各多出一条亮线和暗线,像浮雕一样。做压缩、做编码的时候这个振铃特别致命,会凭空造出视觉上很显眼的伪影。
高斯核在这一点上几乎是天然免疫的。原因有两层:第一,高斯函数在空域上本身就是快速衰减的,截断到 ±3σ 以外的部分能量占比不到 0.3%,截断带来的误差本来就小;第二,高斯的傅里叶变换仍然是高斯,也就是说它在频域上也是平滑过渡的钟形,没有任何陡峭的跳变,自然就不会振铃。这两条性质合起来,让高斯成为一个在空域和频域同时都很"温和"的核,这是它地位稳固的根本原因。
除此之外还有几个工程上极其受用的性质:
- 旋转对称:二维高斯是各向同性的,无论边缘朝哪个方向,被模糊的程度都一样。用方形均值核就会出问题,它对方形方向和对角方向的平滑力度不同,模糊后的圆会变成一个略胖的十字。
- 可分离性:二维核可以拆成两个一维核的外积,这一点后面单独讲,是性能优化的命根子。
- 多尺度一致性:两个高斯核串联,等价于一个 σ 为两者平方和开根号的高斯核。这个性质让"先小模糊再大模糊"和"一次大模糊"在数学上一致,是做图像金字塔和尺度空间的理论基础,别的核基本做不到这一点。
- 参数只有一个:σ 一个数就完整描述了核的形状,半径可以由它推出来,调参维度极低。
下面这张表是我自己在选核时的粗略参照,不是绝对标准,但方向大致没错:
| 平滑核 | 频域特性 | 边缘表现 | 旋转对称 | 可分离 | 典型场景 |
|---|---|---|---|---|---|
| 均值核 | 旁瓣大,有振铃倾向 | 糊得均匀,方向性明显 | 否 | 是(且可用积分图 O(1)) | 实时性极高的粗预处理 |
| 高斯核 | 平滑衰减,无振铃 | 边缘柔和过渡 | 是 | 是 | 去噪、梯度前置、尺度空间 |
| 中值核 | 非线性,无法用频域描述 | 保边能力强 | 是 | 否 | 椒盐噪声、去孤立点 |
| 双边核 | 非线性,值域加权 | 保边强,但会出梯度反转 | 是 | 否 | 美颜、HDR、保边平滑 |
| 导向核 | 局部线性模型 | 保边强,无梯度反转 | 是 | 否 | 抠图、去雾、羽化 |
2. 两个参数撑起的全部:高斯函数的数学骨架
2.1 一维到二维,再从二维拆回一维
一维高斯函数长这样:
G(x) = 1 / (sqrt(2π) · σ) · exp( -x² / (2σ²) )前面那个系数是归一化因子,保证整条曲线下的面积等于 1。二维版本是把它在两个独立方向上相乘:
G(x, y) = 1 / (2πσ²) · exp( -(x² + y²) / (2σ²) )注意指数里是x² + y²,也就是到中心的欧氏距离的平方。这意味着等值线是一圈一圈的圆,权重只跟离中心的距离有关,跟方向无关——这就是旋转对称性的数学来源。
接下来是关键一步:把指数拆开。
exp( -(x² + y²) / (2σ²) ) = exp( -x² / (2σ²) ) · exp( -y² / (2σ²) )也就是说,二维高斯核矩阵里每个元素的值,等于一维行向量里第 x 个元素乘以一维列向量里第 y 个元素。整个核矩阵是一个秩为 1 的矩阵,可以写成两个向量的外积:
K_2d = k_1d ⊗ k_1dᵀ这件事在工程上的价值怎么强调都不过分。假设核半径是 r,核大小是 (2r+1)×(2r+1)。直接做二维卷积,每个像素要做 (2r+1)² 次乘加;拆成"先横着卷一遍、再竖着卷一遍",每个像素只需要 2×(2r+1) 次。当 r=10 的时候,一个是 441 次,一个是 42 次,差了一个数量级。r 越大差距越夸张,这是线性的,另一个是平方的。
我见过不少手写实现的代码,核生成对了,边界处理也对,就是老老实实跑双重循环做二维卷积,一张 4000×3000 的图跑几秒钟,然后抱怨"高斯滤波太慢"。其实改成分离实现,改不了几行代码,速度立刻掉一个数量级。这一点后面第 3 章会给完整代码。
2.2 核半径和 σ 的耦合:3σ 原则到底怎么来的
这是新手最容易犯迷糊的地方:σ 和核大小是什么关系?能不能随便定?
先说结论:σ 决定形状,半径决定截断位置,两者有推荐的比例关系,但不是强制的。
理论上高斯函数的支撑集是整个实数轴,永远不为零。但我们不可能做一个无限大的核,必须截断。截断在哪里合适?这就要用到正态分布的"3σ 原则":对于标准差为 σ 的正态分布,落在 ±1σ 内的概率约 68.27%,±2σ 内约 95.45%,±3σ 内约 99.73%。换句话说,把半径取到 3σ,被丢弃的权重加起来只有约 0.27%,在 8 位灰度(量化步长 1/255 ≈ 0.4%)的精度下基本可以忽略。
所以经验公式是:
radius = ceil(3 · σ) ksize = 2 · radius + 1举几个具体数字,你可以直接对照:
| σ | 3σ | 半径 r | 核大小 | 一维权重末尾值(归一化后) |
|---|---|---|---|---|
| 0.5 | 1.5 | 2 | 5 | 约 0.0108 |
| 1.0 | 3.0 | 3 | 7 | 约 0.0044 |
| 1.5 | 4.5 | 5 | 11 | 约 0.0011 |
| 2.0 | 6.0 | 6 | 13 | 约 0.0004 |
| 3.0 | 9.0 | 9 | 19 | 约 0.00002 |
可以看到 σ 越大,边缘权重衰减得越快,截断得越"放心"。反过来 σ 很小的时候(比如 0.5),3σ 只有 1.5,取整半径 2,边缘权重还有 1% 左右,丢掉这 1% 会有轻微影响,所以有些实现会在 σ 很小时把半径取得比 3σ 稍大一点,比如max(3σ, 1)之后再额外加一。
还有一个常被忽略的点:半径取大一点不会让模糊变强,因为核做了归一化,多包进来几个权重极小的项对结果几乎没影响,只是白白增加计算量。半径取小一点才会真的改变结果,因为它相当于把高斯曲线从两侧硬生生砍掉了,砍得狠了就会在频域上引入一点点振铃。我个人的习惯是老老实实按 3σ 来,如果对性能有要求,最多缩到 2.5σ,再小就要看具体图像的容忍度了。
2.3 归一化:一个看起来不起眼但要命的步骤
核生成完之后必须做一次归一化,把所有元素的和除掉,让总和严格等于 1。这一步的道理很简单:卷积本质上是一个加权平均,如果权重总和是 1,那么一片灰度恒定为 128 的区域,滤波后还是 128,颜色不会整体变亮或变暗。如果忘了归一化,滤波后整张图会按权重总和的比例变亮(总和 > 1)或变暗(总和 < 1),而且这个偏差会随 σ 变化,调参的时候看起来"σ 越大图越亮",非常迷惑。
我见过一个真实案例:某同学手写实现,公式抄对了,但漏了归一化,因为高斯函数前面的1/(sqrt(2π)·σ)系数他也没写。结果核里所有值都变成了exp(-x²/(2σ²)),最大值是 1,两端趋近 0,总和远大于 1。滤波后图像严重过曝,白的地方全变成 255 一片死白。他一开始以为是数据类型溢出,查了半天clip,最后才发现是归一化的事。
顺带说一句归一化的时机:应该在生成浮点核之后、转换成定点整数之前做一次。如果用整数运算,归一化就变成了"除以总和"或者"乘以 1/总和 的定点表示再右移",这时候更要注意舍入误差的累积——7 个系数每个都舍掉不到 1,最终总和可能从 256 变成 254,整体就暗了 0.8%。所以定点实现里通常会在最后一步补一个常数(比如+128)来吸收四舍五入,或者手动微调最大的那个系数,让总和恰好等于 2 的整数次幂。
3. 手写一遍:从生成核到补边界,再到分离卷积
3.1 一维核的生成,比想象中多几个坑
先上代码。这是我最常用的一维核生成函数,用 NumPy 写的,逻辑清晰,也方便直接拿去改:
import numpy as np def gaussian_kernel_1d(sigma, radius=None): if sigma <= 0: raise ValueError("sigma must be positive") if radius is None: radius = max(1, int(np.ceil(3.0 * sigma))) x = np.arange(-radius, radius + 1, dtype=np.float64) kernel = np.exp(-(x ** 2) / (2.0 * sigma ** 2)) kernel /= kernel.sum() return kernel几个细节值得说明。
np.arange(-radius, radius + 1)这一段,一定要带+1。不带的话生成的是从 -r 到 r-1 共 2r 个点,中心就落在两个像素的缝隙里了,卷积时核的中心跟当前像素对不上,整张图会偏移半个像素。这个 bug 极其隐蔽,因为偏移半像素在视觉上几乎看不出来,但如果你要做亚像素级的测量(比如定位一个圆形工件的圆心),这半个像素的偏差会直接传导到最终结果里。
核里没有显式写1/(sqrt(2π)·σ)这个系数,因为后面统一除以了总和。两种做法等价,但先算指数再归一化的写法数值稳定性更好一些——σ 非常小的时候,前面的系数会变得很大,跟指数部分相乘容易产生精度问题。
kernel.sum()是 float64,加起来误差很小。如果核特别大(比如 σ=50,半径 150,核长 301),累加误差还是会积累,可以考虑用np.sum(kernel, dtype=np.float64)显式指定,或者用 Kahan 求和。当然这种级别的核在实际项目里通常会改用别的近似手段,不太会真的去卷积。
还有一个隐含的坑:σ 的物理含义。很多人以为 σ 是一个"模糊强度"的抽象等级,调大了就糊,调小了就清楚。其实它有明确的单位,就是像素。σ=2 意味着权重分布的标准差是两个像素,直观理解就是:中心像素的权重约等于它左右两个像素权重之和,模糊半径大致相当于"±2 个像素内开始明显变淡"。知道了这一点,你就能根据图像分辨率来估参数——1080p 的图像上 σ=1 是轻微去噪,4K 的图像上同样的视觉强度大概需要 σ=2。跨分辨率复用同一套参数是新手常踩的坑。
3.2 边界的四种补法,差别比你想的大
卷积到图像最外圈的时候,核会伸出去,伸出去的部分没有像素怎么办?这就涉及边界延拓策略。常见的四种:
| 策略 | 行为 | 边界处效果 | 适用场景 |
|---|---|---|---|
| zero(补零) | 图外全部当黑 | 四周会出现一圈暗边 | 医学影像、图外确实无信息 |
| reflect(镜像) | 以边缘为轴翻转 | 边界处自然延续,无突变 | 通用首选,OpenCV 默认 BORDER_REFLECT_101 |
| replicate(复制) | 边缘像素向外复制 | 边界处略微"拉平" | 大面积均匀背景 |
| wrap(环绕) | 从对侧取像素 | 周期纹理无缝 | 纹理合成、FFT 卷积 |
补零最直观,但问题最大。假设图像边缘是一块灰度 200 的亮面,外面突然补一圈 0,卷积之后边缘那条像素会被严重拉低,形成一条明显的暗带。如果你的算法后面要做阈值分割,这条暗带会被当成物体边缘。我早期做的一个缺陷检测项目就吃过这个亏——暗带被误判成划痕,误检率高得离谱,查了两天才定位到是边界补零。
镜像(reflect)是通用场景下最稳妥的。OpenCV 默认用的是BORDER_REFLECT_101,也就是gfedcb|abcdefgh|gfedcba这种形式,注意它不重复边缘像素本身,而是从紧邻的那个开始翻。还有一种BORDER_REFLECT是fedcba|abcdefgh|hgfedcb,会重复边缘像素。两者差别不大,但在梯度类算法里会有细微不同,因为重复边缘像素相当于人为制造了一个零梯度点。
复制(replicate)在边缘是大片均匀色块的时候表现最好,因为复制出来的值和边缘值完全一样,卷积结果就是常数,不会产生任何伪影。但如果边缘恰好是纹理丰富的区域,复制会造成明显的方向性拉伸。
用 NumPy 实现很简单:
padded = np.pad(img, radius, mode='reflect') # 对应 BORDER_REFLECT_101 padded = np.pad(img, radius, mode='edge') # 对应 BORDER_REPLICATE padded = np.pad(img, radius, mode='constant', constant_values=0) # BORDER_CONSTANT padded = np.pad(img, radius, mode='wrap') # BORDER_WRAP不过np.pad会额外分配一块内存,大图上这个开销不小。追求性能的话,可以直接在卷积循环里做下标钳制(clamp),省掉那次拷贝,代价是每次取值多一次比较。这两种做法在 4K 图上实测差别能到 10%~15%。
3.3 分离卷积的完整实现
下面是一个不依赖任何滤波库的完整分离实现,支持任意 σ 和半径,灰度图和彩色图都能跑:
import numpy as np def gaussian_blur_separable(img, sigma, radius=None): img = np.asarray(img) is_color = img.ndim == 3 if radius is None: radius = max(1, int(np.ceil(3.0 * sigma))) k = gaussian_kernel_1d(sigma, radius) if is_color: # 彩色图逐通道处理,先记下原通道数 h, w, c = img.shape work = img.astype(np.float64) else: h, w = img.shape c = 1 work = img.astype(np.float64)[:, :, None] # 边界镜像填充 padded = np.pad(work, ((radius, radius), (radius, radius), (0, 0)), mode='reflect') # 水平方向 tmp = np.zeros_like(work) for i, kv in enumerate(k): tmp += kv * padded[radius:radius + h, i:i + w, :] # 垂直方向(复用同一组系数) out = np.zeros_like(work) for i, kv in enumerate(k): out += kv * tmp[i:i + h, :, :] out = np.clip(out, 0, 255) if is_color: return out.astype(np.uint8) return out[:, :, 0].astype(np.uint8)这段代码里有几个我特意做的设计,值得说一下理由。
累加用 float64 而不是 uint8。卷积是加权求和,中间结果的动态范围会超过 8 位。如果你在 uint8 上直接做,NumPy 会静默回绕(255+1 变成 0),图像上会出现极其诡异的亮斑和黑斑。必须先把工作缓冲区提升到浮点。
水平卷完之后不要立刻转回 uint8。这个是最常见的性能与精度权衡错误。有些人为了省内存,横着卷完就astype(np.uint8),再竖着卷。这样一来中间结果就被量化了一次,两次舍入误差叠加,最终精度会掉,尤其在灰度过渡平缓的区域会出现"色带"。正确做法是全程保持浮点,最后一次性量化和裁剪。
np.clip放在最后。因为归一化过的核理论上不会让值超出原图的范围,但浮点误差加上边界处理可能有极小的越界,裁剪一次足够。如果每步都裁,反而慢。
分离后的中间结果形状不变,这一点让代码好写很多——不用管临时数组的大小变化。
这段代码在一张 1920×1080 的灰度图上,σ=2(半径 6,核长 13)跑下来,在我这台机器上大约 180 毫秒。改成朴素的二维卷积(13×13=169 次乘加),同样的图大概要 1.6 秒。差了将近 9 倍。如果 σ 加大到 5(半径 15,核长 31),二维版本直接飙到 4 秒以上,分离版本才 400 毫秒出头,差距拉到 10 倍以上。
3.4 定点整数化:嵌入式上没有浮点单元时的做法
在 PC 上浮点随便用,但到了 MCU、DSP、FPGA 这类场合,浮点乘法要么慢、要么根本没有。这时候要把核系数乘上一个 2 的整数次幂,转成整数,卷积过程全用整数乘加,最后右移。
以 σ≈1、半径 3 为例,浮点核算出来是:
[0.00443, 0.05400, 0.24204, 0.39905, 0.24204, 0.05400, 0.00443]乘以 256 后取整:
[1.13, 13.82, 61.96, 102.16, 61.96, 13.82, 1.13] → [1, 14, 62, 102, 62, 14, 1]有意思的是,这组整数加起来恰好等于 256。这不是巧合也不是必然,但真实算下来经常能凑得这么准。总和正好是 2 的 8 次方,意味着右移 8 位就是无误差的归一化,一次除法的开销都省了。如果凑不齐(比如总和是 254 或 258),就得手动微调最大的那个系数(把 102 改成 104 或者 100),代价是引入约 2% 的形状偏差,肉眼看不出来。
C 语言的实现:
#include <stdint.h> static const int16_t K[7] = {1, 14, 62, 102, 62, 14, 1}; /* sum = 256 */ static inline int clampi(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } /* 水平方向:输出 int32 未归一化中间结果,保留精度 */ static void blur_h(const uint8_t *src, int32_t *dst, int w, int h) { for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { int32_t sum = 0; for (int i = -3; i <= 3; ++i) { int xx = clampi(x + i, 0, w - 1); sum += (int32_t)K[i + 3] * src[y * w + xx]; } dst[y * w + x] = sum; /* 最大值 255*256 = 65280 */ } } } /* 垂直方向:输入 int32,输出 uint8,最后统一右移 16 位 */ static void blur_v(const int32_t *src, uint8_t *dst, int w, int h) { for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { int32_t sum = 0; for (int j = -3; j <= 3; ++j) { int yy = clampi(y + j, 0, h - 1); sum += K[j + 3] * src[yy * w + x]; } /* 最大值 65280*256 = 16711680,仍在 int32 范围内 */ dst[y * w + x] = (uint8_t)((sum + 32768) >> 16); } } }这里的关键是只在最后右移一次,中间结果保留全部精度。如果你在水平卷积结束就右移 8 位,中间结果被压回 8 位,第二次卷积时精度损失会叠加,实测在平滑渐变区域能看出 1~2 个灰阶的色带。
+32768那一下是四舍五入(0.5 对应的定点值),不加的话是截断,会系统性地让整张图偏暗 0.5 个灰阶。这个偏差很小,但在做多帧累加或者差分的时候会被放大。
边界处理用的是clampi钳制,等价于 replicate 策略。这在资源受限的环境里是最省事的做法,不用额外分配缓冲区。如果换成镜像,每个下标要多做几次判断,实测在 Cortex-M7 上会慢 8% 左右,看你能不能接受。
4. 参数与性能:文档里不写的那些取舍细节
4.1 核大小取偶数时,到底会发生什么
标准的高斯核大小一定是奇数:ksize = 2r + 1,因为这样才有唯一的中心像素。但总有各种原因让你想传一个偶数进去,比如继承来的配置项、算法里算出来的ksize = 2r。
OpenCV 的GaussianBlur对偶数ksize是允许的,不会报错,但行为需要搞清楚:当 ksize 为偶数时,核的中心落在两个像素之间,OpenCV 实际取的锚点是ksize/2(向下取整),也就是核的中心偏向左上。结果就是整张图会往左上偏移半个像素。
这个偏移在视觉上完全看不出来,但如果你的下游是:
- 亚像素定位:偏移会直接变成一个系统误差,测量同一个物体在图像中心和边缘的坐标,结果会差 0.5 像素。
- 多帧配准:如果两帧用了不同奇偶性的 ksize,它们之间会差整整 1 个像素,配准直接失败。
- 立体视觉:左右视图如果 ksize 不同,视差图上会出现整体性的偏差。
我个人的习惯是,在任何封装接口里都强制把 ksize 改成奇数:ksize = ksize | 1。这一个操作能省掉后面大量的排查时间。
4.2 σ 和 ksize 的自动推导:那个 0.3 和 0.8 是怎么来的
OpenCV 里有个很实用的设计:GaussianBlur允许你只传一个参数。传 ksize 不传 sigma,它会自己算;传 sigma 不传 ksize,它也会自己算。前者用的公式是:
sigma = 0.3 * ((ksize - 1) * 0.5 - 1) + 0.8这个公式看着莫名其妙,其实是经验拟合。反推一下:ksize=3 时 sigma ≈ 0.8,ksize=5 时 sigma ≈ 1.1,ksize=7 时 sigma ≈ 1.4,ksize=9 时 sigma ≈ 1.7。可以看到 ksize 每加 2,sigma 加 0.3。而半径 r 每加 1 对应 ksize 加 2,所以sigma ≈ 0.3r + 0.5 左右。这跟"半径取 2~3 倍 sigma"是自洽的,只是系数偏保守。
反过来,只传 sigma 不传 ksize 时,OpenCV 按ksize = round(sigma * (depth == CV_8U ? 3 : 4) * 2 + 1) | 1来算,8 位图用 3σ,更高精度用 4σ。这个设计挺贴心,因为浮点图上的量化误差小,截断可以更宽松一些。
我的建议是:这两个参数从来不要一起传。要么明确指定 σ 让库去推核大小,要么明确指定核大小让库去推 σ。同时传的话,如果两者不自洽(比如 ksize=3 却传 σ=10),OpenCV 会用 σ=10 生成一个只有 3 个元素的核,相当于把高斯曲线拦腰砍掉,只剩下中心那一小段,结果会退化成一个近似均值核,边界还会有一点振铃。这种情况不会报错,非常隐蔽。
4.3 彩色图逐通道处理,为什么可能翻车
绝大多数教程讲高斯滤波都是拿灰度图举例,到了彩色图就一句"分三个通道分别处理"。这个说法在 RGB 空间下是对的,但结果不一定是你想要的那个。
问题出在 RGB 的三个通道是强相关的,它们共同编码亮度信息,但同时也把色度信息混在里面。对三个通道分别做高斯模糊,等价于在 RGB 空间做各向同性的平滑,这会导致:一个纯红色的亮点被模糊后,扩散出来的区域不是"暗红渐变",而是"边缘发灰"。因为三个通道的扩散半径虽然一样,但它们的初始值不同(R=255, G=0, B=0),模糊后中心区域的灰度是 (255, 0, 0) 逐渐衰减,边缘处三个通道都接近 0 但比例还是 255:0:0,视觉上仍然是红。可是如果旁边有一个绿色像素,模糊会让它们互相污染,产生一圈灰褐色的过渡带。这在人像磨皮、风景后期里是明显能看出来的。
更合理的做法是转到 YCbCr 或者 LAB 这类亮度色度分离的空间,对 Y(或 L)通道做正常的高斯模糊,对 Cb/Cr 通道做更强一些的模糊(甚至直接大幅降采样再上采),因为人眼对色度的空间分辨率本来就远低于亮度。这样做出来的效果更符合视觉感知,而且可以省下大量计算——色度通道的模糊可以粗糙一点,反正看不出来。
如果只是做机器视觉的预处理(不是给人看的),分通道处理通常没问题,因为后续的特征提取本来就在灰度图上做。但要注意:如果先做模糊再转灰度,和先转灰度再做模糊,结果是不一样的。前者对每个通道独立卷积后加权求和,后者先加权求和再卷积。卷积是线性运算,理论上两者等价(因为加权求和也是线性的),但前提是各通道用了相同的核和相同的边界策略。如果通道间参数不一致,就会出偏差。我习惯的做法是先转灰度再模糊,省 2/3 的计算量,逻辑也更清晰。
4.4 大核大图上的性能账
σ 一大,核就长,计算量线性上涨。一张 4K 图(3840×2160)配 σ=10(半径 30,核长 61),分离实现的乘加次数是 3840×2160×61×2 ≈ 10 亿次。纯 Python 循环要跑几十秒,NumPy 向量化能压到 2 秒左右,但这在实际系统里还是太慢。
常用的加速手段按投入产出比排序:
- 用 SIMD 向量化:一维卷积本质上是滑动窗口乘加,天然适合 SIMD。一次处理 8 个或 16 个像素,在支持 AVX2 的机器上能拿到 4~6 倍加速。手写 intrinsics 门槛高,但很多编译器能自动向量化,只要把内层循环写成无分支、无下标钳制的形式。
- 多线程按行切分:分离卷积的两次 pass 都是按行(水平)/按列(垂直)独立的,可以无锁并行。水平方向按行切最简单,每行独立;垂直方向按列切,或者先把图转置再做一次水平卷积,也是常见技巧。8 核机器上接近 6 倍加速。
- 查表法:如果输入是 8 位图,核系数固定,那么一次乘加的结果只有 256 种可能,可以预先建一张 256 项的查找表,卷积变成纯查表加加法。这在老式 DSP 和 FPGA 上非常常用。
- 下采样 + 上采样:如果 σ 很大(比如大于 10),先降采样 2 倍、在低分辨率上做等效的小核模糊、再上采样回来,误差很小但速度快 4 倍以上。这在图像金字塔里是标准操作。
- 积分图近似:前面说过高斯不能用积分图,但可以用多个不同宽度的盒式滤波串并联来逼近高斯(三次盒式滤波,见下一章),每个盒式滤波用积分图是 O(1),总共 O(3)。在 σ 很大时这是最快的方案。
具体选哪个,取决于你的延迟预算和硬件。如果是离线批处理,多线程 + SIMD 就够;如果是实时的嵌入式,多半得走定点 + 查表 + 降采样这套组合拳。
5. 高斯滤波的几个"变体亲戚"
5.1 DoG 与尺度空间:高斯模糊最有价值的延伸
如果对同一个 σ 做高斯模糊,然后拿原图减去模糊图,你得到的是高频部分,也就是边缘和细节,这就是所谓的高斯差分(Difference of Gaussians,DoG)的简化版。更标准的 DoG 是用两个不同 σ 的模糊结果相减:
DoG(x, y) = G_σ1 * I(x, y) - G_σ2 * I(x, y) (σ1 < σ2)它近似于高斯拉普拉斯(LoG),也就是先高斯平滑再求拉普拉斯算子,这个组合能同时压噪声和检测边缘,是经典的边缘检测方法之一。它的一个重要性质是对边缘的响应在尺度空间里会出现极值——一条粗线和一条细线,在各自的 σ 上响应最强。这引出了尺度空间的概念:把图像用一系列递增的 σ 模糊,形成一组模糊程度不同的图,然后在每个尺度上找极值点,就能同时找到不同大小的特征。
这个思路在特征点检测里被用得很广。比如检测一张图里的斑点状结构,用不同 σ 的 LoG 响应,某个 σ 上响应最强的位置就是一个稳定特征点,而且这个 σ 还能反映出斑点的尺寸。工业检测里找圆点、找焊点,用这套方法比单纯用阈值分割稳得多,因为它的尺度自适应,不管工件离镜头远近都能检出。
代价是计算量,因为要算多个尺度。实际实现通常配合图像金字塔,每个 octave 模糊几次然后下采样一半,下一层继续,总计算量只比单个尺度多一倍左右。
5.2 用三次盒式滤波逼近高斯:大核场景的加速利器
前面说过盒式滤波可以用积分图做到 O(1)。单次盒式滤波的频响是 sinc 形状,旁瓣大,逼近高斯的效果很差,因此会产生块状伪影。但如果串联三次宽度不同的盒式滤波,根据中心极限定理,三个矩形分布的卷积会非常接近高斯形状,视觉上几乎看不出区别。
具体做法有现成的参数表,大致思路是:给定 σ,算出三个盒式滤波的半径 r1、r2、r3,然后依次做三次水平方向、三次垂直方向的盒式滤波。整个计算量跟 σ 无关,只跟像素数有关,在大 σ 的时候比分离高斯卷积快一个数量级以上。
这个技巧在很多实时渲染和移动端图像处理里被大量使用。它的代价是形状上不完美,边缘处的平滑曲线跟真高斯有细微差异,但人眼绝对分辨不出来。如果你的场景是给人看的预览图或者实时视频,这几乎是必选项;如果是做精密的亚像素测量,还是老老实实用真高斯。
5.3 横向对比:什么时候不该用高斯
| 需求 | 推荐滤波器 | 理由 |
|---|---|---|
| 去除传感器随机噪声 | 高斯 | 噪声近似高斯分布,匹配度最高 |
| 去除椒盐噪点 | 中值 | 高斯会把孤立点摊成一团,中值能整个剔除 |
| 保边平滑(磨皮、美颜) | 双边 / 导向 | 高斯无差别平滑,边缘一律糊掉 |
| 超大 σ 的快速模糊 | 三次盒式 / 降采样 | 计算量与 σ 解耦,速度优势明显 |
| 需要亚像素精度的测量 | 高斯(严格 3σ) | 形状精确、旋转对称,不引入方向偏差 |
| 去除周期性纹理 | 频域陷波 | 高斯是空域低通,对特定频率无能为力 |
一个常见的误用是拿高斯滤波去处理已经很模糊、但带明显块状伪影的图像(比如经过强力压缩的图)。这时候高斯只会把块状边界糊得更均匀,伪影还在,反而更难处理。这种场景更适合用保边类的去块算法。
6. 我在实际项目里攒下来的几条经验
第一条,是关于预处理顺序的。很多人习惯"先缩放再滤波",理由是缩放之后图小了,滤波更快。这个顺序在多数情况下是对的,因为缩放在某种意义上已经做了一次低通,缩放之后的噪声强度本身就被削弱了。但有一个例外:如果图像的噪声很重,缩放时的插值(尤其是双线性插值)会把噪声跟信号混在一起,产生一种"糊在结构上的脏点",这种脏点比原始噪声更难去除。我的做法是,噪声明显的时候先做一个 σ 很小的模糊(比如 σ=0.7)再缩放,成本几乎为零,但缩放结果干净很多。
第二条,是关于调参的直觉。我一般不从"我觉得需要多糊"入手,而是从"我下游要什么"倒推。比如边缘检测,我会先不加模糊跑一遍,看噪声导致的虚假边缘大概是什么尺度(几个像素宽),然后取 σ 约等于这个尺度的一半。比如细碎毛刺的宽度大概是 3 个像素,那 σ 取 1.5 左右,半径取 5。这样既能把毛刺压到阈值以下,又不会把真正的边缘糊掉。比盲试高效得多。
第三条,是永远要留一个"关闭滤波"的开关。我在每个带滤波的管线里都会留一个调试开关,能一键跳过滤波直接看结果。这个开关救过我好几次——有一次一个很奇怪的现象,边缘位置在两帧之间来回跳,我一开始以为是滤波参数的问题,调了半天没用。把滤波关掉之后问题依然存在,才顺着去查采集端的同步信号,发现是触发时序的问题。如果没有这个开关,我可能会在不相关的地方耗上一整天。
第四条,是不要迷信默认值。很多库的参数默认值是针对"一般图像"调出来的,但"一般"这个词在具体场景里没有意义。工业相机出来的图对比度极高、噪声结构也不一样;手机拍的图经过强烈的锐化和降噪,本身已经被处理过了。拿到一个新库,我会先做一个脉冲响应实验:在一张全黑的图上放一个白点,跑一次滤波,把输出打印出来看核的形状、看有没有偏移、看边界是怎么处理的。这十分钟的验证能省掉后面无数次"为什么结果跟预期不一样"。
第五条,关于数值精度。如果你后面要做的是差分、比值、或者多帧平均,前端的滤波精度会被显著放大。这种场景下,即使原图是 8 位,也建议滤波全程用 16 位或者浮点,最后再转回 8 位。多出来的那点内存和计算开销,比起后期追踪数值误差的痛苦,实在太划算了。
再补一个实用的小技巧:如果你只是想在代码里快速验证一个 σ 值的效果,不想写完整的卷积,可以用一个极简的替代品——连续做三次boxFilter或者cv2.blur,半径取round(sqrt(4σ²+1)/2)。这个近似的形状跟真高斯非常接近,速度还快,用来扫参数区间足够了。等确定了大概范围,再换成真高斯精调。我调参的时候基本都用这个方法,能省掉大量等待时间。