NI Vision:二值图像连通域标记算法

前面说到,要使用Labwindows + NI Vision(IMAQ Vision)这套商用开发框架来做数图课设。很明显,这套虚拟仪器开发平台由NI Instrument(美国国家仪器公司)开发的。大名鼎鼎的Labview软件就是这个公司开发的。相比较而言,Labwindows使用ANSI C开发,但应用场景是差不多的。

虚拟仪器(VI)的实质是利用计算机的IO接口完成信号的采集、测试与调试,凭借现代PC强大的计算能力来实现信号数据的运算、分析和处理,并使用显示器来模拟传统仪器的控制面板,从而完成各种测试功能的一种计算机仪器系统。

在做课程作业的时候,遇到了一个很有趣的应用。输入是米粒,比背景灰度要低,目的是输出米粒的颗数、面积、周长和孔数,这是工业上的一个很常见的应用。具体处理过程是二值化后使用低通滤波,并计算各种性质。


原图 - 二值化 - 低通滤波 - 连通域标记

界面设计如下,可以看到米粒的详细情况。

Labwindows界面

让我感兴趣的,是通过怎样的算法能够得到米粒的数量?之前曾经用过OpenCV中找最大外界矩形这个函数,但没有具体了解算法实现。直觉告诉我原理应该是相似的。

1.连通区域

可以看到,每一个米粒之间都是不连通的。这里就就提出了一个概念。连通区域(Connected Component)是指图像中相邻并有相同像素值的图像区域。连通区域分析(Connected Component Analysis,Connected Component Labeling)是指将图像中的各个连通区域找出并标记。

二值图像分析最重要的方法就是连通区域标记,它是所有二值图像分析的基础,它通过对二值图像中白色像素(目标)的标记,让每个单独的连通区域形成一个被标识的块,进一步的我们就可以获取这些块的轮廓、外接矩形、质心、不变矩等几何参数。如果要得到米粒的数量,那么通过连通区域分析(这里是二值图像的连通区域分析),就可以得到标记的数量,从而得到米粒的数量。

首先,连通区域的定义一般有两种,分为4邻接和8邻接,上图就知道了。
4邻接
8邻接

下面这幅图中,如果考虑4邻接,则有3个连通区域,8邻接则是2个。


连通区域数量与邻接定义有关

从连通区域的定义可以知道,一个连通区域是由具有相同像素值的相邻像素组成像素集合,因此,我们就可以通过这两个条件在图像中寻找连通区域,对于找到的每个连通区域,我们赋予其一个唯一的标识(Label),以区别其他连通区域。

连通区域分析的基本算法有两种:1)Two-Pass两便扫描法 2)Seed-Filling种子填充法 。

2.Two-Pass算法

两遍扫描法(Two-Pass),正如其名,指的就是通过扫描两遍图像,就可以将图像中存在的所有连通区域找出并标记。

(1)第一次扫描:
访问当前像素B(x,y),如果B(x,y) == 1:
a、如果B(x,y)的领域中标签值都为0,则赋予B(x,y)一个新的label:
label += 1, B(x,y) = label;
b、如果B(x,y)的领域中有像素值 > 1的像素Neighbors:
1)将Neighbors中的最小值赋予给B(x,y):
B(x,y) = min{Neighbors}
2)记录Neighbors中各个值(label)之间的相等关系,即这些值(label)同属同一个连通区域;
labelSet[i] = { label_m, .., label_n },labelSet[i]中的所有label都属于同一个连通区域(注:这里可以有多种实现方式,只要能够记录这些具有相等关系的label之间的关系即可)
(2)第二次扫描:
访问当前像素B(x,y),如果B(x,y) > 1:
a、找到与label = B(x,y)同属相等关系的一个最小label值,赋予给B(x,y);
完成扫描后,图像中具有相同label值的像素就组成了同一个连通区域。

说了一堆数学语言,其实用图很好理解


Two-Pass动态过程

3.Seed-Filling算法

种子填充方法来源于计算机图形学,常用于对某个图形进行填充。它基于区域生长算法。至于区域生长算法是什么,可以参照我的这篇文章

