最近用opencv做项目,也在研究opencv源码,发现它中值滤波的性能达不到项目的要求,就想办法优化。本文先解析opencv的中值滤波源码,然后针对滤波核尺寸为7和9的情况进行优化。
opencv4.7.0实现中值滤波的主要函数有三个:
(1)medianBlur_SortNet
(2)medianBlur_8u_Om
(3)medianBlur_8u_O1
其中(1)是实现小核的中值滤波(3和5),用了sortnet的方法(下一章详解)直接对固定个数(9和25)的数求中值。因为个数确定了,就可以用最少的次数比较出中值(参考),性能也是最快的。(2)和(3)是尺寸无关的中值滤波,它始终维护一个直方图记录着相邻像素的分布,再利用直方图直接求出中值。(经测试,这种方法只适用核比较大情况,我用不上这么大的核,所以没有深入研究,感兴趣的可以看看相应的论文)
外层接口是medianBlur,代码如下:
void medianBlur(const Mat& src0, /*const*/ Mat& dst, int ksize)
{
CV_INSTRUMENT_REGION();
bool useSortNet = ksize == 3 || (ksize == 5
#if !(CV_SIMD)
&& ( src0.depth() > CV_8U || src0.channels() == 2 || src0.channels() > 4 )
#endif
);
Mat src;
if( useSortNet )
{
if( dst.data != src0.data )
src = src0;
else
src0.copyTo(src);
if( src.depth() == CV_8U )
medianBlur_SortNet<MinMax8u, MinMaxVec8u>( src, dst, ksize );
else if( src.depth() == CV_16U )
medianBlur_SortNet<MinMax16u, MinMaxVec16u>( src, dst, ksize );
else if( src.depth() == CV_16S )
medianBlur_SortNet<MinMax16s, MinMaxVec16s>( src, dst, ksize );
else if( src.depth() == CV_32F )
medianBlur_SortNet<MinMax32f, MinMaxVec32f>( src, dst, ksize );
else
CV_Error(CV_StsUnsupportedFormat, "");
return;
}
else
{
// TODO AVX guard (external call)
cv::copyMakeBorder( src0, src, 0, 0, ksize/2, ksize/2, BORDER_REPLICATE|BORDER_ISOLATED);
int cn = src0.channels();
CV_Assert( src.depth() == CV_8U && (cn == 1 || cn == 3 || cn == 4) );
double img_size_mp = (double)(src0.total())/(1 << 20);
if( ksize <= 3 + (img_size_mp < 1 ? 12 : img_size_mp < 4 ? 6 : 2)*
(CV_SIMD ? 1 : 3))
medianBlur_8u_Om( src, dst, ksize );
else
medianBlur_8u_O1( src, dst, ksize );
}
}
这里面逻辑还是比较简单的。首先判断是否使用sortnet,如果使用,则根据图像类型调用(1),如果不使用,再看滤波核尺寸是不是特别大(和图像尺寸,是否有SIMD有关),如果不算太大则用(2),否则用(3)。
这个名字来自论文“A Fast Median Filter using AltiVec”,主要针对固定个数的数求中值进行优化,证明了个数和最少比较次数之间的关系。比如三个数a,b,c,需要两两相比才能求出中值,相当于对三个数进行排序。而个数大于3的时候,就不需要对所有的数进行完全排序才能求出中值,可以减少很多无效的比较。最经典的是9个数求中值,分成三组,最大组只留最小值,直接就排除了两个数(最小组同理),这种求中值的方法是最快的方法。
当尺寸增大时,情况就比较复杂了,作者给出的公式如下:(奇数情况)
步骤1:将5x5的图像块按列排序
步骤2:将5x5的图像块按行排序
步骤3:按对角线排序,斜率依次为1,2
步骤4:位于5x5的图像块中间的值即为25个数的中值
下面详细分析一个这个过程。首先按列排序,就相当于将数据分成五组,每列是一组,每个组内先排好序,大的在下面(图中箭头的方向是递减的方向)。然后再按行排序,相当于五组数据中每组最小的数据放在一起比一次,第二小的数据放在一起比一次,以此类推。经过这次比较,很多不可能成为中值的数就被淘汰了(如最大组最大值、最小组最小值等)。可能成为中值的数已经在图中圈出来了,包括最小组最大的两个数,第二小的组最大的三个数,以此类推。这些数刚好分布在对角线上。最后是按对角线排序,k是作者定义的一个参数,大小从1到(N-1)/2,N为核尺寸。这里我理解k就是斜率,k=1时就是画若干条斜率为1的线,然后把线上的数排好序,k=2同理(如果把水平看成x轴的话斜率为1/2,反着看就行了)。排好所有的数后,最后留在中间的数就是所有数的中值。
算法原理是这样,但实现的时候还有个trick,就是只比较后面要用到的数,不需要将所有的数排序,可以减少无效比较。例如第二步的时候需要将最小组的五个数完全排好序吗?不用!只需要得到最大的两个就行了。可以简单算一下,将5个数完全排序需要比较10次,而只得到最大的两个数只需要比较7次(最大的数4次,第二大的数3次),其他组和第三步也同理。这样可以追求最极限的效率,没有一点多余比较。
核尺寸为5时opencv的核心代码如下:
vop(p1, p2); vop(p0, p1); vop(p1, p2); vop(p4, p5); vop(p3, p4);
vop(p4, p5); vop(p0, p3); vop(p2, p5); vop(p2, p3); vop(p1, p4);
vop(p1, p2); vop(p3, p4); vop(p7, p8); vop(p6, p7); vop(p7, p8);
vop(p10, p11); vop(p9, p10); vop(p10, p11); vop(p6, p9); vop(p8, p11);
vop(p8, p9); vop(p7, p10); vop(p7, p8); vop(p9, p10); vop(p0, p6);
vop(p4, p10); vop(p4, p6); vop(p2, p8); vop(p2, p4); vop(p6, p8);
vop(p1, p7); vop(p5, p11); vop(p5, p7); vop(p3, p9); vop(p3, p5);
vop(p7, p9); vop(p1, p2); vop(p3, p4); vop(p5, p6); vop(p7, p8);
vop(p9, p10); vop(p13, p14); vop(p12, p13); vop(p13, p14); vop(p16, p17);
vop(p15, p16); vop(p16, p17); vop(p12, p15); vop(p14, p17); vop(p14, p15);
vop(p13, p16); vop(p13, p14); vop(p15, p16); vop(p19, p20); vop(p18, p19);
vop(p19, p20); vop(p21, p22); vop(p23, p24); vop(p21, p23); vop(p22, p24);
vop(p22, p23); vop(p18, p21); vop(p20, p23); vop(p20, p21); vop(p19, p22);
vop(p22, p24); vop(p19, p20); vop(p21, p22); vop(p23, p24); vop(p12, p18);
vop(p16, p22); vop(p16, p18); vop(p14, p20); vop(p20, p24); vop(p14, p16);
vop(p18, p20); vop(p22, p24); vop(p13, p19); vop(p17, p23); vop(p17, p19);
vop(p15, p21); vop(p15, p17); vop(p19, p21); vop(p13, p14); vop(p15, p16);
vop(p17, p18); vop(p19, p20); vop(p21, p22); vop(p23, p24); vop(p0, p12);
vop(p8, p20); vop(p8, p12); vop(p4, p16); vop(p16, p24); vop(p12, p16);
vop(p2, p14); vop(p10, p22); vop(p10, p14); vop(p6, p18); vop(p6, p10);
vop(p10, p12); vop(p1, p13); vop(p9, p21); vop(p9, p13); vop(p5, p17);
vop(p13, p17); vop(p3, p15); vop(p11, p23); vop(p11, p15); vop(p7, p19);
vop(p7, p11); vop(p11, p13); vop(p11, p12);
共比较113次,怎么来的呢?我算了一下:
步骤1:五组全排序 5x10=50次
步骤2:最大最小组 7x2 次大次小组 9x2 中间组 9(相当于五个数求中间三个数,我验证了确实是9次) 共41次
步骤3:k=1 上下四个数求最值 3*2 中间五个数求中值 7,k=2 三个数求中值 3 共16次
共107次,可见opencv也没有优化到极致(当然它这么写也许是为了充分利用缓存,将一个数一直比比到不能比为止,可以充分利用CPU缓存,我没测试到底哪种方法快)。
注:这里的一次指对v0,v1进行一次操作op(v0,v1),操作完后小的存在v0里,大的存在v1里,和论文里的定义是有区别的。
有了上面的分析,我们再看滤波核尺寸为7和9的情况。(步骤1和步骤2比较简单,略过,主要看对角线排序)
这里为了方便说明将数字加了序号。k=1时一共5条线上的数需要排序,k=2时3条,k=3时1条,已经用相同颜色标记出来了。最后24号对应的数即为这49个数的中值。再看实际计算时如何减少无效比较。倒着推,k=3时只需要20,24,28这三个数,所以k=2求的时候只需要求第一条线(橙色)的最大值,第二条线(黄色)的中值和第三条线(蓝色)的最小值,k=1从上到下分别需要最大的1个值,最大的3个值,中间的3个值,最小的3个值,最小的1个值。
k=1时7条,k=2时5条,k=3时3条,k=4时1条。最后40号对应的数即为这81个数的中值(注意k=2时我一开始推错了,没有第一条和最后一条,因为所有直线应该位于35和45所在直线之间,外面的不考虑了)。减少无效比较的思路和上面一致,不再赘述。
opencv的实现有一个问题,就是只有SIMD的实现快,对于无法使用SIMD的部分(如对于有SIMD128的设备运行CV_8U图像时,剩下图像尺寸不足16的部分)比较慢。因为剩下几个像素是一个一个计算的,由于这个算法的特性,一个一个算是和一个128位的寄存器一起算效率是一样的,所以如果正好剩下15个像素的话还得单独计算15次,但其实只要把它们放一起,一次就解决了。具体做法是上次16个像素的最后一次像素和这15像素组成16个像素,再用一次SIMD就完事了。当然其中会有一部分像素(小于16)重复计算,不过这个没有性能损失。
我比较了一下图像宽度为1000和1028的情况,速度差很多(图像高度无所谓,我电脑最多支持SIMD256,CV_8U图像),只需要在全是vop那个循环里加下面一行:
if (j > size.width - nlanes * 2 - cn * 2 && j < size.width - nlanes - cn * 2) j = size.width - nlanes * 2 - cn * 2;
就可以把剩下的像素用SIMD一次做完了。
注:cn乘2对应核尺寸为5的情况,其他情况应该乘(N-1)/2
应用sortnet和SIMD对齐,和opencv对比核尺寸7和9的中值滤波。
参数:
CPU为i7-10750H,图像为1000*1000,CV_8U类型。
耗时(ms)
可见核尺寸为7时提升极大,为9时提升也不小,猜测为11时提升就不大了,速度接近medianBlur_8u_Om或medianBlur_8u_O1,所以我才重点优化了7和9。另外,opencv的代码是不支持并行的,如果需要多线程的话可以在行循环前加上 #pragma omp parallel for,并把dst改成局部变量。我电脑12线程,全部开启后1000*1000的单通道图像进行核尺寸为7的中值滤波只需要1.4ms,还是相当不错的。
如果有疑问或更好的优化方法,欢迎交流讨论。