摘要 :本文介绍 OpenCV 中基于颜色与图割的两种图像分割方法。第一部分讲解利用 HSV 色彩空间与
inRange函数实现彩色图像阈值化分割,并给出可交互调节色相、饱和度、亮度区间的完整示例;第二部分介绍 grabCut 算法的基本原理与交互流程,详细说明grabCut函数各参数含义,并演示其与compare函数配合,从静态图像中提取前景物体的用法。
1. 彩色图像分割
灰度图像大多通过算子寻找边缘和区域生长融合来分割图像。 彩色图像增加了色彩信息, 可以通过不同的色彩值来分割图像。 常用彩色空间HSV/HIS、 RGB、 LAB等都可以用于分割图像。
本节使用inRange函数来实现阈值化, 它跟前面的阈值化方法一样, 只不过在实现时用阈值范围来替代固定阈值。 inRange函数提供了一种物体检测的手段, 用基于像素值范围的方法, 在HSV色彩空间检测物体, 从而达到分割的效果。
图中所示是HSV(Hue、 Saturation、 Value的首字母, 分别表示颜色的色相、 饱和度、 强度) 圆柱体, 表示HSV的颜色空间。 HSV色彩空间是一种类似于RGB的颜色表示方式。 Hue通道是颜色类型, 在需要根据颜色来分割物体的应用中非常有效。 Saturation的变化从不饱和到完全饱和, 对应图9-11所示的灰色过渡到阴影(没有白色成分) 。 Value描述了颜色的强度或者亮度。
HSV是一种比较直观的颜色模型, 在许多图像编辑工具中应用广泛。 这个模型中颜色的参数分别是:色调(H, Hue)、饱和度(S, Saturation)、明度(V, Value)。

色调用角度度量, 取值范围为0°~360°, 从红色开始按逆时针方向计算, 红色为0°,绿色为120°, 蓝色为240°。 它们的补色是黄色为60°, 青色为180°, 品红300°。饱和度表示颜色接近光谱色的程度。 一种颜色可以看作某种光谱色与白色混合的结果。 其中光谱色所占的比例愈大, 颜色接近光谱色的程度就愈高, 颜色的饱和度也就愈高。 饱和度高, 颜色则深而艳。 光谱色的白光成分为0, 饱和度达到最高。 通常饱和度取值范围为0%~100%, 值越大, 颜色越饱和。
明度表示颜色明亮的程度。 对于光源色, 明度值与发光体的光亮度有关; 对于物体色, 此值和物体的透射比或反射比有关。 通常明度取值范围为0%(黑)~100%(白)。HSV颜色空间模型(圆锥模型) 如图所示。

由于RGB色彩空间是由3个通道来编码颜色, 因此难以根据颜色来分割物体。 而HSV中只有Hue一个通道表示颜色, 此时可以用函数cvtColor将BGR转换到HSV色彩空间, 然后用函数inRange根据HSV设置的范围检测目标。 inRange函数声明如下:
cpp
void inRange (InputArray src, InputArray lowerb, InputArray upperb,OutputArray dst);
其中src表示输入图像; lowerb表示H、 S、 V的最小值; upperb表示H、 S、 V的最大值; dst表示输出图像, 要和输入图像有相同的尺寸且为CV_8U类。
OpenCV中的inRange()函数可实现二值化功能(这点类似threshold函数), 更关键的是可以同时针对多通道进行操作。 通俗来讲, 这个函数就是判断src中每一个像素是否在lowerb, upperb区间, 注意区间的开闭。 如果结果为是, 那么在dst相应像素位置填上255, 反之填0。 一般我们把dst当作一个mask来用。 对于单通道图像, 如果一幅灰度图像的某个像素的灰度值在指定的高、 低阈值范围之内, 则在dst图像中令该像素值为255, 否则令其为0, 这样就生成了一幅二值化的输出图像。 对于三通道图像, 每个通道的像素值
都必须在规定的阈值范围内。 下面看一个用函数inRange进行颜色分割的例子。
【例9.5】 直接用HSV体系进行颜色分割
cpp
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"
#include <opencv2/imgproc/types_c.h>
#include <iostream>
using namespace std;
using namespace cv;
//输入图像
Mat img;
//灰度值归一化
Mat bgr;
//HSV图像
Mat hsv;
//色相
int hmin = 0;
int hmin_Max = 360;
int hmax = 180;
int hmax_Max = 180;
//饱和度
int smin = 0;
int smin_Max = 255;
int smax = 255;
int smax_Max = 255;
//亮度
int vmin = 106;
int vmin_Max = 255;
int vmax = 255;
int vmax_Max = 255;
//显示原图的窗口
string windowName = "src";
//输出图像的显示窗口
string dstName = "dst";
//输出图像
Mat dst;
void callBack(int, void*)//回调函数
{
dst = Mat::zeros(img.size(), img.type()); //输出图像分配内存
Mat mask; //掩码
inRange(hsv, Scalar(hmin, smin, vmin), Scalar(hmax, smax, vmax), mask);
//掩模到原图的转换
for (int r = 0; r < bgr.rows; r++)
for (int c = 0; c < bgr.cols; c++)
if (mask.at<uchar>(r, c) == 255)
dst.at<Vec3b>(r, c) = bgr.at<Vec3b>(r, c);
//输出图像
imshow(dstName, dst);
//保存图像
//dst.convertTo(dst, CV_8UC3, 255.0, 0);
imwrite("HSV_inRange.jpg", dst);
}
int main(int argc, char** argv)
{
cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
//输入图像
img = imread("myclr.jpg");
if (!img.data || img.channels() != 3)
return -1;
imshow(windowName, img);
bgr = img.clone();
//颜色空间转换
cvtColor(bgr, hsv, CV_BGR2HSV);
//定义输出图像的显示窗口
namedWindow(dstName, WINDOW_GUI_EXPANDED);
//调节色相 H
createTrackbar("hmin", dstName, &hmin, hmin_Max, callBack);
createTrackbar("hmax", dstName, &hmax, hmax_Max, callBack);
//调节饱和度 S
createTrackbar("smin", dstName, &smin, smin_Max, callBack);
createTrackbar("smax", dstName, &smax, smax_Max, callBack);
//调节亮度 V
createTrackbar("vmin", dstName, &vmin, vmin_Max, callBack);
createTrackbar("vmax", dstName, &vmax, vmax_Max, callBack);
callBack(0, 0);
waitKey(0);
return 0;
}