下面给出基于种子填充法的连通区域分析方法:
(1)扫描图像,直到当前像素点B(x,y) == 1:
a、将B(x,y)作为种子(像素位置),并赋予其一个label,然后将该种子相邻的所有前景像素都压入栈中;
b、弹出栈顶像素,赋予其相同的label,然后再将与该栈顶像素相邻的所有前景像素都压入栈中;
c、重复b步骤,直到栈为空;
此时,便找到了图像B中的一个连通区域,该区域内的像素值被标记为label;
(2)重复第(1)步,直到扫描结束;
扫描结束后,就可以得到图像B中所有的连通区域;

同样的,上动图


Seed-Filling动态过程

4.算法实现

NI Vision 中的算子定义如下

//统计标记数量
int imaqLabel(Image* dest, Image* source, int connectivity8, int* particleCount);

//得到粒子的具体信息
ParticleReport* imaqGetParticleInfo(Image* image, int connectivity8,
                                    ParticleInfoMode mode, int* reportCount);

OpenCV中也有相应的算子

//带统计信息
int cv::connectedComponents(
InputArray  image, // 输入二值图像,黑色背景
OutputArray  labels, // 输出的标记图像,背景index=0
int  connectivity = 8, // 连通域,默认是8连通
int  ltype = CV_32S // 输出的labels类型,默认是CV_32S
)  
//不带统计信息
int cv::connectedComponentsWithStats(
InputArray  image, // 输入二值图像,黑色背景
OutputArray     labels, // 输出的标记图像,背景index=0
OutputArray     stats, // 统计信息,包括每个组件的位置、宽、高与面积
OutputArray     centroids, // 每个组件的中心位置坐标cx, cy
int     connectivity, // 寻找连通组件算法的连通域,默认是8连通
int     ltype, // 输出的labels的Mat类型CV_32S
int     ccltype // 连通组件算法
)

这里参照其他博客实现一下Two-Pass算法,Seed-Filling算法就偷懒不搞了。

/********************************************************************
 * Created by 杨帮杰 on 10/21/18
 * Right to use this code in any way you want without
 * warranty, support or any guarantee of it working
 * E-mail: yangbangjie1998@qq.com
 * Association: SCAU 华南农业大学
 ********************************************************************/

#include <iostream>
#include <string>
#include <list>
#include <vector>
#include <map>
#include <stack>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>


using namespace std;
using namespace cv;


/**
 * @brief TwoPassLabel 对二值图像连通域进行标记
 * @param bwImg 输入必须为二值图像
 * @return labImg 已经标记的灰度图像,不同标记灰度值不同
 */
Mat TwoPassLabel(const Mat &bwImg)
{
    assert(bwImg.type() == CV_8UC1 );
    Mat labImg;
    bwImg.convertTo( labImg, CV_32SC1 );
    int rows = bwImg.rows - 1;
    int cols = bwImg.cols - 1;

    //二值图像像素值为0或1,为了不冲突,label从2开始
    int label = 2;
    vector<int> labelSet;
    labelSet.push_back(0);
    labelSet.push_back(1);

    //第一次扫描
    int *data_prev = (int*)labImg.data;
    int *data_cur = (int*)(labImg.data + labImg.step );
    int left, up;//指针指向的像素点的左方点和上方点

    for( int i = 1; i < rows; i++ )
    {
        data_cur++;
        data_prev++;
        for( int j = 1; j < cols; j++, data_cur++, data_prev++ )
        {
            if( *data_cur!=1 )//当前点不为1,扫描下一个点
                continue;
            left = *(data_cur-1);
            up = *data_prev;
            int neighborLabels[2];
            int cnt = 0;
            if( left > 1 )
                neighborLabels[cnt++] = left;
            if( up > 1)
                neighborLabels[cnt++] = up;
            if(!cnt)
            {
                labelSet.push_back(label);
                *data_cur = label;
                label++;
                continue;
            }
            //将当前点标记设为左点和上点的最小值
            int smallestLabel = neighborLabels[0];
            if(cnt==2 && neighborLabels[1] < smallestLabel )
                smallestLabel = neighborLabels[1];
            *data_cur = smallestLabel;
            //设置等价表,这里可能有点难理解
            //左点有可能比上点小,也有可能比上点大,两种情况都要考虑,例如
            //0 0 1 0 1 0       x x 2 x 3 x
            //1 1 1 1 1 1  ->   4 4 2 2 2 2
            //要将labelSet中3的位置设置为2
            for(int k = 0; k < cnt; k++ )
            {
                int neiLabel = neighborLabels[k];
                int oldSmallestLabel = labelSet[neiLabel];
                if(oldSmallestLabel > smallestLabel )
                {
                    labelSet[oldSmallestLabel] = smallestLabel;
                }
                else if(oldSmallestLabel<smallestLabel )
                    labelSet[smallestLabel] = oldSmallestLabel;
            }
        }
        data_cur++;
        data_prev++;
    }
    //上面一步中,有的labelSet的位置还未设为最小值,例如
    //0 0 1 0 1      x x 2 x 3
    //0 1 1 1 1  ->  x 4 2 2 2
    //1 1 1 0 1      5 4 2 x 2
    //上面这波操作中,把labelSet[4]设为2,但labelSet[5]仍为4
    //这里可以将labelSet[5]设为2
    for( size_t i = 2; i < labelSet.size(); i++ )
    {
        int curLabel = labelSet[i];
        int prelabel = labelSet[curLabel];
        while(prelabel != curLabel)
        {
            curLabel = prelabel;
            prelabel = labelSet[prelabel];
        }
        labelSet[i] = curLabel;
    }
    //第二次扫描,用labelSet进行更新,最后一列
    data_cur = (int*)labImg.data;
    for(int i = 0; i < rows; i++ )
    {
        for(int j = 0; j < cols; j++, data_cur++)
            *data_cur = labelSet[*data_cur];
        data_cur++;
    }

    return labImg;
}

