OpenCV源码解析:FloodFill(漫水填充)函数

本文深入剖析了OpenCV中的FloodFill填充算法,包括4连通和8连通填充的概念,以及OpenCV如何通过逐行填充的方式高效实现填充操作。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

花木成畦手自栽 !

先来两张wikipedia上的图,分别为4连通填充和8连通填充的示意图。

FloodFill,一般的翻译是漫水填充,也就是把相邻的满足填充要求的颜色换成某个颜色的过程。填充方式一般是4连通,也就是扩展方向是上下左右4个方向,

当然你也可以采用8连通填充,把角上那4个也包括进来

直观地来说,可以这样填充,

stack堆栈为基础的递归填充

一个很简单的递归填充如下所示:

Flood-fill (点node, 目标色, 替换色)
{
1. 如果目标色不在可替换范围之内, return.
2. 把当node的颜色改成替换色.
3. 填充周边点
对左边点进行 Flood-fill(左边node,目标色, 替换色);
对右边点进行 Flood-fill(右边node,目标色, 替换色);
对上边点进行 Flood-fill(上边node,目标色, 替换色);
对下边点进行 Flood-fill(下边node,目标色, 替换色);
4. Return.
}

这个填充非常直观,很容易理解,也很容易实现,然而,当图像变大时,产生的大量递归,使得参数不断被入栈,时间上是一个巨大的消耗,而且,如此巨大的堆栈开销,在堆栈有限的嵌入式开发中,或当编译器无法提供这种堆栈时(如Java applets),这种算法是不可取的。

OpenCV采用的填充方式

OpenCV采用的是逐行填充,他抛弃了递归的堆栈形式,而是通过一个向量(也是一个栈,但不属于系统堆栈,即:std::vector<FFillSegment>* buffer)来实现填充操作。

其填充函数(v3.4.1)是floodFillGrad_CnIR,我们来分析一下其源代码。

函数说明:逐行进行漫水填充。

填充方向的说明:因为填充是一行一行填充的,所以dir方向只有UP和DOWN,其中理解这个data[][]数组设计,是理解填充的关键。

int data[][3] =
{
    {-dir, L - _8_connectivity, R + _8_connectivity}, // _8_connectivity=0 or 1
    {dir, L - _8_connectivity, PL - 1},
    {dir, PR + 1, R + _8_connectivity}
};

Data的解释如下图所示(在4连通填充时, _8_connectivity=0),假设已经填充的区域为绿色,当前要填充的行是有L,R标记所在的行,假设dir=UP,则-dir=DOWN,

首先填充黄色区域所在的行,填充完后会入栈L,R,PL,PR这四个参数(意思是Left, Right, Previous Left, Previous Right);
在后面该参数出栈后,
第一步,先填充data[0]={-dir, L - _8_connectivity, R + _8_connectivity}所指定的区域,
也就是黄色区域的下面那一行,即L,R所在的行的底下那行;
第二步,填充data[1]={dir, L - _8_connectivity, PL – 1}所指定的区域,
即填充图中的1,2,3,4,5这几格,此时Left = L - _8_connectivity, Right = PL – 1,
(在填充PL-PR这一行时,这是被挡住的左边部分);
第三步,填充data[2]={dir, PR + 1, R + _8_connectivity}所指定的区域,
即填充图中的6,7,8,9,10这几格,此时Left = PR + 1,Right = R + _8_connectivity,
(在填充PL,PR这一行时,这是被挡住的右边部分);

为了方便理解,我换个说法再解释一次,

首先,当从上一次的Left=PL, Right=PR进行到本行时,先填充图中的黄色部分,在填充过程中会逐步向左右尽可能的区域扩散,直到达到L,R所在的极限位置,然后入栈L,R,PL,PR这四个参数;
这4个参数出栈后的填充情况是这样的,
第一步:填充data参数数组的指定的第一行,即data[0][?],-dir表示向下,即黄色行行所在的下面那行;
第二步,填充后回过头来看有没有上一次被非填充区域(图中的黑色)挡住的部分,如图所示,标有数字1,2,3,4,5,6,7,8,9,10的部分正好是被挡住的,所以现在回过头来填充,
因此,Left=data[1][1] = L, Right=data[1][2]= PL-1,dir=UP,会对左边部分1,2,3,4,5进行填充;Left=data[2][1] = PR, Right=data[2][2]= R,dir=UP,会对右边6,7,8,9,10部分进行填充,