在上述代码中, 首先载入图像, 然后利用函数cvtColor进行颜色空间转换, 转换为HSV颜色空间。 接着为调节色相H、 饱和度S和亮度V分别创建滑块, 这样就可以通过调节不同的颜色、 饱和度、 亮度区间来定义信息。 最后调用自定义函数callBack, 在callBack函数中调用inRange进行颜色分割。
2. grabCut算法分割图像
2.1 基本概念
使用grabCut算法可以用最小程度的用户交互来分解前景。 从用户角度来看, grabCut算法是怎么工作的呢? 首先画一个矩形方块把前景图圈起来, 前景区域应该完全在矩形内;然后算法反复进行分割以达到最好的效果。 但是, 有些情况下分割得不是很好,比如把前景标成背景了, 这种情况下用户需要再润色, 就是在图像上有缺陷的地方画几笔。 这几笔的意思是说"嘿, 这个区域应该是前景, 你把它标成背景了, 下次迭代改过来"或者是反过来。那么下次迭代的结果会更好。 比如图所示的图像。

首先将球员和足球包含在蓝色矩形框里, 然后用白色笔(指出前景) 和黑色笔(指出背景) 来做一些润色。 后台会发生什么呢?
(1) 用户输入矩形, 矩形外的所有东西都被确认是背景, 矩形内的所有东西都是未知的。 同样地, 任何用户输入指定的前景和背景也都被认为是硬标记, 在处理过程中不会变。
(2) 计算机会根据我们给的数据做初始标记, 它会标记出前景和背景像素。
(3) 现在会使用高斯混合模型(GMM) 来为前景和背景建模。
(4) 根据我们给的数据, GMM学习和创建新的像素分布, 未知像素被标为可能的前景或可能的背景(根据其他硬标记像素的颜色统计和它们之间的关系) 。 根据这个像素分布创建一幅图, 图中的节点是像素。 另外还有两个节点, 即源节点和汇节点, 每个前景像素和源节点相连, 每个背景像素和汇节点相连。
(5) 源节点和汇节点连接的像素的边的权重由像素是前景或者背景的概率决定。像素之间的权重是由边的信息或者像素的相似度决定。 如果像素颜色有很大差异, 它们之间的边的权重就比较低。
( 6) 最小分割算法是用来分割图的, 它用最小成本函数把图切成两个分开的源节点和汇节点, 成本函数是被切的边的权重之和。 切完以后, 所有连到源节点的像素称为前景,所有连到汇节点的像素称为背景。
( 7) 过程持续进行, 直到分类覆盖。
整个过程如图所示。