/**
 * @brief LabelColor 对连通域分析得到的图像(矩阵)添加颜色
 * @param labelImg 二值图像连通域分析得到的图像(每个点32位)
 * @param num 标记的个数
 * @return coloerLabelImg 带有颜色的标记图像
 */
Mat LabelColor(const Mat& labelImg, int& num)
{
    num = 0;

    assert(labelImg.empty() == false);
    assert(labelImg.type() == CV_32SC1);

    map<int, Scalar> colors;

    int rows = labelImg.rows;
    int cols = labelImg.cols;

    Mat colorLabelImg = Mat::zeros(rows, cols, CV_8UC3);

    uchar r = 255 * (rand()/(1.0 + RAND_MAX));
    uchar g = 255 * (rand()/(1.0 + RAND_MAX));
    uchar b = 255 * (rand()/(1.0 + RAND_MAX));

    for (int i = 0; i < rows; i++)
    {
        const int* data_src = (int*)labelImg.ptr<int>(i);
        uchar* data_dst = colorLabelImg.ptr<uchar>(i);
        for (int j = 0; j < cols; j++)
        {
            int pixelValue = data_src[j];
            if (pixelValue > 1)
            {
                if (colors.count(pixelValue) == 0)
                {
                    colors[pixelValue] = Scalar(b,g,r);
                    r = 255 * (rand()/(1.0 + RAND_MAX));
                    g = 255 * (rand()/(1.0 + RAND_MAX));
                    b = 255 * (rand()/(1.0 + RAND_MAX));
                    num++;
                }

                Scalar color = colors[pixelValue];
                *data_dst++   = color[0];
                *data_dst++ = color[1];
                *data_dst++ = color[2];
            }
            else
            {
                data_dst++;
                data_dst++;
                data_dst++;
            }
        }
    }
    return colorLabelImg;
}

int main()
{

    Mat binImage = imread("/media/jacob/存储盘2/图像资料/3.3 实验参考图像/rice.bmp", 0);
    threshold(binImage, binImage, 107, 1, CV_THRESH_BINARY);
    Mat labelImg;
    int num; //标记的数量
    labelImg = TwoPassLabel(binImage);

    //彩色显示
    Mat colorLabelImg;
    colorLabelImg = LabelColor(labelImg, num);
    cout << "total number of label:" << num << endl;
    imshow("colorImg", colorLabelImg);

    //灰度显示
    Mat grayImg;
    labelImg.convertTo(grayImg, CV_8UC1);
    imshow("grayImg", grayImg);

    waitKey(0);
    return 0;
}

结果

Beautiful ~~

Reference:
OpenCV实现图像连通组件标记与分析
OpenCV-二值图像连通域分析
数字图像处理技术 ——邓继忠(我的任课老师)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,657评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,662评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,143评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,732评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,837评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,036评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,126评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,868评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,315评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,641评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,773评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,859评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,584评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,676评论 2 351

推荐阅读更多精彩内容