在方向上,要注意入栈时用的是

ICV_PUSH( YC + dir, j+1, i-1, L, R, -dir ); // j+1=>L,   i-1=>R,   L=>PL,   R=>PR

 

源码详解

先说一下OpenCV的范围比较函数Diff8uC3。
假设像素差允许范围是20,那么对RGB中任一个channel(假设为c,c=1,2,3),有interval[c]=40,
选取的像素a的值离像素b值的差要满足范围为<=20,也就是
abs(a[0][c] - b[0][c])<=20
这个算法等价于
-20 <= (a[0][c] - b[0][c]) <= 20
也等价于
0 <= (a[0][c] - b[0][c] + 20 ) <= interval[c] = 40
即程序中采用的代码(通过这种变换,把大小范围的判断变成一次性的判断),
(unsigned)(a[0][c] - b[0][c] + 20 ) <= interval[c]

struct Diff8uC3
{
    Diff8uC3(Vec3b _lo, Vec3b _up)
    {
        for( int k = 0; k < 3; k++ )
            lo[k] = _lo[k], interval[k] = _lo[k] + _up[k];
    }
    bool operator()(const Vec3b* a, const Vec3b* b) const
    {
        return (unsigned)(a[0][0] - b[0][0] + lo[0]) <= interval[0] &&
               (unsigned)(a[0][1] - b[0][1] + lo[1]) <= interval[1] &&
               (unsigned)(a[0][2] - b[0][2] + lo[2]) <= interval[2];
    }
    unsigned lo[3], interval[3];
};

下面来看主函数floodFillGrad_CnIR的源码

