1.滤波、核和卷积基础知识
滤波,简单来说就是按照某种规则,用周围的数据来重新计算当前数据,从而改变信号或图像的特征。
在图像处理中,图像可以看成一张二维的数字表格,每个格子是一个像素。滤波就是拿一个小窗口在图像上滑动,对窗口里的像素做某种运算,算出一个新值,代替窗口中心的像素,这个小窗口就是滤波器(也称核)。这种用核在图像上滑动、对局部像素进行加权求和的操作,在数学上称为卷积(Convolution)。
严格来说,核不翻转、直接加权求和的操作在数学上称为相关(Correlation);卷积则需要先把核旋转。OpenCV实现的其实是相关运算。由于本文讨论的盒状核与高斯核都中心对称,旋转后不变,因此文中沿用了业界习惯的叫法“卷积”。日常图像处理中,我们所说的“做一次滤波”,通常就是指对图像进行一次卷积运算。
图1. 5x5盒状核与5x5高斯核,“锚点”由粗体表示
图1中展示了图像处理中常用的两种5x5核,用于对图像进行平滑或模糊处理。以盒状核为例,矩阵中有一个点的值加粗且带有下划线,这个点就称作“核的锚点(anchor)”。锚点用于定义核与源图像的对齐关系:在计算输出图像的某个像素时,将该像素的位置与锚点对齐,再用核覆盖源图像中对应的邻域像素。注意,锚点不一定在核的中心;图中它位于中心,只是为了展示一种常见情形。
图1中矩阵旁边的1/25,代表在计算时,将矩阵内的数字与图像对应像素相乘后求和,最后再乘以1/25。实际上这就是5x5的均值滤波核,它会对周围25个像素求平均值,起到平滑图像、去除噪声的作用,但会让图像边缘变得模糊。
再来说明一下图中的高斯核,与盒状核相同的是,它也是5x5的矩阵;不同的是,核里的数字呈现“中间大、四周小”的对称分布。旁边的分数1/273中,273是矩阵里所有25个权重数字相加的总和,用来对加权求和的结果进行归一化(即除以273求平均),从而保证图像整体亮度不变,矩阵里的数字(如1、4、7、26、41)是赋予像素的实际权重,而并非实际的像素值。这一点与盒状核原理一样(其他核同理),只不过盒状核赋予所有像素的权重都是平等的(全是1),而高斯核则根据距离中心的远近赋予了不同的权重。
2.滤波核实际工作流程
1.非边界值滤波核处理流程
在上一章中,我们了解了滤波的基础原理,这一章中,我们将学习均值滤波的完整流程,并对照OpenCV中均值滤波的实现原理展开。
图2. 3x3均值滤波核的工作流程
图2中展示了3x3均值滤波核的工作流程,src为源图像,dst为输出图像,src中边缘加粗的方框代表3x3滤波核,核中心灰色的元素是锚点,当滤波核在源图像上滑动到某一位置时,会将核内元素的像素值进行加权求和,然后进行归一化处理(即除以核内元素的总数,3x3核即除以9,计算的结果四舍五入),最终计算的结果会存入dst中与锚点对应的元素内,一般不会对原图造成任何影响。
但是如果把同一个Mat同时当src和dst传入(blur(img, img, ...)),两个名字会指向同一块内存,语义上就变成原地写入,某些实现下结果不正确,所以尽量不要把src和dst传同一个Mat,虽然在源码中确实有路径专门支持这么做,但仍建议src与dst使用不同的Mat。
2.边界值滤波核处理流程
然而,当滤波核滑动到图像边缘时,核的一部分会超出图像边界,出现“无像素可取”的情况,例如,计算图像第一行第一列的像素时,核的左上角会悬空。为了解决这个问题,我们需要在滤波前对图像边界进行边界值处理。常见的处理方式有以下几种:
| 边框类型 | 含义 | 效果 |
|---|---|---|
| BORDER_CONSTANT | 用指定常量(默认 0)填充 | 边缘易出现黑边 |
| BORDER_WRAP | 复制对边的像素 | 上下/左右强行缝合,极不自然 |
| BORDER_REPLICATE | 复制最边缘的像素 | 无黑边,但边缘有"拉伸拖尾"感 |
| BORDER_REFLECT | 镜像反射,包含边界元素 | 最边缘像素被重复一次,过渡不够平滑 |
| BORDER_REFLECT_101 | 镜像反射,不包含边界元素 | 对称轴在边缘外侧,过渡平滑且不引入新颜色 |
| BORDER_DEFAULT | BORDER_REFLECT_101的别名 | 默认值 |
图3. 3x3滤波核在镜像反射(BORDER_REFLECT_101)处理后的矩阵中进行计算
在边界处理中,镜像反射既是OpenCV默认的边界处理类型,也是实际使用中最常见的类型,图3展示了3x3滤波核在进行镜像反射处理后的矩阵中进行计算的过程。边界需要向外扩展的宽度由核的大小决定:当锚点位于核中心时,扩展宽度等于核的半径,图3以3x3核为例,半径是1,因此在原矩阵外围扩展了1圈;若换成5x5核,就需要向外扩展2圈,7x7核则扩展3圈,依此类推。
在表格中的几种边界处理类型中,BORDER_REFLECT_101对于普通图像的处理一般是最平滑自然的,因此,OpenCV将其作为默认的边界类型,所以在实际编写代码时,如果不手动指定borderType(边界类型处理),OpenCV底层自动调用的就是BORDER_REFLECT_101。
图4.通过不同的边界处理方法处理图像的结果(Original作为对比的源图像)
3.浅谈opencv中实现的均值滤波
1.分离式滤波
在之前的章节中,我们学习了以3x3滤波核为例的均值滤波工作流程,可以发现,如果直接使用二维核(9个元素),每个像素都需要进行九次加法运算。当核变大(如31x31)时,计算量会呈平方级爆炸式增长。而分离式滤波就是解决这个问题的核心技巧。
由于均值滤波核是一个二维的全1矩阵,在数学上,它可以被分解为两个一维向量的乘积。以3x3均值核为例:
这意味着原本的二维卷积可以拆分为两步一维卷积。
1.行滤波:先对图像的每一行做一维均值滤波(如图5所示,1x3的核在水平方向滑动,计算水平方向3个像素的平均值)。
2.列滤波:对行滤波的结果,每一列做一维均值滤波(如图6所示,3x1的核在垂直方向滑动,将行滤波的结果再做垂直方向的平均,得到最终输出)。
顺序问题:先做行滤波再做列滤波,或者先列后行,最终结果理论上是一样的,OpenCV底层通常先做行再做列,因为这样对内存缓存的利用更友好。
边界问题:分离式滤波同样需要进行边界处理,按照BORDER_REFLECT_101等规则对边界进行填充。
图5.行滤波工作流程
图6.列滤波工作流程
为什么分离式滤波这么快?因为对比直接二维滤波,计算复杂度从O(N²)降到了O(N)。我们具体对比一下计算量。
当N=3时,直接二维滤波每个像素需要N²次运算,也就是3x3=9次加法运算,而分离式滤波需要N+N=2N次计算,也就是3+3=6次加法运算,差距尚不明显;
当N=11时,直接二维滤波需要进行11x11=121次加法运算,而分离式滤波只需要11+11=22次加法运算;
当N=31时,直接二维滤波需要进行31x31=961次加法运算,而分离式滤波只需要31+31=62次加法运算。
也就是说,核越大,二者差距越大,分离式滤波对于性能的提升越明显,这就是OpenCV均值滤波底层默认采用分离式滤波的原因。但是OpenCV实际上还会再做一次优化,下一节我们再详细说明。
2.滑动窗口
分离式滤波把二维核拆成了两个一维核,但一维滤波内部仍然是朴素求和——每滑动一格,都要把窗口内的N个数重新计算一遍。
图7.核宽N=3的滑块计算过程
图8.核宽N=5的滑块计算过程
观察图7和图8中滑动的过程可以发现:相邻两次的窗口大部分是重叠的。图中红色元素为离开的元素,绿色元素为新进入的元素,蓝色元素则是两次窗口共有、无需重新计算的元素。
窗口每移动一格,只有一端有一个像素离开、另一端有一个像素进入,中间的N-1个元素不动。因此不必重新求和,只需在上一次的结果上做一次修正:
新窗口和 = 旧窗口和 - 离开的像素 + 进入的像素。
除第一次计算时需要进行N-1次加法,剩下的每一步只需要进行两次计算。
于是对N=3、11、31这些尺寸,每输出一个像素所需的运算次数都是4次,行滤波中“加进一个新像素、减去一个离开的像素”是两次运算,列滤波中“加进一行、减去一行”同样为两次,合计为4次,不会随核增大而增加运算次数。
但是有一个例外,在N=5时,详细原因在下一章中结合源代码来分析。
4.opencv中均值滤波的源代码浅析
在上面的章节中,我们讨论了滤波的实现原理,在这一章中我们将结合OpenCV中滤波的源代码来分析实现过程。
1.均值滤波的实现
blur 是 OpenCV 对外提供的均值滤波接口,而它的实现只有一行:
void blur(InputArray src, OutputArray dst, Size ksize, Point anchor, int borderType) { CV_INSTRUMENT_REGION(); boxFilter( src, dst, -1, ksize, anchor, true, borderType ); }均值滤波本身不是一个独立的算法,它只是“归一化的方框滤波”。
前面讲的盒状核、分离式滤波、滑动窗口,落到代码里全都发生在boxFilter里面。所以接下来我们只需读懂boxFilter,就可以理解均值滤波。
2.方框滤波的实现
2.1.整体实现
方框滤波整体结构如下:
void boxFilter(InputArray _src, OutputArray _dst, int ddepth, Size ksize, Point anchor, bool normalize, int borderType) { CV_INSTRUMENT_REGION(); CV_Assert(!_src.empty()); CV_OCL_RUN(_dst.isUMat() && (borderType == BORDER_REPLICATE || borderType == BORDER_CONSTANT || borderType == BORDER_REFLECT || borderType == BORDER_REFLECT_101), ocl_boxFilter3x3_8UC1(_src, _dst, ddepth, ksize, anchor, borderType, normalize)) CV_OCL_RUN(_dst.isUMat(), ocl_boxFilter(_src, _dst, ddepth, ksize, anchor, borderType, normalize)) Mat src = _src.getMat(); int stype = src.type(), sdepth = CV_MAT_DEPTH(stype), cn = CV_MAT_CN(stype); if( ddepth < 0 ) ddepth = sdepth; _dst.create( src.size(), CV_MAKETYPE(ddepth, cn) ); Mat dst = _dst.getMat(); if( borderType != BORDER_CONSTANT && normalize && (borderType & BORDER_ISOLATED) != 0 ) { if( src.rows == 1 ) ksize.height = 1; if( src.cols == 1 ) ksize.width = 1; } Point ofs; Size wsz(src.cols, src.rows); if(!(borderType&BORDER_ISOLATED)) src.locateROI( wsz, ofs ); CALL_HAL(boxFilter, cv_hal_boxFilter, src.ptr(), src.step, dst.ptr(), dst.step, src.cols, src.rows, sdepth, ddepth, cn, ofs.x, ofs.y, wsz.width - src.cols - ofs.x, wsz.height - src.rows - ofs.y, ksize.width, ksize.height, anchor.x, anchor.y, normalize, borderType&~BORDER_ISOLATED); //CV_IPP_RUN_FAST(ipp_boxfilter(src, dst, ksize, anchor, normalize, borderType)); borderType = (borderType&~BORDER_ISOLATED); if(sdepth >= CV_32F && src.type() == dst.type() && (ksize.height <= 5 && ksize.width <= 5)) { CV_CPU_DISPATCH(blockSum, (src, dst, ksize, anchor, wsz, ofs, normalize, borderType), CV_CPU_DISPATCH_MODES_ALL); return; } Ptr<FilterEngine> f = createBoxFilter( src.type(), dst.type(), ksize, anchor, normalize, borderType ); f->apply( src, dst, wsz, ofs ); }boxFilter内部按优先级依次判断,选择一条执行路径:
①尝试OpenCL(GPU加速)
②尝试HAL(厂商提供的加速库)
③浮点数据且核不超过5x5 → blockSum
④以上均不适用 → FilterEngine(通用实现)
2.2.具体实现
2.2.1.函数签名
void boxFilter(InputArray _src, OutputArray _dst, int ddepth, Size ksize, Point anchor, bool normalize, int borderType)各参数在前文均已出现:
ksize:图1中的核尺寸。
anchor:图1中的锚点,传(-1,-1)表示取核中心。
normalize:图1中的1/25和1/273。
borderType:第二章的边界处理类型。
有一处命名约定需要说明:_src与_dst前面的下划线表示这是尚未确定具体类型的通用容器——它既可以接收Mat,也可以接受UMat(GPU上的矩阵)。因此函数内的第一步是将其转换为确定的类型:
Mat src = _src.getMat(); // 转换为 CPU 上的 Mat2.2.2.前置检查
CV_INSTRUMENT_REGION(); CV_Assert(!_src.empty());CV_INSTRUMENT_REGION()是性能分析工具的插桩标记,与算法本身无关。
CV_Assert则是一项输入检查:源图不能为空。第二章讨论的是“核部分超出图像”,由边界填充解决;而整幅图像都没有像素属于另一种情形,此时直接断言失败并报错。
2.2.3.尝试OpenCL
CV_OCL_RUN(_dst.isUMat() && (borderType == BORDER_REPLICATE || borderType == BORDER_CONSTANT || borderType == BORDER_REFLECT || borderType == BORDER_REFLECT_101), ocl_boxFilter3x3_8UC1(_src, _dst, ddepth, ksize, anchor, borderType, normalize)) CV_OCL_RUN(_dst.isUMat(), ocl_boxFilter(_src, _dst, ddepth, ksize, anchor, borderType, normalize))CV_OCL_RUN(条件,表达式)的含义是:若条件成立则执行该表达式,若执行成功便立即从boxFilter返回。因此这两行合起来的效果是“能交给GPU就交给GPU,交不出去就继续往下走”。
两行的分工不同:
第一行调用的是专用内核,它只服务于3x3核、8位单通道等一小组特定情形,并且要求边界类型为四种常见类型之一。该内核自身还会检查设备型号与数据对齐方式,条件不满足时会返回false,于是控制权返回到boxFilter,继续执行下一行。
第二行调用的是通用内核,适用范围更宽,但不享受专用内核的优化。
此外要注意两行的第一个条件_dst.isUMat(),它表示目标位于GPU,这是走OpenCL路径的前提。若目标是普通的Mat,这两行都会被跳过。
2.2.4.退回CPU
若GPU路径未走通,则退回CPU执行:
Mat src = _src.getMat(); int stype = src.type(), sdepth = CV_MAT_DEPTH(stype), cn = CV_MAT_CN(stype); if( ddepth < 0 ) ddepth = sdepth; _dst.create( src.size(), CV_MAKETYPE(ddepth, cn) ); Mat dst = _dst.getMat(); if( borderType != BORDER_CONSTANT && normalize && (borderType & BORDER_ISOLATED) != 0 ) { if( src.rows == 1 ) ksize.height = 1; if( src.cols == 1 ) ksize.width = 1; }前五行的作用如下:
拆包:把通用容器_src转换为确定的Mat,_dst同样如此。
拆分类型:CV_MAT_DEPTH取出数据深度(如CV_8U),CV_MAT_CN取出通道数。后续计算要分别用到这两项信息,因此先把合成类型拆开。
分配输出:_dst.create(...)只在该矩阵的尺寸或类型与要求不符时才真正重新分配内存,这是OpenCV的惰性分配约定。
ddepth < 0表示与源图保持相同深度,1.1节中blur传入的-1正是此意。
后面的if处理的是图像退化为单行或单列的情形。它只在三个条件同时成立时触发:边界类型不是常量填充、需要归一化、且处于隔离模式。此时核在另一维度上找不到足够的真实像素,而归一化又要除以核面积(即核内元素总数);若不作修正,除数就会包含并未真正参与运算的像素,导致结果整体偏暗。把该维度的核尺寸压成1,等价于只除以真实参与运算的像素数。
2.2.5.区域位置与隔离标志
Point ofs; Size wsz(src.cols, src.rows); if(!(borderType&BORDER_ISOLATED)) src.locateROI( wsz, ofs );src未必是一幅完整的图像,它可能只是从大图中截取的一块区域(例如img(Rect(...)))。locateROI的作用是查询该区域在整幅图中的位置:
ofs:该区域左上角在整幅图中的坐标。
wsz:整幅图的尺寸。
滤波需要这些信息的原因是:如果区域之外存在真实像素,就没有必要人为构造边界像素。例如从大图中截取一块区域做滤波时,它的边界之外在大图中确实存在真实像素,直接使用它们显然比镜像复制的结果更准确。
BORDER_ISOLATED标志正是用来区分这两种情形的:标志已设置时,该区域被视为一幅独立完整的图像,边界像素按borderType构造;标志未设置时,则承认它是大图的一部分,优先使用大图中真实存在的像素。这也解释了代码中那个if的作用——隔离模式下不需要查询位置,因为区域之外的像素本来就不该被使用。
2.2.6.尝试HAL
CALL_HAL(boxFilter, cv_hal_boxFilter, src.ptr(), src.step, dst.ptr(), dst.step, src.cols, src.rows, sdepth, ddepth, cn, ofs.x, ofs.y, wsz.width - src.cols - ofs.x, wsz.height - src.rows - ofs.y, ksize.width, ksize.height, anchor.x, anchor.y, normalize, borderType&~BORDER_ISOLATED);HAL是硬件加速层的缩写。CALL_HAL的含义是:若当前平台提供了cv_hal_boxFilter这一实现则调用它,调用成功即直接从boxFilter返回。
参数虽多,但可以分组来看:
源:src.ptr()、src.step。
目标:dst.ptr()、dst.step。
尺寸与类型:src.cols、src.rows、sdepth、ddepth、cn。
区域位置:ofs.x、ofs.y,以及右、下两侧的距离。
滤波参数:ksize、anchor、normalize、borderType。
在普通计算机上通常没有该实现,因此这一行一般会被跳过。
紧接着还有一行:
borderType = (borderType&~BORDER_ISOLATED);BORDER_ISOLATED不是一种边界填充方式,而是一个标志位,其含义已在2.2.5说明。到这里它的作用已经完成——区域的位置信息已由ofs与wsz显式表达,后续调用不再需要这个标志,所以先把它去掉,以免干扰下面的判断。
2.2.7.blockSum分支
if(sdepth >= CV_32F && src.type() == dst.type() && (ksize.height <= 5 && ksize.width <= 5)) { CV_CPU_DISPATCH(blockSum, (src, dst, ksize, anchor, wsz, ofs, normalize, borderType), CV_CPU_DISPATCH_MODES_ALL); return; }触发条件是三项:浮点数据、输入与输出类型相同、核不超过5x5。满足则提前返回。
单独为它开一条路径,是因为2.2.8中的FilterEngine是一套通用框架,要管理行缓冲、列缓冲、边界、锚点与缩放系数,框架本身的开销对小核而言并不划算。blockSum采用更直接的做法,并省去了一次中间结果的内存往返,因此在小核情形下更快。核增大之后这一优势不再明显,所以限制在5x5以内。
2.2.8.通用实现
Ptr<FilterEngine> f = createBoxFilter( src.type(), dst.type(), ksize, anchor, normalize, borderType ); f->apply( src, dst, wsz, ofs );这是最后一条路径,也是任何情况下都可用的实现。
createBoxFilter负责装配:它先构造一个行滤波器与一个列滤波器,再把两者组装成一个滤波引擎。这正是第三章所讲的分离式滤波落地的地方——第三章从数学上说明了二维核可以拆成两个一维核,而这里就是把两个一维核对应的滤波器实际创建出来。
f->apply(src,dst,wsz,ofs)随后执行滤波,wsz与ofs用于告知引擎这块区域在整幅图中的位置。
至此boxFilter的工作结束。它完成的事情是:判断采用哪条执行路径、确定中间结果使用的数据类型、装配行滤波器与列滤波器。至于两个滤波器具体如何计算,boxFilter并未涉及,那部分在box_filter.simd.hpp里。
3.box_filter.simd.hpp中行滤波和列滤波的实现
3.1.均值滤波会用到哪些代码
2.2.8中提到,createBoxFilter会构造一个行滤波器与一个列滤波器。这两个滤波器就定义在box_filter.simd.hpp中,分别是RowSum与ColumnSum两个结构体。均值滤波真正用到的就是这两个。
文件中还有另外两个结构体:BlockSum服务于2.2.7的blockSum分支,做法与上面两者不同;SqrRowSum服务于sqrBoxFilter,与均值滤波无关。
在展开这两个结构体之前,先明确两个文件的关系。它们并非并列,而是同一份代码的两个层次:
box_filter.dispatch.cpp决定采用哪条执行路径,并装配各组成部分;box_filter.simd.hpp负责具体的计算逻辑。
box_filter.simd.hpp并不是一个普通的头文件,它会被反复包含多次,每次针对不同的CPU指令集,从而使同一套算法生成多份实现:
opt_BASELINE 中的 RowSum、ColumnSum…… opt_SSE4_2 中的 RowSum、ColumnSum…… opt_AVX2 中的 RowSum、ColumnSum…… opt_AVX512 中的 RowSum、ColumnSum……而2.2.7中出现的CV_CPU_DISPATCH(blockSum,...),其作用正是在运行时选择一份当前CPU支持的实现来调用。
概括而言:算法只编写一次,机器码生成多份,运行时择优选用。因此同一份OpenCV代码在不同计算机上执行的,实际上是不同的机器码。
3.2.行滤波RowSum
RowSum的完整实现如下:
template<typename T, typename ST> struct RowSum : public BaseRowFilter { RowSum( int _ksize, int _anchor ) : BaseRowFilter() { ksize = _ksize; anchor = _anchor; } virtual void operator()(const uchar* src, uchar* dst, int width, int cn) CV_OVERRIDE { CV_INSTRUMENT_REGION(); const T* S = (const T*)src; ST* D = (ST*)dst; int i = 0, k, ksz_cn = ksize*cn; width = (width - 1)*cn; if( ksize == 3 ) { for( i = 0; i < width + cn; i++ ) { D[i] = (ST)S[i] + (ST)S[i+cn] + (ST)S[i+cn*2]; } } else if( ksize == 5 ) { for( i = 0; i < width + cn; i++ ) { D[i] = (ST)S[i] + (ST)S[i+cn] + (ST)S[i+cn*2] + (ST)S[i + cn*3] + (ST)S[i + cn*4]; } } else if( cn == 1 ) { ST s = 0; for( i = 0; i < ksz_cn; i++ ) s += (ST)S[i]; D[0] = s; for( i = 0; i < width; i++ ) { s += (ST)S[i + ksz_cn] - (ST)S[i]; D[i+1] = s; } } else if( cn == 3 ) { ST s0 = 0, s1 = 0, s2 = 0; for( i = 0; i < ksz_cn; i += 3 ) { s0 += (ST)S[i]; s1 += (ST)S[i+1]; s2 += (ST)S[i+2]; } D[0] = s0; D[1] = s1; D[2] = s2; for( i = 0; i < width; i += 3 ) { s0 += (ST)S[i + ksz_cn] - (ST)S[i]; s1 += (ST)S[i + ksz_cn + 1] - (ST)S[i + 1]; s2 += (ST)S[i + ksz_cn + 2] - (ST)S[i + 2]; D[i+3] = s0; D[i+4] = s1; D[i+5] = s2; } } else if( cn == 4 ) { ST s0 = 0, s1 = 0, s2 = 0, s3 = 0; for( i = 0; i < ksz_cn; i += 4 ) { s0 += (ST)S[i]; s1 += (ST)S[i+1]; s2 += (ST)S[i+2]; s3 += (ST)S[i+3]; } D[0] = s0; D[1] = s1; D[2] = s2; D[3] = s3; for( i = 0; i < width; i += 4 ) { s0 += (ST)S[i + ksz_cn] - (ST)S[i]; s1 += (ST)S[i + ksz_cn + 1] - (ST)S[i + 1]; s2 += (ST)S[i + ksz_cn + 2] - (ST)S[i + 2]; s3 += (ST)S[i + ksz_cn + 3] - (ST)S[i + 3]; D[i+4] = s0; D[i+5] = s1; D[i+6] = s2; D[i+7] = s3; } } else for( k = 0; k < cn; k++, S++, D++ ) { ST s = 0; for( i = 0; i < ksz_cn; i += cn ) s += (ST)S[i]; D[0] = s; for( i = 0; i < width; i += cn ) { s += (ST)S[i + ksz_cn] - (ST)S[i]; D[i+cn] = s; } } } };逐段说明:
构造函数只保存核尺寸ksize与锚点anchor。这里有一处细节值得注意:anchor被保存下来,但在后面的operator()中从头到尾没有出现。原因是滤波器的职责只有计算,对准关系由FilterEngine在调用之前安排妥当。
operator()接收一行数据,输出这一行的行和。开头先把宽度换算成元素个数:width = (width - 1) * cn,同时算出ksz_cn = ksize * cn,也就是一个窗口跨越的元素个数。
接下来的if-else按核尺寸与通道数区分:
ksize == 3:把窗口内三个元素直接相加,每个输出两次加法。
ksize == 5:把窗口内五个元素直接相加,每个输出四次加法。
cn == 1(其余核尺寸):这是滑动写法。先逐个累加出第一个窗口的和,此后每前进一格,只做一次减法与一次加法——减去离开的S[i],加上进入的S[i + ksz_cn]。
cn == 3、cn == 4以及最后的通用分支:写法与cn == 1相同,只是为每个通道各维护一个累加器,从而避免通道的交错存储带来的重复寻址。
3.3.N=5的例外
第三章末尾留下了一个问题:为什么N=5时为例外?
从上面对RowSum的说明就可以看出答案:N=3与N=5走的是展开相加,并未滑动。因此各尺寸的运算量如下:
| N | 行滤波 | 列滤波 | 合计 |
| 3 | 展开,2次 | 滑动,2次 | 4 |
| 5 | 展开,4次 | 滑动,2次 | 6 |
| 11 | 滑动,2次 | 滑动,2次 | 4 |
| 31 | 滑动,2次 | 滑动,2次 | 4 |
N=5时行滤波多出两次加法,合计即为6次。
那么为何不始终采用滑动?因为滑动存在一项代价:每一步都必须等待上一步完成。
s += (ST)S[i + ksz_cn] - (ST)S[i]; // 本句必须等待上一句的 s 计算完毕 D[i+1] = s;这构成了串行依赖,无法并行执行。
而展开相加不存在这一问题:
D[i] = (ST)S[i] + (ST)S[i+cn] + (ST)S[i+cn*2];相邻的两个输出之间互不依赖,处理器可以同时执行多个。因此:
核较小时,展开相加更优——运算次数本就不多,并行执行的收益更大。
核较大时,滑动更优——滑动恒为两次运算,而展开的运算次数随N线性增长。
两者的交叉点约在N=6~7附近。因此OpenCV只为3和5编写了展开版本,这两个恰好是最常用的核尺寸,其余尺寸统一采用滑动。
需要指出的是,源码中并不存在“N≥7则采用滑动”这类判断,只有ksize == 3与ksize == 5两条硬编码分支。因此更准确的表述是:OpenCV只特化了两个最常用的尺寸,其余尺寸统一使用滑动。
3.4.列滤波ColumnSum
ColumnSum的通用版本如下(此外还有若干针对具体数据类型的特化版本,见本章节末尾说明):
template<typename ST, typename T> struct ColumnSum : public BaseColumnFilter { ColumnSum( int _ksize, int _anchor, double _scale ) : BaseColumnFilter() { ksize = _ksize; anchor = _anchor; scale = _scale; sumCount = 0; } virtual void reset() CV_OVERRIDE { sumCount = 0; } virtual void operator()(const uchar** src, uchar* dst, int dststep, int count, int width) CV_OVERRIDE { CV_INSTRUMENT_REGION(); int i; ST* SUM; bool haveScale = scale != 1; double _scale = scale; if( width != (int)sum.size() ) { sum.resize(width); sumCount = 0; } SUM = &sum[0]; if( sumCount == 0 ) { memset((void*)SUM, 0, width*sizeof(ST)); for( ; sumCount < ksize - 1; sumCount++, src++ ) { const ST* Sp = (const ST*)src[0]; for( i = 0; i < width; i++ ) SUM[i] += Sp[i]; } } else { CV_Assert( sumCount == ksize-1 ); src += ksize-1; } for( ; count--; src++ ) { const ST* Sp = (const ST*)src[0]; const ST* Sm = (const ST*)src[1-ksize]; T* D = (T*)dst; if( haveScale ) { for( i = 0; i <= width - 2; i += 2 ) { ST s0 = SUM[i] + Sp[i], s1 = SUM[i+1] + Sp[i+1]; D[i] = saturate_cast<T>(s0*_scale); D[i+1] = saturate_cast<T>(s1*_scale); s0 -= Sm[i]; s1 -= Sm[i+1]; SUM[i] = s0; SUM[i+1] = s1; } for( ; i < width; i++ ) { ST s0 = SUM[i] + Sp[i]; D[i] = saturate_cast<T>(s0*_scale); SUM[i] = s0 - Sm[i]; } } else { for( i = 0; i <= width - 2; i += 2 ) { ST s0 = SUM[i] + Sp[i], s1 = SUM[i+1] + Sp[i+1]; D[i] = saturate_cast<T>(s0); D[i+1] = saturate_cast<T>(s1); s0 -= Sm[i]; s1 -= Sm[i+1]; SUM[i] = s0; SUM[i+1] = s1; } for( ; i < width; i++ ) { ST s0 = SUM[i] + Sp[i]; D[i] = saturate_cast<T>(s0); SUM[i] = s0 - Sm[i]; } } dst += dststep; } } double scale; int sumCount; std::vector<ST> sum; };逐段说明:
构造函数除了核尺寸与锚点,还接收一个缩放系数scale。第三章讲过,归一化系数只放在列滤波这一步,就是通过这个参数传进来的。同样,anchor在这里也没有被使用。
成员变量中有一个vector<ST>sum,它是整段代码的关键:累加器不是一个标量,而是一个数组,长度等于图像的宽度。这与RowSum只用一个标量s形成对照。
operator()的参数与前两个不同:
src:不是一整行,而是若干个指针(const uchar**)。
dststep:输出每次前进的字节数。
count:本次要输出多少行。
width:每行多少列。
函数体分三段。
第一段是初始化:
if( sumCount == 0 ) { memset((void*)SUM, 0, width*sizeof(ST)); for( ; sumCount < ksize - 1; sumCount++, src++ ) { const ST* Sp = (const ST*)src[0]; for( i = 0; i < width; i++ ) SUM[i] += Sp[i]; } }它先把ksize-1行累加进数组。之所以是ksize-1行,是因为接下来每读入一行就会输出一行,那么第一行输出所对应的窗口,正好由这ksize-1行加上新读入的一行凑齐。
第二段是主体循环,外层推进一行:
for( ; count--; src++ ) { const ST* Sp = (const ST*)src[0]; // 新进入的一整行 const ST* Sm = (const ST*)src[1-ksize]; // 离开的一整行 ... }Sp指向新进入窗口的那一行,Sm指向离开窗口的那一行。第三段是内层循环,遍历这一行的所有列:
ST s0 = SUM[i] + Sp[i]; // 加上新进入的一行 D[i] = saturate_cast<T>(s0*_scale); // 输出(需要时乘以缩放系数) SUM[i] = s0 - Sm[i]; // 减去离开的一行因此列滤波的推进单位是一整行,而不是单个像素;内层虽然逐列进行,但它们在同一个外层轮次中全部完成。
为什么列滤波的累加器是数组?行滤波的窗口是横向的N列,滑动方向沿着一行向右,每前进一格只影响一个位置,因此一个标量s就足以记住“当前窗口的和”。
列滤波的窗口是纵向的N行、同一列。要对某一列做滑动,就需要记住“这一列当前的和”;而图像有W列,每一列都要各自记住,于是累加器必然是W个数,也就是一个数组。
上面给出的是通用版本。文件中还有一批针对具体数据类型的特化版本,例如ColumnSum<int,uchar>、ColumnSum<ushort,uchar>、ColumnSum<int,short>等,具体使用哪一个由2.2.8中createBoxFilter选定的中间类型决定。
这些特化版本沿用的是同一套算法,只是在此基础上增加了一些优化,例如用整数乘法加移位代替浮点除法、以及对循环做SIMD向量化。这些内容超出本章范围,此处不再展开。
5.简单实现均值滤波
下面来根据原理来简单实现一下opencv中的均值滤波。
#include <opencv2/opencv.hpp> #include <iostream> #include <windows.h> using namespace cv; using namespace std; // 边界镜像:把越界下标折回图内 // 实现 BORDER_REFLECT_101 —— 反射时不重复最外圈像素,所以 -1→1 而非 0,size→size-2 而非 size-1 static inline int mirrorIndex(int index, int size) { if (index < 0) return -index; if (index >= size) return 2 * size - index - 2; return index; } // 分离式 + 滑块的均值滤波 // 朴素:每像素 ksize² 次加法 → 分离式:两趟一维,2×ksize 次 → 滑块:恒定 4 次,与核大小无关 Mat myblur(const Mat& src, int ksize) { const int r = ksize / 2, h = src.rows, w = src.cols, count = ksize * ksize; // ===================== 第一步:行滤波(横向滑动) ===================== // 中间图存"和"不存"平均值",用 32 位整数是因为和会溢出 uchar: // ksize=31 时单通道和最大 255×31=7905,远超 255 Mat rowSum(src.size(), CV_32SC3); for (int row = 0; row < h; row++) { // 累加器必须放在"行循环内、列循环外":值才能跨列传递,滑动才成立 int b = 0, g = 0, rr = 0; for (int col = 0; col < w; col++) { if (col == 0) { // 第一列没有"上一格",没有可减的像素,只能把整个窗口加一遍 for (int k = -r; k <= r; k++) { Vec3b p = src.at<Vec3b>(row, mirrorIndex(col + k, w)); b += p[0]; g += p[1]; rr += p[2]; } } else { // 窗口右移一格:左端出去一个、右端进来一个 // 输出 col-1 时窗口是 [col-1-r, col-1+r],输出 col 时是 [col-r, col+r] // 所以离开的是 col-r-1,进入的是 col+r Vec3b leave = src.at<Vec3b>(row, mirrorIndex(col - r - 1, w)); Vec3b enter = src.at<Vec3b>(row, mirrorIndex(col + r, w)); b += enter[0] - leave[0]; g += enter[1] - leave[1]; rr += enter[2] - leave[2]; } // 只存和,除法留到最后做一次,整条链路上只取整一次 rowSum.at<Vec3i>(row, col) = Vec3i(b, g, rr); } } // ===================== 第二步:列滤波(纵向滑动) ===================== Mat dst = Mat::zeros(src.size(), CV_8UC3); // 循环顺序反过来了:col 在外、row 在内 // 因为要沿纵向滑动,必须把一列走完累加器才能跨行传递 for (int col = 0; col < w; col++) { int b = 0, g = 0, rr = 0; for (int row = 0; row < h; row++) { if (row == 0) { for (int k = -r; k <= r; k++) { Vec3i p = rowSum.at<Vec3i>(mirrorIndex(row + k, h), col); b += p[0]; g += p[1]; rr += p[2]; } } else { Vec3i leave = rowSum.at<Vec3i>(mirrorIndex(row - r - 1, h), col); Vec3i enter = rowSum.at<Vec3i>(mirrorIndex(row + r, h), col); b += enter[0] - leave[0]; g += enter[1] - leave[1]; rr += enter[2] - leave[2]; } // 此处 b/g/rr 是 ksize² 个像素的总和,除以 count 才是平均值 // 整数除法是向下取整;cv::blur 是四舍五入,所以两者最多差 1 dst.at<Vec3b>(row, col) = Vec3b(b / count, g / count, rr / count); } } return dst; } // ================================ 对比与输出 ================================ int main() { SetConsoleOutputCP(CP_UTF8); Mat img = imread("C:/Users/Administrator/Desktop/sucai/milkfrog2.jpg"); // imread 失败不抛异常,只返回空 Mat,必须显式检查 if (img.empty()) { cout << "img not found" << endl; return -1; } if (img.channels() == 1) cvtColor(img, img, COLOR_GRAY2BGR); // 灰度图按 Vec3b 读会崩溃 int ksize = 11; int64 t = getTickCount(); Mat mine = myblur(img, ksize); double ms1 = (getTickCount() - t) * 1000.0 / getTickFrequency(); // 周期差 ÷ 每秒周期数 = 秒,×1000 得毫秒;写 1000.0 防止整数除法截断小数 Mat ref; t = getTickCount(); blur(img, ref, Size(ksize, ksize)); // 第 4 个参数省略 = BORDER_DEFAULT = BORDER_REFLECT_101,与自己的实现一致才有可比性 double ms2 = (getTickCount() - t) * 1000.0 / getTickFrequency(); cout << "myblur : " << ms1 << " ms" << endl; cout << "cv::blur : " << ms2 << " ms" << endl; cout << "ratio : " << ms1 / ms2 << " x" << endl; // 差异图:差值最大只有 1,乘 20 才看得出来 // 全黑=完全一致,均匀暗灰=全是取整误差,局部发亮=那里真的算错了 Mat diff; absdiff(mine, ref, diff); diff.convertTo(diff, -1, 20); // 必须 clone(),因为 putText 会直接修改传入的图像 Mat a = img.clone(), b = mine.clone(), c = ref.clone(), d = diff.clone(); putText(a, "src", Point(10, 35), FONT_HERSHEY_SIMPLEX, 1.0, Scalar(0, 0, 255), 2); putText(b, "myblur", Point(10, 35), FONT_HERSHEY_SIMPLEX, 1.0, Scalar(0, 0, 255), 2); putText(c, "cv::blur", Point(10, 35), FONT_HERSHEY_SIMPLEX, 1.0, Scalar(0, 0, 255), 2); putText(d, "diff x20", Point(10, 35), FONT_HERSHEY_SIMPLEX, 1.0, Scalar(0, 0, 255), 2); Mat t1, t2, canvas; hconcat(a, b, t1); hconcat(c, d, t2); vconcat(t1, t2, canvas); // 按宽、按高各算一个缩放比,取小的那个 —— 只按宽度算的话 2×2 拼图会竖向超出屏幕 double sw = 0.6 * GetSystemMetrics(SM_CXSCREEN); double sh = 0.6 * GetSystemMetrics(SM_CYSCREEN); double s = (sw / canvas.cols < sh / canvas.rows) ? sw / canvas.cols : sh / canvas.rows; if (s < 1.0) resize(canvas, canvas, Size(), s, s, INTER_AREA); namedWindow("comparison", WINDOW_NORMAL); imshow("comparison", canvas); imwrite("C:/Users/Administrator/Desktop/sucai/myblur.png", canvas); waitKey(0); return 0; }ksize=11时结果如下:
图9.对比结果图
速度对比如下:
图10.Debug下速度对比
图11.Release下速度对比
可以看出还是比官方慢,且两种配置下的差距并不相同:Debug下官方快了约15倍,Release下官方快了约5倍,Debug下的代码是未经优化过的,而OpenCV中的代码是已经优化过的,两者不在同一起跑线上,所以真正需要探讨的是Release下的对比,cv::blur使用SIMD指令,一条指令同时处理8到16个像素;列滤波按整行推进,全程顺序访问内存;中间图在核面积不超过256时只占16位;边界也只对首尾若干像素单独处理。而两者的运算次数其实完全相同——都是每像素4次,与核尺寸无关。换言之,加法次数已经追平,剩下的差距在实现细节。
6.小结
本文围绕均值滤波这一个算法,走完了从原理到实现的全过程。均值滤波看起来是最简单的滤波——一个全1的核,一次求和,一次除法。但就是这样一个小算法,背后涉及可分离性、滑动窗口、边界策略、缓存布局与指令集分发。图像处理中的大多数算法,也是这样一层一层叠加起来的。
本文为学习OpenCV时的心得,如有遗漏或错误,欢迎在评论区补充或指正。
参考书籍:
《数字图像处理(第三版)》
《学习OpenCV3(中文版)》
《C++Primer Plus(第6版)中文版》