一、图像邻域
图像中两个像素相邻的定义方式分别为4-邻域和8-邻域。4-邻域中相邻的两个像素之间的街区距离为1,8-邻域中相邻的两个像素之间的棋盘距离为1。
对于4-邻域而言,像素点P0(x,y)的相邻像素点为P1(x,y-1)、P2(x,y+1)、P3(x+1,y)和P4(x-1,y);对于8-邻域而言,像素点P0(x,y)的相邻像素点为P1(x-1,y-1)、P2(x-1,y)、P3(x-1,y+1)、P4(x,y-1)、P5(x,y+1)、P6(x+1,y-1)、P7(x+1,y)和P8(x+1,y+1)。
根据像素邻域的定义不同,得到的连通域也不一样。
街区距离:两个像素点x方向和y方向的距离之和;
棋盘距离:两个像素点x方向距离和y方向距离的最大值。
二、两遍扫描法(以4-邻域为例)
1、第一次遍历
从上到下,从左到右遍历图像,为每一个非零元素赋予一个数字标签(0像素的数字标签默认为数字0)。从遍历顺序来看访问的当前像素点的正上方像素点和正左方的像素点均已被赋予了数字标签;当前像素点为非零像素时,有以下四种情况。
1):当前像素点的上方邻域和左侧邻域的像素点均为零,则赋予当前像素点一个新的数字标签,同时将该标签值记录下来;
2):当前像素点的上方邻域像素点为零,左侧邻域像素点不为零,则当前像素点的数字标签与左侧像素点的数字标签一致;
3):当前像素点的上方邻域像素点不为零,左侧邻域像素点为零,则当前像素点的数字标签与上方邻域像素点的数字标签一致;
4):当前像素点的上方邻域和左侧邻域的像素点均不为零,则当前像素点的数字标签为左侧邻域和上方邻域像素的数字标签的最小值,
2、第二次遍历
第一次遍历完成后同一个连通域可能被赋予了一个或多个标签,第二次遍历的目的就是将属于同一连通域的不同数字标签合并,最后实现同一个连通域中的所有像素点的标签一致。因此在第二次遍历开始之前,需要对存储数字标签的数组进行一个union-find(并查集)处理,使得同一连通域中的不同标签都指向同一标签。
三、算法代码
int find(vector<int> &nums, int i)
{
if (i < 0 || i >= nums.size())
return 0;
while (i>0&&nums[i] != i )
{
//路径压缩
nums[i] = nums[nums[i]];
i = nums[i];
}
return i;
}
//int myTwoPass(Mat &src, Mat &dst,Mat &c)//观测labels和dst的变化时使用
int myTwoPass(Mat &src, Mat &dst)
{
if (src.empty() || src.type() != CV_8UC1)
return 0;
int row = src.rows;
int col = src.cols;
dst.release();
dst = Mat::zeros(row, col, CV_16U);
//标注数组,数组下标表示标注,值表示该标注所在的区域号。每个标注初始所在的区域为其自身
vector<int> labels;
labels.push_back(0);//第一个元素表示背景像素为0,标注也为0
//对待测连通域的输入图像进行一个向上和向左的边界外推一个像素单位的操作,外推像素的像素值为0
Mat extrapolationImg;//外推图像
copyMakeBorder(src, extrapolationImg, 1, 0, 1, 0, 0);
int upPixel, leftPixel;//当前像素点的上邻域像素和左邻域像素
int curPixel;//当前扫描的像素点的值
int label=1;//初始标记数值为1
//第一次扫描first scan
for (int i = 1;i <= row;i++)
{
for (int j = 1;j <= col;j++)
{
upPixel =(int)extrapolationImg.at<uchar>(i - 1, j);
leftPixel = (int)extrapolationImg.at<uchar>(i, j - 1);
curPixel = (int)extrapolationImg.at<uchar>(i, j);
if (curPixel != 0)
{
if (upPixel == 0 && leftPixel == 0)
{
labels.push_back(label);
dst.at<ushort>(i - 1,j - 1) = label++;
}
else if (upPixel != 0 && leftPixel == 0)
{
dst.at<ushort>(i - 1, j - 1) = dst.at<ushort>(i - 2, j - 1);
}
else if (upPixel == 0 && leftPixel != 0)
{
dst.at<ushort>(i - 1, j - 1) = dst.at<ushort>(i - 1, j - 2);
}
else if (upPixel != 0 && leftPixel != 0)
{
int left_label = (int)dst.at<ushort>(i - 1, j - 2);
int up_label = (int)dst.at<ushort>(i - 2, j - 1);
dst.at<ushort>(i - 1, j - 1) = min(left_label, up_label);
//将同一连通域中的标注链接起来
if (labels[left_label] > labels[up_label])
{
labels[left_label] = labels[up_label];
}
else
{
labels[up_label] = labels[left_label];
}
}
}
}
}
//dst.copyTo(c);
/*
for (int i = 1;i < labels.size();i++)
{
cout << "第一次扫描后标注" << i << "所属区域号为:" << labels[i] << endl;
}
*/
for (int i = labels.size() - 1;i >=1;i--)
{
find(labels, i);//路径压缩
}
/*
for (int i = 1;i < labels.size();i++)
{
cout << "标注路径压缩后标注 " << i << "所属区域号为:" << labels[i] << endl;
}
*/
/*经过上面操作所得到的标注数组中的数值不是连续的,需进行变换;
遍历标注数组:
以标志的数值作为map的key,对应的vaule从1开始计数;
使用map的count()函数来判断当前标注所表示的区域是否已被记录,
如count(labels[i])==0,就表示该标注所表示的区域未被记录,
count(labels[i])==1,就表示该标注所表示的区域已被记录,
map中的值就是其所对应的key所在的区域号,
最后map中最大的值就表示连通域的数目;
*/
int CDomainNumber = 0;//已被确定的连通域数目
map<int, int> transform_labels;
for (int i = 1;i < labels.size();i++)
{
if (transform_labels.count(labels[i]) == 0)
{
transform_labels.insert(pair<int,int>(labels[i], ++CDomainNumber));
labels[i] = transform_labels[labels[i]];
}
else
{
labels[i]= transform_labels[labels[i]];
}
}
//第二次扫描second scan
for (int i = 0;i < row;i++)
{
for (int j = 0;j < col;j++)
{
int key =*(dst.data + dst.step[0] * (i) + dst.step[1] * (j));
*(dst.data + dst.step[0] * (i)+dst.step[1] * (j)) = labels[key];
}
}
/*
for (int i = 1;i < labels.size();i++)
{
cout << "区域号连续后标记 " << i << "所属区域号为:" << labels[i] << endl;
}
*/
//cout<<"第二次扫描后的图像dst:"<<endl<<dst<<endl;
return CDomainNumber;
}
上面被注释掉的代码是为了观查labels和dst的变化,不需要删除即可。
四、代码详解
1、边界外推
//对待测连通域的输入图像进行一个向上和向左的边界外推一个像素单位的操作,外推像素的像素值为0
Mat extrapolationImg;//外推图像
copyMakeBorder(src, extrapolationImg, 1, 0, 1, 0, 0);
对于copyMakeBorder()函数的使用可以参考OpenCV-Python: cv2.copyMakeBorder()函数详解这篇博文。
图像的上方边界的像素点都没有上邻域像素点,左边界的像素点也都没有左邻域像素点。为了不考虑边界像素点的特殊性,对待测连通域的图像进行一个向上和向左都外推一个像素单位的操作,外推像素的像素值为0。
这样可以从行=1,列=1开始遍历外推图像extrapolationImg,然后根据下标获取上邻域、左邻域像素和当前像素。根据像素之间的关系对输出图像dst中的相应位置进行数字标注。
//第一次扫描first scan
for (int i = 1;i <= row;i++)
{
for (int j = 1;j <= col;j++)
{
upPixel =(int)extrapolationImg.at<uchar>(i - 1, j);//上邻域像素
leftPixel = (int)extrapolationImg.at<uchar>(i, j - 1);//左邻域像素
curPixel = (int)extrapolationImg.at<uchar>(i, j);//当前像素
......
}
}
2、第一次遍历中对标注数组labels的操作
1):操作一
if (upPixel == 0 && leftPixel == 0)
{
labels.push_back(label);
dst.at<ushort>(i - 1,j - 1) = label++;
}
输出图像dst与输入图像src的尺寸一致,在遍历外推图像像素点(i,j)时,在dst中对应的像素点就是(i-1,j-1);
以上代码是两遍扫描法中描述的第一种情况,将当前标注值label放入标注数组labels中(表示当前检测到一个新区域,不是连通域),label自增1;label的初始值为1是为了使labels中的下标与值最初是一样的。两遍扫描法中描述的第二和第三种情况是比较简单的,就是完成对dst中当前像素点的一个赋值(数字标注)。
2):操作二
else if (upPixel != 0 && leftPixel != 0)
{
int left_label = (int)dst.at<ushort>(i - 1, j - 2);
int up_label = (int)dst.at<ushort>(i - 2, j - 1);
dst.at<ushort>(i - 1, j - 1) = min(left_label, up_label);
//将同一连通域中的标注链接起来
if (labels[left_label] > labels[up_label])
{
labels[left_label] = labels[up_label];
}
else
{
labels[up_label] = labels[left_label];
}
}
以上代码是两遍扫描法中描述的第四种情况,这里对标注数组labels的操作十分重要,完成了同一连通域中不同标注(区域)的链接。若不进行链接最后造成得到的连通域数量就是标注的个数,可能大于实际的连通域数量。
使用下方二值化后的8*8图像进行测试来查看第一次扫描过后图像各位置像素的标注情况以及标注数组labels中的值。
下图中展示的是第一次扫描后的标注情况,红线圈出的是该标注第一次出现的位置。一个标注代表一个区域。可以看到最大的一个连通域被标注分为1、3、4、5、9、10和11一共7个区域。将同一连通域中的不同区域链接起来就是操作二的最重要的部分。
遍历labels数组
/*
for (int i = 1;i < labels.size();i++)
{
cout << "第一次扫描后标注" << i << "所属区域号为:" << labels[i] << endl;
}
*/
下图是经过链接操作后的标注数组,标注3所属的区域号为1,可理解为标注3的父节点是标注1;标注5的父节点是标注3,标注10的父节点为标注9,标注4、9和11的父节点均为1;这样就实现了同一连通域不同标注代表的区域的链接。
3、标注数组labels的路径压缩
上述操作完成之后,可以看到同一连通域不同标注的父节点是不完全相同的。必须使同一连通域不同的标注对应的区域号相同才能在第二次扫描中使同一连通域中像素标注一致。上图中标注5的父节点为3,而3的父节点是标注1;即在上例最大的连通域中不同的标注的根节点都是标注1。在第一次扫描结束后进行一次查找所有标注根节点的操作。
int find(vector<int> &nums, int i)
{
if (i < 0 || i >= nums.size())
return 0;
while (i>0&&nums[i] != i )
{
//路径压缩
nums[i] = nums[nums[i]];
i = nums[i];
}
return i;
}
for (int i = labels.size() - 1;i >=1;i--)
{
find(labels, i);//路径压缩
}
/*
for (int i = 1;i < labels.size();i++)
{
cout << "标注路径压缩后标注 " << i << "所属区域号为:" << labels[i] << endl;
}
*/
完成后继续输出标注数组进行查看,可以看到最大连通域中的不同部分标注对应的区域号都指向了“1”。
4、标注数组值的变换
完成上述操作后直接进行第二次扫描也是可以的,第二次扫描图像是较为简单的,直接对各个像素重新赋值即可。
//第二次扫描second scan
for (int i = 0;i < row;i++)
{
for (int j = 0;j < col;j++)
{
int key =*(dst.data + dst.step[0] * (i) + dst.step[1] * (j));
*(dst.data + dst.step[0] * (i)+dst.step[1] * (j)) = labels[key];
}
}
//cout<<"第二次扫描后的图像dst:"<<endl<<dst<<endl;
下图是标注路径压缩后直接进行第二次扫描后得到的图像,可以看到图像被分为标注为1,2,6,8的4个连通域。连通域的标注数值并不连续,为得到连续的连通域号,需要在第二次扫描之前对标注数组labels进行一次数值转换。
标注数组数值转换
/*经过上面操作所得到的标注数组中的数值不是连续的,需进行变换;
遍历标注数组:
以标志的数值作为map的key,对应的vaule从1开始计数;
使用map的count()函数来判断当前标注所表示的区域是否已被记录,
如count(labels[i])==0,就表示该标注所表示的区域未被记录,
count(labels[i])==1,就表示该标注所表示的区域已被记录,
map中的值就是其所对应的key所在的区域号,
最后map中最大的值就表示连通域的数目;
*/
int CDomainNumber = 0;//已被确定的连通域数目
map<int, int> transform_labels;
for (int i = 1;i < labels.size();i++)
{
if (transform_labels.count(labels[i]) == 0)
{
transform_labels.insert(pair<int,int>(labels[i], ++CDomainNumber));
labels[i] = transform_labels[labels[i]];
}
else
{
labels[i]= transform_labels[labels[i]];
}
}
标注数组数值转换后的输出图像,一共4个连通域,区域号分别为1,2,3,4。
到此,两遍扫描法分析连通域基本完成。
五、测试效果及与OpenCV库函数的对比
测试代码
int main()
{
Mat img = imread("../coins.png");//原图
//Mat img = imread("../rice.png");
if (img.empty())
{
cout << "请确认图像文件名称是否正确" << endl;
return -1;
}
Mat rice, riceBW;
//将图像转换成二值图像,用于统计连通域
cvtColor(img, rice, COLOR_BGR2GRAY);
threshold(rice, riceBW, 55, 255, THRESH_BINARY);
//用于生成随机颜色,用于区分不同的连通域
RNG rang(10086);
Mat myout;
int mynumber = myTwoPass(riceBW, myout);//自定义函数
vector<Vec3b> mycolors;
for (int i = 0;i < mynumber;i++)
{
//使用均匀分布的随机数确定颜色
Vec3b myvec3 = Vec3b(rang.uniform(0, 256), rang.uniform(0, 256), rang.uniform(0, 256));
mycolors.push_back(myvec3);
}
//以不同的颜色标记不同的连通域
Mat myresult = Mat::zeros(rice.size(), img.type());//自定义输出图像
int w = myresult.cols;
int h = myresult.rows;
for (int i = 0;i < h;i++)
{
for (int j = 0;j < w;j++)
{
int label = myout.at<int>(i, j);
if (label == 0)
{//背景颜色不改变
continue;
}
myresult.at<Vec3b>(i, j) = mycolors[label-1];
}
}
Mat out;
int number = connectedComponents(riceBW, out, 4, CV_16U);//库函数
vector<Vec3b> colors;
for (int i = 0;i < number;i++)
{
//使用均匀分布的随机数确定颜色
Vec3b vec3 = Vec3b(rang.uniform(0, 256), rang.uniform(0, 256), rang.uniform(0, 256));
colors.push_back(vec3);
}
Mat result = Mat::zeros(rice.size(), img.type());//库函数输出图像
for (int i = 0;i < h;i++)
{
for (int j = 0;j < w;j++)
{
int label = out.at<ushort>(i, j);
if (label == 0)
{//背景颜色不改变
continue;
}
result.at<Vec3b>(i, j) = colors[label - 1];
}
}
cout << "库函数得到的连通域数:" << number << endl;
cout << "自定义函数得到的连通域数:" << mynumber << endl;
//显示结果
imshow("原图", img);
imshow("库函数输出图像", result);
imshow("自定义函数输出图像", myresult);
waitKey(0);
return 0;
}
原图:
自定义函数与库函数的输出图像
连通域数量统计对比,其区域分别是九个圆圈和图像边框,一共十个区域,库函数计算了背景所以连通域数量多1,从对比结果来看自定义函数是有效的。
总结:算法总体来说是完成了,写的比较繁琐,也是一次不错的学习记录。