目录
0x01 FloodFill分割
FloodFill泛洪填充算法是在很多图形绘制软件中常用的填充算法,通常来说是自动选中与种子像素相关的区域,利用指定的颜色进行区域颜色替换,可用于标记或分离图形的某些部分。比如windows系统中的图像编辑软件中的油漆桶这一功能,或者是Photoshop的魔术棒选择工具,都是通过FloorFill泛洪填充来改进和延伸的。
(一)原理
从一个点开始遍历附近的像素点,填充成新的颜色,直到封闭区域内的所有像素点都被填充成新颜色为止。
(二)常见方法
4邻域像素填充法、8邻域像素填充法、基于扫描线的像素填充方法等。
(三)OpenCV中的接口
int floodFill( InputOutputArray image, //1或3通道,8bit或浮点型数据,输入图像将会因为被函数操作而修改,如果想保留原图则使用CV_FLOODFILL_MASK_ONLY
Point seedPoint, //种子点开始位置
Scalar newVal, //新的重新绘制的像素值
CV_OUT Rect* rect = 0,
Scalar loDiff = Scalar(), //当前观察像素值与其部件领域像素或者待加入该部件的种子像素之正差的最大值
Scalar upDiff = Scalar(), //当前观察像素值与其部件领域像素或待加入该部件的种子像素之正差的最大值
int flags = 4 ); //操作选项标志位,低位比特包含连通值,4或8
函数解析:
用指定颜色填充一个连通域。高位比特位可以选择0或下面的开关选项的组合:如果设置CV_FLOODFILL_FIXED_RANGE为当前选项,则考虑当前像素与种子像素的差,否则考虑当前像素与其相邻像素的差。如果设置CV_FLOODFILL_MASK_ONLY为当前选项,函数不填充原始图像,但填充掩模图像(这种情况下MASK必须是非空的)。
怎么用:
FloodFill区域填充适用于对内定义区域的种子进行填充,区域内部所有像素具有统一的颜色或亮度,外部区域外的所有像素值表现出不同的特征。利用FloodFill可将该区域中的全部像素都设置为另外一个新值,并通过特定规则来实现区域内点中的连通域,从而实现对相似区域进行填充,直到找到区域内所有像素或边界轮廓。
那么我们可以通过实现一个点击一个点,判断邻域的特征值进行填充,就像油漆桶的操作:
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include "opencv2/core/core.hpp"
#include "opencv2/core/utility.hpp"
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <opencv2/imgproc/imgproc_c.h>
#include <opencv2/opencv.hpp>
using namespace cv;
using namespace std;
// 初识化参数
Mat image, gray, mask;
int ffillMode = 1;
int loDiff = 20, upDiff = 20;
int connectivity = 4;
int isColor = true;
bool useMask = false;
int newMaskVal = 255;
// 鼠标响应函数
static void onMouse(int event, int x, int y, int, void*)
{
if (event != EVENT_LBUTTONDOWN)
return;
// floodfill参数设置
Point seed = Point(x, y);
int lo = ffillMode == 0 ? 0 : loDiff;
int up = ffillMode == 0 ? 0 : upDiff;
//设置flag,为什么要这么写?
int flags = connectivity + (newMaskVal << 8) +
(ffillMode == 1 ? CV_FLOODFILL_FIXED_RANGE : 0);
// 颜色分量随机选取
int b = (unsigned)theRNG() & 255;
int g = (unsigned)theRNG() & 255;
int r = (unsigned)theRNG() & 255;
Rect ccomp;
// 颜色选择,判断是否要显示为灰度
Scalar newVal = isColor ? Scalar(b, g, r)
: Scalar(r * 0.299 + g * 0.587 + b * 0.114);
Mat dst = isColor ? image : gray;
int area;
// 根据标志位选择泛洪填充
if (useMask)
{
// 阈值化操作
threshold(mask, mask, 1, 128, CV_THRESH_BINARY);
cout << lo << up << endl;
area = floodFill(dst, mask, seed,
newVal, &ccomp, Scalar(lo, lo, lo),
Scalar(up, up, up), flags);
imshow("mask", mask);
}
else
{
// 泛洪填充
// 判断点击点附近的值,进行填充
area = floodFill(dst, seed, newVal, &ccomp,
Scalar(lo, lo, lo),
Scalar(up, up, up), flags);
}
imshow("image", dst);
}
int main()
{
cv::Mat srcImage = cv::imread("./image/circle.jpg");
if (srcImage.empty())
return 0;
srcImage.copyTo(image);
cvtColor(srcImage, gray, CV_BGR2GRAY);
mask.create(srcImage.rows + 2, srcImage.cols + 2, CV_8UC1);
// 鼠标响应回调函数
namedWindow("image", 0);
setMouseCallback("image", onMouse, 0);
for (;;)
{
imshow("image", isColor ? image : gray);
int c = waitKey(0);
if ((c & 255) == 27)
{
cout << "Exiting ...\n";
break;
}
if (c == 'r')
{
cout << "Original image is restored\n";
srcImage.copyTo(image);
cvtColor(image, gray, CV_BGR2GRAY);
mask = Scalar::all(0);
}
}
return 0;
}
最后实现的效果如下:
0x02 均值漂移MeanShift
均值漂移是一种核密度估计方法,用来分析复杂多模特征空间。本质其实就是利用了梯度下降法,沿着梯度下降方法寻找目标函数的极值。图像分割是找到分割像素点所属类的中心,均值漂移认为类中心是概率密度的极大值点,对于任一像素沿着梯度方向总能找到其极值点。其实这个技术也在神经网络中运用,在不断的梯度下降中找到合适的权重。
均值漂移的步骤:
(1)平滑mask点搜索。图像由N维像素构成特征空间,特征空间由坐标空间与颜色空间构成,空间域或颜色域构成相应的联合域。
那么对于图像中的像素点,它在联合特征空间中迭代搜索相应的mask点,设置源图像的像素用xi表示,平滑后的图像用zi表示,平滑是将模点的颜色值初始化为原始像素点,利用空间颜色域特征函数yi+k+1得到平滑后的像素值:
那么其中g为核函数,hr为颜色空间的核平滑尺度,xn为特征函数的空间分布。对于每一个像素点x,我们计算其y,然后在坐标空间内应用方形区域筛选靠近yi+k的数据点,然后对得到的数据点计算其重心移动yi+k+1,接着判断得到的点集是否满足终止条件,最后将得到mask点的颜色更新xi=yi,c。
(2)mask点聚类。对平滑后的图像应用颜色相似度与空间位置相似性进行判定,在实际计算中常常只考虑颜色相似性,对满足||zi-zj||<=hs的点集进行合并。
(3)合并相似小区域。将上一步得到的模点集消除像素个数小于S的区域或与它相似的区域进行合并。
可能看到这也是一知半解,那我们再深入看看:
-
首先我们以随机选取的点为圆心,r为半径做一个圆形的滑窗,其目标是找到数据中心密度最高处,然后将其作为中心。
-
在每次迭代后滑动窗口的中心将向着较高密度的方向移动。
-
连续移动,直到任何方向的移动都不能增加滑窗中点的数量,此时的滑窗收敛。
-
将上述步骤在多个滑窗上进行以覆盖所有的点,当多个滑窗收敛重叠时,其经过的点将会通过其滑窗聚类为一个类。
OpenCV提供的接口:
void pyrMeanShiftFiltering( InputArray src, //输入8位3通道图像
OutputArray dst, //输出图像
double sp, //空间窗半径
double sr, //颜色窗的半径
int maxLevel = 1, //高斯金字塔分割的最高水平
TermCriteria termcrit=TermCriteria(TermCriteria::MAX_ITER+TermCriteria::EPS,5,1) ); //均值漂移参数的终止条件
函数解析:
用于均值漂移分割算法初始化步骤。
代码:
实现是通过MeanShift进行划分区域,使用floodfill进行填充区域,相比于普通的floodfill,我们规定了行走步数以及空间范围:
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include "opencv2/core/core.hpp"
#include "opencv2/core/utility.hpp"
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <opencv2/imgproc/imgproc_c.h>
#include <opencv2/opencv.hpp>
using namespace cv;
using namespace std;
//颜色通道分离
static void MergeSeg(Mat& img
, const Scalar& colorDiff = Scalar::all(1))
{
CV_Assert(!img.empty());
RNG rng = theRNG();
// 定义掩码图像
Mat mask(img.rows + 2, img.cols + 2,
CV_8UC1, Scalar::all(0));
for (int y = 0; y < img.rows; y++)
{
for (int x = 0; x < img.cols; x++)
{
if (mask.at<uchar>(y + 1, x + 1) == 0)
{
// 颜色定义
Scalar newVal(rng(256), rng(256), rng(256));
// 泛洪合并
floodFill(img, mask, Point(x, y)
, newVal, 0, colorDiff, colorDiff);
}
}
}
}
int main(int argc, char** argv)
{
cv::Mat srcImg = imread("./image/beauty.png");
if (srcImg.empty())
return -1;
// 参数设定
int spatialRad = 20;
int colorRad = 20;
int maxPyrLevel = 6;
cv::Mat resImg;
// 均值漂移分割
pyrMeanShiftFiltering(srcImg, resImg,
spatialRad, colorRad, maxPyrLevel);
// 颜色通道分离合并
MergeSeg(resImg, Scalar::all(2));
cv::imshow("src", srcImg);
cv::imshow("resImg", resImg);
cv::waitKey(0);
return 0;
}
利用OpenCV自带的pyrMeanShiftFiltering函数可以实现均值漂移分割,即图像过滤阶段。该函数过滤后输出的函数色彩与纹理同时进行拉伸,在输入的图像下采样图像函数将进行MeanShift迭代,即(x,y)的像素邻域将会重新分配颜色空间,具体方式如下:
其中(R,G,B)与(r,g,b)分别是颜色空间(X,Y)、(x,y)的组成部分,通过计算邻域的平均空间值和平均颜色向量,它们将作为下一次迭代的邻域中心,迭代结束后,每次迭代中心像素的颜色分量值将设量位下一次迭代中心的最后值(平均颜色分量),当maxlevel>0时,建立maxlevel+1层高斯金字塔,执行上面得迭代过程,结果被传递到更大的层和迭代只运行这些像素的层,从而实现颜色不同层是从较低的分辨率层的锥体开始的,使颜色区域的边界清晰。
0x03 图割Grabcut
Graphcut是一种基于图论的分割方法。基于图论的分割技术是图像分割领域中新的研究热点,该方法基于能力优化算法,将图像分割问题转换为图的最小割优化问题。Grabcut是Graphcut算法的改进,Graphcut是一种直接基于图切算法的图像分割技术,仅仅需要确认前景与背景输入,该算法就可以完成背景与前景相似督导额赋权图,并通过最优切割来实现图像分割。Grabcu算法不需要用户交互,仅仅需要输入包含目标前景的区域,就可以完成前景与背景的分离。
其实讲的这么复杂,其实就是抠图的一个处理。
Graphcut与Grabcut的区别在哪:
-
Graphcut的目标和背景模型是灰度直方图,Grabcut采用的是RGB三通道混合高斯模型。
-
Graphcut的能量最小化分割是通过一次计算完成的,但是Grabcut是根据分割模型参数更新完成的学习过程。
-
Graphcut需要用户输入前景与背景区域点集,而Grabcut只需要提供含有背景的区域像素集就可以完成分割。
那么OpenCV提供的函数接口:
void grabCut(
InputArray img, //8位3通道的图像
InputOutputArray mask, //输入/输出掩码图像
Rect rect, //分割目标的ROI限定区域范围
InputOutputArray bgdModel, //背景模型的临时存储数组
InputOutputArray fgdModel, //前景模型的临时存储数组
int iterCount, //迭代次数
int mode=GC_EVAL
)
具体:
-
mask:当掩码设置位GC_INIT_WITH_RECT时,掩码必须要初始化,其元素可以选择以下参数:GCD_BGD的定义为确认背景,GCD_FGD的定义为确认前景,GCD_PR_BGD的定义为可能的背景,GCD_PR_FGD的定义为可能的前景。
-
rect:该窗口内的像素会被处理,参数可以设置为GC_INIT_WITH_RECT。
利用OpenCV进行图割算法实现时,首先需要定义分割矩形区域,利用Grabcut完成背景与前景的分离:
其实通俗一点来讲的话,他就是把我选中的区域分开来,跟抠图差不多。
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include "opencv2/core/core.hpp"
#include "opencv2/core/utility.hpp"
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <opencv2/imgproc/imgproc_c.h>
#include <opencv2/opencv.hpp>
using namespace cv;
using namespace std;
// 全局变量区域
cv::Point point1, point2;
int drag = 0;
cv::Rect rect;
cv::Mat srcImage, roiImg;
bool findflag = false;
cv::Mat rectimg;
vector<Point>Pf, Pb;
// 鼠标响应回调
void mouseHandler(int event, int x, int y, int flags, void* param)
{
// 左键按下
if (event == EVENT_LBUTTONDOWN && !drag)
{
point1 = Point(x, y);
drag = 1;
}
// 鼠标移动
else if (event == EVENT_MOUSEMOVE && drag)
{
Mat img1 = srcImage.clone();
point2 = Point(x, y);
rectangle(img1, point1, point2, CV_RGB(255, 0, 0), 3, 8, 0);
imshow("srcImage", img1);
}
// 左键抬起与拖拽标志
else if (event == EVENT_LBUTTONUP && drag)
{
point2 = Point(x, y);
rect = Rect(point1.x, point1.y, x - point1.x, y - point1.y);
drag = 0;
roiImg = srcImage(rect);
}
// 右键按下
else if (event == EVENT_RBUTTONDOWN)
{
findflag = true;
drag = 0;
imshow("ROI", roiImg);
}
}
int main()
{
srcImage = imread("./image/sea.jpg");
if (srcImage.empty())
return -1;
// 定义前景与输出图像
cv::Mat srcImage2 = srcImage.clone();
cv::Mat foreground(srcImage.size(), CV_8UC3, cv::Scalar(255, 255, 255));
cv::Mat result(srcImage.size(), CV_8UC1);
// grabcut分割前景与背景
cv::Mat fgMat, bgMat;
namedWindow("srcImage", 1);
imshow("srcImage", srcImage);
// 迭代次数
int i = 20;
std::cout << "20 iters" << std::endl;
// 鼠标响应
setMouseCallback("srcImage", mouseHandler, NULL);
//按下按键才处理
waitKey(0);
if (findflag == true)
{
// 图割算法熟悉
grabCut(srcImage, result, rect, bgMat,fgMat, i, GC_INIT_WITH_RECT);
// 图像匹配
cv::compare(result, cv::GC_PR_FGD,result, cv::CMP_EQ);
// 生成前景图像
srcImage.copyTo(foreground, result);
imshow("foreground", foreground);
}
waitKey(0);
return 0;
}
0x04 奇异区域检测
奇异区域通常指的是与周围邻域有着某些特征(颜色或灰度)差别的区域。奇异区域相对于点区域检测更稳定,在目标分割及检测、图像配准、特征分析等领域得到了广泛的应用。常见的奇异区域如医学领域的X光照片或CT某些特定组织、天空降落物等。
计算机视觉中我们常常关注目标的特征是颜色和灰度,刻画图像中两个区域的视觉相似性有许多方法,如形状描述子、颜色特征、距特征等。如果对于某种具有独特纹理的图片,我们可以使用纹理描述。对于颜色不同的区域,我们可以使用颜色特征来测量对象的不同部分的相似性。
常用的表达颜色扩散的方法是使用前文叙述的直方图操作,利用色彩空间表达RGB颜色空间直方图不能很好的表示颜色扩散现象,因此利用HSV色调饱和度纯度来计算相应其直方图信息。
在计算机视觉中,奇异区域检测主要是通过微分检测或局部极值的分水岭算法实现的。基于图像中的奇异区域的邻域像素值或大或小的特征,我们可以通过计算图像中的局部极值点来实现相应兴趣区域的检测。
那么关于奇异区域检测通常使用两种方法来实现:
(1)基于微分检测器检测
拉普拉斯算子是检测图像的奇异区域常用的方法,二维高斯经过拉普拉斯变换后得到的:
拉普拉斯检测到图像中的局部极值点,通常需要先对图像进行低通滤波,去除伪点噪声。拉普拉斯相应在尺度:σ=r/√2时取得极值,也就是说满足特征空间和尺度上的极值点就是需要检测的奇异区域。
(2)基于局部机制的分水岭检测
局部机制的分水岭检测奇异区域时对源图像进行多间隔区域二值化操作,对一个二值化图像提取相应的连通域并计算相应区域的连通中心点;根据中心点拟合归类成同一块group,得到对应的blob特征;最后根据的得到的中心点集group估计出blob特征和对应半径。
OpenCV实现其接口:SimpleBlobDetector。
Opencv中有一个blob检测类,这个类用于使我们可以更改其中的参数,方便奇异区域检测:
class SimpleBlobDetector : public FeatureDetector
{
public:
struct Params
{
Params();
float thresholdStep;
float minThreshold;
float maxThreshold;
size_t minRepeatability;
float minDistBetweenBlobs;
bool filterByColor;
uchar blobColor;
bool filterByArea;
float minArea, maxArea;
bool filterByCircularity;
float minCircularity, maxCircularity;
bool filterByInertia;
float minInertiaRatio, maxInertiaRatio;
bool filterByConvexity;
float minConvexity, maxConvexity;
};
SimpleBlobDetector(const SimpleBlobDetector :: Params & parameters = SimpleBlobDetector :: Params());
};
类函数解析:
该类实现对blob区域的检测。
-
minThreshold与maxThreshold为二值化参数的起始区间和终止区间。其中thresholdStep为二值化参数区间间隔。
-
filterByColor为二值化中blobs特征选取的颜色,当blobColor=0时检测暗区域,当blobColor=255时检测亮区域。
-
filterByArea为使用参数minArea、maxArea去限定区域面积。
-
filterByCircularity为在minCircularity、maxCircularity之间提取blobs目标特征半径。
-
filterByInertia为提取blobs目标特征使用的最小与最大中心比率。
-
filterByConvexity为提取blobs目标特征,利用区域凸包特性限定为minConvexity与maxConvexity之间。
OpenCV中SimpleBlobDetector提取blobs目标是先将输入图像源转化为二值图像,以thresholdStep为参数间隔利用二值化区间阈值区间[minThreshold,maxThreshold]进行多次二值操作。然后提取每个二值化图像的区域连通并计算它们的中心。接着根据minDistBetweenBlobs将上一步骤得到的区域中心归类得到group。最后根据group估计得到每个blob的中心及其半径,并返回相应的关键点位置及尺寸。
那么对于下面这个图像:使用奇异区域即可提取出所有热气球的位置以及中心。
using namespace cv;
using namespace std;
int main()
{
cv::Mat srcImage =
cv::imread("./image/boudingRect.jpg");
if (!srcImage.data)
return -1;
cv::imshow("srcImage", srcImage);
// 向量关键点
std::vector<KeyPoint> keypoints;
// blob类定义
SimpleBlobDetector::Params params;
// 参数定义
params.filterByArea = true;
params.minArea = 8;
params.maxArea = 150;
Ptr<SimpleBlobDetector> blobDetector = SimpleBlobDetector::create(params);
// 奇异区域检测
blobDetector->detect(srcImage, keypoints);
// 绘制关键点
drawKeypoints(srcImage, keypoints,
srcImage, Scalar(255, 0, 0));
cv::imshow("result", srcImage);
cv::waitKey();
return 0;
}
0x05 肤色检测
肤色检测技术常用的方法有基于颜色空间、光谱特征及肤色反射模型等方法,这些方法的主要步骤都是先进行颜色空间变换,然后再建立肤色模型。肤色检测中颜色空间有RGB、YCrCb、HSV和Lab等,通常再处理的时候是将RGB颜色空间变换成相应的颜色空间,对某种类型的图像通过统计或物理分析,转换成YUV、LUX或H-SV-V等,肤色在颜色空间的分布中相对集中,为了消除光照的影响,一般放弃亮度通道。
皮肤模型中有单高斯、混合高斯、贝叶斯模型和椭圆模型等,高斯分布模型哟关于刻画椭圆高斯概率,对肤色与非肤色采用高斯混合模型在特定区域内能取得较好的实验效果。
那么我们常用哪种色彩模型检测肤色?常用的是YCbCr,其中Y代表的是亮度,Cr代表的是光源中的红色分量,Cb代表光源中的蓝色分量。人的肤色在外观上的差异是由色度引起的,不同人的肤色分布集中在较小的区域内。
肤色的YCbCr颜色空间CbCr平面分布在近似的椭圆区域内,就可以很容易地确认当前像素点是否属于肤色。将图像转化到YCbCr空间并且在CbCr平面进行投影,可以采集到肤色地样本点,将其投影到此平面后,我们进行相应地非线性变换(K-L变换),进而形成的统计椭圆模型如下:
将上式转换为参数矩阵形式:
将CbCr平面均分为许多小区域,将每个区域的中心点CbCr色度值作为当前区域的特征值,对肤色区域像素值进行遍历,如果当前像素值落在该区域内替换当前区域特征值。
int main()
{
cv::Mat srcImage, resultMat;
srcImage = cv::imread("./image/hand1.jpg");
if (srcImage.empty())
return -1;
// 构建椭圆模型
cv::Mat skinMat = cv::Mat::zeros(cv::Size(256, 256), CV_8UC1);
ellipse(skinMat, cv::Point(113, 155.6), cv::Size(23.4, 15.2),
43.0, 0.0, 360.0, cv::Scalar(255, 255, 255), -1);
// 结构元素定义
cv::Mat struElmen = getStructuringElement(cv::MORPH_RECT,
cv::Size(3, 3), cv::Point(-1, -1));
cv::Mat YcrcbMat;
cv::Mat tempMat = cv::Mat::zeros(srcImage.size(), CV_8UC1);
// 颜色空间转换YCrCb
cvtColor(srcImage, YcrcbMat, CV_BGR2YCrCb);
// 椭圆皮肤模型检测
for (int i = 0; i < srcImage.rows; i++)
{
uchar* p = (uchar*)tempMat.ptr<uchar>(i);
cv::Vec3b* ycrcb = (cv::Vec3b*)YcrcbMat.ptr<cv::Vec3b>(i);
for (int j = 0; j < srcImage.cols; j++)
{
// 颜色判断
if (skinMat.at<uchar>(ycrcb[j][1], ycrcb[j][2]) > 0)
p[j] = 255;
}
}
// 形态学闭操作
morphologyEx(tempMat, tempMat, cv::MORPH_CLOSE, struElmen);
// 定义轮廓参数
std::vector< std::vector<cv::Point> > contours;
std::vector< std::vector<cv::Point> > resContours;
std::vector< cv::Vec4i > hierarchy;
// 连通域查找
findContours(tempMat, contours, hierarchy, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_SIMPLE);
// 筛选伪轮廓
for (size_t i = 0; i < contours.size(); i++)
{
if (fabs(contourArea(cv::Mat(contours[i]))) > 1000)
resContours.push_back(contours[i]);
}
tempMat.setTo(0);
// 绘制轮廓
drawContours(tempMat, resContours, -1, cv::Scalar(255, 0, 0), CV_FILLED);
srcImage.copyTo(resultMat, tempMat);
imshow("srcImage", srcImage);
imshow("resultMat", resultMat);
cv::waitKey(0);
return 0;
}