2.2 grabCut函数
grabCut算法是Graphcut算法的改进, Graphcut是一种直接基于图割算法的图像分割技术, 只需确认前景和背景的输入, 该算法就可以完成前景和背景的最优分割。 grabCut算法利用图像中的纹理( 颜色) 信息和边界( 反差) 信息, 只需少量的用户交互操作, 即可获得较好的分割结果。 与分水岭算法相比, grabCut的计算速度较慢, 但能够提供更精确的分割结果。 如果要从静态图像中提取前景物体(如从一幅图像剪切物体到另一幅图像),采用grabCut算法是最好的选择。 其用法很简单, 只需输入一幅图像, 并对一些像素进行标记, 指明其属于背景或前景, 算法就会根据这些局部标记计算出整个图像中前景和背景的分割线。 现在我们用OpenCV来实现grabCut算法。 OpenCV中有一个函数grabCut, 其声明如下:
cpp
void grabCut( InputArray img, InputOutputArray mask, Rect rect,
InputOutputArray bgdModel, InputOutputArray fgdModel, int iterCount, int mode =
GC_EVAL );
参数说明如下:
- img: 表示输入图像。
- mask: 表示输出掩码, 如果使用掩码进行初始化, 那么mask保存初始化掩码信息。
在执行分割的时候, 也可以将用户交互所设定的前景与背景保存到mask中, 然后再传入grabCut函数, 在处理结束之后, mask中会保存结果。 mask只能取以下4种值:
GCD_BGD(=0) 表示背景, GCD_FGD(=1) 表示前景, GCD_PR_BGD(=2) 表示可能的背景, GCD_PR_FGD(=3) 表示可能的前景。
如果没有手动标记GCD_BGD或者GCD_FGD,那么结果只会有GCD_PR_BGD或GCD_PR_FGD。
- rect: 表示用户选择的前景矩形区域, 包含分割对象的矩形ROL, 矩形外部的像素为 背景, 矩形内部的像素为前景, 当参数mode=GC_INIT_WITH_RECT时使用。
- bgModel: 表示输出背景图像。
- fgModel: 表示输出前景图像。
- iterCount: 表示迭代次数。
- mode: 表示用于指示grabCut函数进行什么操作, 可选的值有GC_INIT_WITH_RECT(=0),表示用矩形窗初始化grabCut;GC_INIT_WITH_MASK( =1) 表示用掩码图像初始化GrabCut; GC_EVAL(=2) 表示执行分割。
可以按以下方式来使用grabCut函数:
( 1) 用矩形窗或掩码图像初始化grabCut。
( 2) 执行分割。
( 3) 如果对结果不满意, 在掩码图像中设定前景和( 或) 背景, 再次执行分割。
( 4) 使用掩码图像中的前景或背景信息。
利用grabCut函数做图像分割时, 通常还需要和compare函数联合作战。 compare函数
主要用于在两个图像之间进行逐像素的比较, 并输出比较的结果, 其声明如下:
cpp
void compare(InputArray src1, InputArray src2, OutputArray dst, int cmpop);
其中参数src1表示原始图像1( 必须是单通道) 或者一个数值, 比如是一个Mat或者一个单纯的数字n; src2表示原始图像2( 必须是单通道) 或者一个数值, 比如是一个Mat或者一个单纯的数字n; dst表示结果图像, 类型是CV_8UC1, 即单通道8位图, 大小和src1、 src2中最大的那个一样, 比较结果为真的地方值为255, 否则为0; cmpop表示操作类型, 有以下几种类型:

从参数的要求可以看出, compare函数只对以下3种情况进行比较:
1) array和array
此时输入的src1和src2必须是相同大小的单通道图, 否则没办法进行比较。 计算过程如下:
cpp
dst(i) = src1(i) cmpop src2(i)
也就是对src1和src2逐像素进行比较。
2) array和scalar
此时array仍然要求是单通道图, 大小无所谓, 因为scalar只是一个单纯的数字而已。比较过程是把array中的每个元素逐一和scalar进行比较, 所以此时的dst大小和array是一样的。 计算过程如下:
cpp
dst(i) = src1(i) cmpop scalar
3) scalar和array
这个就是第2种情况的反过程了, 只是比较运算符cmpop左右的参数顺序不一样而已。 计算过程如下:
c
dst(i) = scalar cmpop src2(i)
这个函数有一个很有用的地方就是: 当你需要从一幅图像中找出某些特定像素值的像素时, 可以用这个函数。 它与threshold()函数类似, 但是threshold()函数是对某个区间内的像素值进行操作, 而compare()函数可以只是对某一个单独的像素值进行操作。 比如我们要从图像中找出像素值为50的像素点, 可以像下面这样做:
cpp
Mat result;
compare(image,50, result, cv::CMP_EQ);
【例9.6】 利用grabCut做分割
cpp
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"
#include <opencv2/imgproc/types_c.h>
#include <iostream>
using namespace std;
using namespace cv;
int main()
{
cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
Mat src = imread("bird.jpg");
imshow("src", src);
Rect rect(84, 84, 406, 318);//左上坐标(X,Y)和长宽
Mat result, bg, fg;
grabCut(src, result, rect, bg, fg, 1, GC_INIT_WITH_RECT);
imshow("grab", result);
/*threshold(result, result, 2, 255, CV_THRESH_BINARY);
imshow("threshold", result);*/
//result和GC_PR_FGD对应像素相等时,目标图像该像素值置为255
compare(result, GC_PR_FGD, result, CMP_EQ);
imshow("result", result);
Mat foreground(src.size(), CV_8UC3, Scalar(255, 255, 255));
src.copyTo(foreground, result);//copyTo有两种形式,此形式表示result为mask
imshow("foreground", foreground);
waitKey(0);
return 0;
}

在上述代码中, 首先加载了图片test.jpg, 然后使用函数grabCut从原图中复制可能的
前景到结果图像中。 函数compare在两个图像之间进行逐像素的比较, 并输出比较的结
果。 这里result和GC_PR_FGD对应像素相等时, 目标图像的像素值置为255。 最后把前景
复制到矩阵foreground中, 并进行显示。