template<typename _Tp, typename _MTp, typename _WTp, class Diff>
static void
floodFillGrad_CnIR( Mat& image, Mat& msk,
                   Point seed, _Tp newVal, _MTp newMaskVal,
                   Diff diff, ConnectedComp* region, int flags,
                   std::vector<FFillSegment>* buffer )
{
   int step = (int)image.step, maskStep = (int)msk.step;
   // step 是线的大小(行的跨度),也就是一行占多少个byte,等于 width*channels 
   // maskStep是指mask一行占多少个byte,一般是width+2, 参考cv::floodFill的源码
   uchar* pImage = image.ptr();  // image的起始位置
   _Tp* img = (_Tp*)(pImage + step*seed.y); // img是种子所在的行的起始位置
   uchar* pMask = msk.ptr() + maskStep + sizeof(_MTp); 

这里要注意,pMask是指MASK的对应的image的起始地址,注意MASK在各个方向都要比image大一个字节,所以这里代码中有+maskStep+sizeof(_MTp) ,sizeof(_MTp)= sizeof(uchar) = 1;

接下来所有的解释,都在源码中,有些没有使用注释符,因为注释之后颜色太淡了,不利于观看。

mask是对应img的,表示seed在MASK中的行

    _MTp* mask = (_MTp*)(pMask + maskStep*seed.y); 
    int i, L, R;
    int area = 0;
    int XMin, XMax, YMin = seed.y, YMax = seed.y;

下面这句判断填充时是8连通还是4连通(4连通_8_connectivity=0; 8连通_8_connectivity=1)
8连通填充:x边上8个点,符合色彩条件的都填充, 
4连通填充:x边上4个点,符合色彩条件的才填充,

int _8_connectivity = (flags & 255) == 8;

下面,如果设置FLOODFILL_FIXED_RANGE为这个标识符的话,就会考虑当前像素与种子像素之间的差,因为种子像素是不变的,所以这个差值的范围是不变的(大多数图像处理中,使用的就是这种情况);否则,就考虑当前像素与其相邻像素的差,取决于不同的位置,这个范围是浮动的。

    int fillImage = (flags & FLOODFILL_MASK_ONLY) == 0;

接下来,下面这句,如果设置FLOODFILL_MASK_ONLY标识符的话,函数不会去填充改变原始图像, 而是去填充掩模图像(MASK)。也就是说,当(flags & FLOODFILL_MASK_ONLY) == 0为true时,填充原图像。

    int fillImage = (flags & FLOODFILL_MASK_ONLY) == 0;


    FFillSegment* buffer_end = &buffer->front() + buffer->size(), 
                 *head = &buffer->front(), 
                 *tail = &buffer->front();

    L = R = seed.x;
    if( mask[L] )
        return;

    mask[L] = newMaskVal; 
    _Tp val0 = img[L];

下面两个while的作用:如果种子点所在行如果满足色彩要求,就把相应的mask位设置为newMaskVal,比如0xFF

    if( fixedRange )
  {
        while( !mask[R + 1] && diff( img + (R+1), &val0 ))
            mask[++R] = newMaskVal;

        while( !mask[L - 1] && diff( img + (L-1), &val0 ))
            mask[--L] = newMaskVal;
    } 
    else 
    { 
        while( !mask[R + 1] && diff( img + (R+1), img + R ))
            mask[++R] = newMaskVal;

        while( !mask[L - 1] && diff( img + (L-1), img + L ))
            mask[--L] = newMaskVal;
    }

    XMax = R; // 本行已经填充的位置的最大值(最右边)
    XMin = L; // 本行已经填充的位置的最小值(最左边)

将开始状态入栈(也就是向量buffer),这里,prevL=R+1, prevR=R, dir = UP
每ICV_PUSH一次,就执行一次tail++,参考ICV_PUSH宏定义

    ICV_PUSH( seed.y, L, R, R + 1, R, UP ); // 入栈(1)

再看后面的while循环,这里head == tail的条件,只有在完全填充完之后,才能得到满足。

循环中各个参数的含义:YC = y current, PL = previous Left, PR = previous Right, dir = direction (这个direction只有UP=1和DOWN=-1,因为是逐行填充)。

举例说明:上一次填充的行是YC=10,入栈了YC+dir=11; 那么本次出栈的是YC=11, YC+dir=12,在后面的for循环中,是先处理第12行,得到要填充的区域,然后入栈第12行,处理完毕后再填充11行,然后再回到while看有没有完全填充完11反方向的行,有的话就填充完,然后继续循环。

值得注意的是:
ICV_PUSH( YC + dir, j+1, i-1, L, R, -dir ); 其作用是入栈下一个填充行;

data参数中,第一个参数为-dir,为什么?注意看int data[][3]的定义,假设上一行到本行10的方向是UP(dir=1,所以也就是YC + dir=11),入栈时方向是-dir;出栈(1)时,得到的dir就是-1,dir=data[0][0]=-(-1),负负得正,这样在data[0][]参数首先得到处理时,依然是最有可能需要得到填充的区域。

    while( head != tail )
    {
	   
        int k, YC, PL, PR, dir; 
       
        ICV_POP( YC, L, R, PL, PR, dir ); // 出栈(1)当前填充行  

        int data[][3] =
        {
            {-dir, L - _8_connectivity, R + _8_connectivity},
            {dir, L - _8_connectivity, PL - 1},
            {dir, PR + 1, R + _8_connectivity}
        };

        //该行可填充的像素的宽度
        unsigned length = (unsigned)(R-L); 

        if( region )
        {
            //填充的面积(总像素个数)
            area += (int)length + 1; 

       //下面,是重新设定本次填充的范围(上下左右的极限值)
            if( XMax < R ) XMax = R;
            if( XMin > L ) XMin = L;
            if( YMax < YC ) YMax = YC;
            if( YMin > YC ) YMin = YC;
        }

        for( k = 0; k < 3; k++ )
        {
            dir = data[k][0];
            img = (_Tp*)(pImage + (YC + dir) * step); // 更新当前行的地址
            _Tp* img1 = (_Tp*)(pImage + YC * step);   // 上一次(刚处理完)的行的地址
            mask = (_MTp*)(pMask + (YC + dir) * maskStep); // 对应的当前MASK地址
            int left = data[k][1]; 
            int right = data[k][2]; 
 情况(I) FLOODFILL_FIXED_RANGE,当前像素与种子像素之间的差在设定范围内
            if( fixedRange )
            //如果该行有满足色采条件的像素点,则把该行的MASK全部设置为newMaskVal
            //并将相应的参数入栈,
                for( i = left; i <= right; i++ )
                {
                    if( !mask[i] && diff( img + i, &val0 ))
                    {
                        int j = i;
                        mask[i] = newMaskVal;
                        while( !mask[--j] && diff( img + j, &val0 ))
                            mask[j] = newMaskVal;

                        while( !mask[++i] && diff( img + i, &val0 ))
                            mask[i] = newMaskVal;
                        
                        ICV_PUSH( YC + dir, j+1, i-1, L, R, -dir ); // 入栈(2)
                    }
                }

情况(II) !FLOODFILL_FIXED_RANGE,当前像素与相信像素之间的差在设定范围内
且不为8连通填充
            else if( !_8_connectivity )
                for( i = left; i <= right; i++ )
                {
                    if( !mask[i] && diff( img + i, img1 + i ))
                    {
                        int j = i;
                        mask[i] = newMaskVal;
                        while( !mask[--j] && diff( img + j, img + (j+1) ))
                            mask[j] = newMaskVal;

                        while( !mask[++i] &&
                              (diff( img + i, img + (i-1) ) ||
                               (diff( img + i, img1 + i) && i <= R)))
                            mask[i] = newMaskVal;

                        ICV_PUSH( YC + dir, j+1, i-1, L, R, -dir );
                    }
                }

情况(III) 8 连通填充的情况
            else
                for( i = left; i <= right; i++ )
                {
                    int idx;
                    _Tp val;

                    if( !mask[i] &&
                       (((val = img[i],
                          (unsigned)(idx = i-L-1) <= length) &&
                         diff( &val, img1 + (i-1))) ||
                        ((unsigned)(++idx) <= length &&
                         diff( &val, img1 + i )) ||
                        ((unsigned)(++idx) <= length &&
                         diff( &val, img1 + (i+1) ))))
                    {
                        int j = i;
                        mask[i] = newMaskVal;
                        while( !mask[--j] && diff( img + j, img + (j+1) ))
                            mask[j] = newMaskVal;

                        while( !mask[++i] &&
                              ((val = img[i],
                                diff( &val, img + (i-1) )) ||
                               (((unsigned)(idx = i-L-1) <= length &&
                                 diff( &val, img1 + (i-1) ))) ||
                               ((unsigned)(++idx) <= length &&
                                diff( &val, img1 + i )) ||
                               ((unsigned)(++idx) <= length &&
                                diff( &val, img1 + (i+1) ))))
                            mask[i] = newMaskVal;

                        ICV_PUSH( YC + dir, j+1, i-1, L, R, -dir );
                    }
                }
        }
	    
        // 这里才是真正的填充当前行 
        img = (_Tp*)(pImage + YC * step); 
        if( fillImage ) 
            for( i = L; i <= R; i++ ) 
                img[i] = newVal; 
        /*else if( region )
         for( i = L; i <= R; i++ )
         sum += img[i];*/
    }

    if( region )
    {
        region->pt = seed;
        region->label = saturate_cast<int>(newMaskVal);
        region->area = area;
        region->rect.x = XMin;
        region->rect.y = YMin;
        region->rect.width = XMax - XMin + 1;
        region->rect.height = YMax - YMin + 1;
    }
}

}

总结:OpenCV的填充原理其实很简单,就是一行一行找,然后把还没填充的区域入栈。用向量栈替代递归时所需要的系统堆栈。理解了这些东西之后,就不难理解每一行代码的具体含义。

 

参考资料

【1】 https://en.wikipedia.org/wiki/Flood_fill

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值