本章包括以下内容:
- 用策略设计模式比较颜色;
- 用GrabCut 算法分割图像;
- 转换颜色表示法;
- 用色调、饱和度和亮度表示颜色。
3.2 用策略设计模式比较颜色
假设我们要构建一个简单的算法,用来识别图像中具有某种颜色的所有像素。这个算法必须输入一幅图像和一个颜色,并且返回一个二值图像,显示具有指定颜色的像素。在运行算法前,还要指定一个参数,即能接受的颜色的公差。
这里先看一个部署和使用它的例子。写一个简单的主函数,调用颜色检测算法:
int main()
{
// 1.创建图像处理器对象
ColorDetector cdetect;
// 2.读取输入的图像
cv::Mat image= cv::imread("boldt.jpg");
if (image.empty()) return 0;
// 3.设置输入参数
cdetect.setTargetColor(230,190,130); // 这里表示蓝天
// 4.处理图像并显示结果
cv::namedWindow("result");
cv::Mat result = cdetect.process(image);
cv::imshow("result",result);
cv::waitKey(0);
return 0;
}
这个算法的核心过程非常简单,只是对每个像素进行循环扫描,把它的颜色和目标颜色做比较。可以这样写这个循环:
#include "colordetector.h"
#include <vector>
cv::Mat ColorDetector::process(const cv::Mat &image) {
// 必要时重新分配二值映像
// 与输入图像的尺寸相同,不过是单通道
result.create(image.size(),CV_8U);
// Converting to Lab color space
if (useLab)
cv::cvtColor(image, converted, CV_BGR2Lab);
// 取得迭代器
cv::Mat_<cv::Vec3b>::const_iterator it= image.begin<cv::Vec3b>();
cv::Mat_<cv::Vec3b>::const_iterator itend= image.end<cv::Vec3b>();
cv::Mat_<uchar>::iterator itout= result.begin<uchar>();
// get the iterators of the converted image
if (useLab) {
it = converted.begin<cv::Vec3b>();
itend = converted.end<cv::Vec3b>();
}
// 对于每个像素
for ( ; it!= itend; ++it, ++itout) {
// 比较与目标颜色的差距
if (getDistanceToTargetColor(*it)<maxDist) {
*itout= 255;
} else {
*itout= 0;
}
}
return result;
}
我们已经定义了核心的处理方法,下面就看一下为了部署该算法,还需要添加哪些额外方法。
#if !defined COLORDETECT
#define COLORDETECT
#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>
class ColorDetector {
private:
int maxDist; // 允许的最小差距
cv::Vec3b target; // 目标颜色
cv::Mat result; // 存储二值映像结果的图像
public:
// 空构造函数
// 在此初始化默认参数
ColorDetector() : maxDist(100), target(0, 0, 0) {}
// 另一种构造函数,使用目标颜色和颜色距离作为参数
ColorDetector(uchar blue, uchar green, uchar red, int mxDist=100):maxDist(maxDist){
// 目标颜色
setTargetColor(blue, green, red);
}
cv::Mat process(const cv::Mat& image);
// 设置颜色差距的阈值
// 阈值必须是正数,否则就设为0
void setColorDistanceThreshold(int distance) {
if (distance < 0)
distance = 0;
maxDist = distance;
}
// 取得颜色差距的阈值
int getColorDistanceThreshold() const {
return maxDist;
}
// 设置需要检测的颜色
void setTargetColor(uchar blue, uchar green, uchar red) {
target = cv::Vec3b(blue, green, red);
}
// 设置需要检测的颜色
void setTargetColor(cv::Vec3b color) {
target = color;
}
// 取得需要检测的颜色
cv::Vec3b getTargetColor() const {
return target;
}
// 计算两个颜色之间的城区距离
int getColorDistance(const cv::Vec3b& color1,
const cv::Vec3b& color2) const {
return abs(color1[0] - color2[0]) +
abs(color1[1] - color2[1]) +
abs(color1[2] - color2[2]);
}
// 计算与目标颜色的差距
int getDistanceToTargetColor(const cv::Vec3b& color) const {
return getColorDistance(color, target);
}
};
#endif
计算两个颜色向量间的距离
要计算两个颜色向量间的距离,可使用这个简单的公式:
abs(color[0]-target[0])+
abs(color[1]-target[1])+
abs(color[2]-target[2]);
OpenCV 中也有计算向量的欧几里得范数的函数,因此也可以这样计算距离:
return static_cast<int>(
cv::norm<int, 3>(cv::Vec3i(color[0] - target[0],
color[1] - target[1],
color[2] - target[2])));
使用OpenCV 函数
本节采用了在循环中使用迭代器的方法来进行计算。还有一种做法是调用OpenCV 的系列函数,也能得到一样的结果。因此,检测颜色的方法还可以这样写:
cv::Mat ColorDetector::process(const cv::Mat& image) {
cv::Mat output;
// 计算与目标颜色的距离的绝对值
cv::absdiff(image, cv::Scalar(target), output);
// 把通道分割进3 幅图像
std::vector<cv::Mat> images;
cv::split(output, images);
// 3 个通道相加(这里可能出现饱和的情况)
output = images[0] + images[1] + images[2];
// 应用阈值
cv::threshold(output, // 相同的输入/输出图像
output,
maxDist, // 阈值(必须<256)
255, // 最大值
cv::THRESH_BINARY_INV); // 阈值化模式
return output;
}
该方法使用了absdiff 函数计算图像的像素与标量值之间差距的绝对值。该函数的第二个参数也可以不用标量值,而是改用另一幅图像,这样就可以逐个像素地计算差距。因此两幅图像的尺寸必须相同。
然后,用split 函数提取出存放差距的图像的单个通道以便求和。
最后一步是用cv::threshold 函数创建一个二值图像。。这个函数通常用于将所有像素与某个阈值(第三个参数)进行比较,并且在常规阈值化模式(cv::THRESH_BINARY)下,将所有大于指定阈值的像素赋值为预定的最大值(第四个参数),将其他像素赋值为0。
一般来说,最好直接使用OpenCV 函数。它可以快速建立复杂程序,减少潜在的错误,而且程序的运行效率通常也比较高(得益于OpenCV 项目参与者做的优化工作)。不过这样会执行很多的中间步骤,消耗更多内存。
floodFill 函数
ColorDetector 类可以在一幅图像中找出与指定颜色接近的像素,它的判断方法是对像素进行逐个检查。cv::floodFill 函数的做法与之类似,但有一个很大的区别,那就是它在判断一个像素时,还要检查附近像素的状态,这是为了识别某种颜色的相关区域。用户只需指定一个起始位置和允许的误差,就可以找出颜色接近的连续区域。
首先根据亚像素确定搜寻的颜色,并检查它旁边的像素,判断它们是否为颜色接近的像素;然后,继续检查它们旁边的像素,并持续操作。这样就可以从图像中提取出特定颜色的区域。例如要从图中提取出蓝天,可以执行以下语句:
cv::floodFill(image, // 输入/输出图像
cv::Point(100, 50), // 起始点
cv::Scalar(255, 255, 255), // 填充颜色
(cv::Rect*)0, // 填充区域的边界矩形
cv::Scalar(35, 35, 35), // 偏差的最小/最大阈值
cv::Scalar(35, 35, 35), // 正差阈值,两个阈值通常相等
cv::FLOODFILL_FIXED_RANGE); // 与起始点像素比较
图像中亚像素(100, 50)所处的位置是天空。函数会检查所有的相邻像素,颜色接近的像素会被重绘成第三个参数指定的新颜色。为了判断颜色是否接近,需要分别定义比参考色更高或更低的值作为阈值。这里使用固定范围模式,即所有像素都与亚像素的颜色进行对比,默认模式是将每个像素与和它邻近的像素进行对比。得到的结果如下图所示。
3.3 用GrabCut 算法分割图像
如果要从静态图像中提取前景物体(例如从图像中剪切一个物体,并粘贴到另一幅图像),最好采用GrabCut 算法。
cv::grabCut 函数的用法非常简单,只需要输入一幅图像,并对一些像素做上“属于背景”或“属于前景”的标记即可。根据这个局部标记,算法将计算出整幅图像的前景/背景分割线。
一种指定输入图像局部前景/背景标签的方法是定义一个包含前景物体的矩形:
// 定义一个带边框的矩形
// 矩形外部的像素会被标记为背景
cv::Rect rectangle(5,70,260,120);
矩形之外的像素都会被标记为背景。调用cv::grabCut 时,除了需要输入图像和分割后的图像,还需要定义两个矩阵,用于存放算法构建的模型,代码如下所示:
cv::Mat result; // 分割结果(四种可能的值)
cv::Mat bgModel,fgModel; // 模型(内部使用)
// GrabCut 分割算法
cv::grabCut(image, // 输入图像
result, // 分割结果
rectangle, // 包含前景的矩形
bgModel,fgModel, // 模型
5, // 迭代次数
cv::GC_INIT_WITH_RECT); // 使用矩形
注意,我们在函数的中用cv::GC_INIT_WITH_RECT 标志作为最后一个参数,表示将使用带边框的矩形模型。
输入/输出的分割图像可以是以下四个值之一。
- cv::GC_BGD:这个值表示明确属于背景的像素(例如本例中矩形之外的像素)。
- cv::GC_FGD:这个值表示明确属于前景的像素(本例中没有这种像素)。
- cv::GC_PR_BGD:这个值表示可能属于背景的像素。
- cv::GC_PR_FGD:这个值表示可能属于前景的像素(即本例中矩形之内像素的初始值)。
通过提取值为cv::GC_PR_FGD 的像素,可得到包含分割信息的二值图像,实现代码为:
// 取得标记为“可能属于前景”的像素
cv::compare(result,cv::GC_PR_FGD,result,cv::CMP_EQ);
// 生成输出图像
cv::Mat foreground(image.size(),CV_8UC3,cv::Scalar(255,255,255));
image.copyTo(foreground, result); // 不复制背景像素
要提取全部前景像素,即值为cv::GC_PR_FGD 或cv::GC_FGD 的像素,可以检查第一位的值,代码如下所示:
// 用“按位与”运算检查第一位
result= result&1; // 如果是前景像素,结果为1
这可能是因为这几个常量被定义的值为1 和3,而另外两个(cv::GC_BGD 和cv::GC_PR_BGD)被定义为0 和2。本例因为分割图像不含cv::GC_FGD 像素(只输入了cv::GC_BGD 像素),所以得到的结果是一样的。
完整的代码为:
cv::Mat image = cv::imread("boldt.jpg", 1);
if (image.empty())
return 0;
cv::Rect rectangle(50, 25, 210, 180);
cv::Mat bgModel, fgModel;
cv::Mat result;
cv::grabCut(image,
result,
rectangle,
bgModel, fgModel,
5,
cv::GC_INIT_WITH_RECT);
cv::compare(result, cv::GC_PR_FGD, result, cv::CMP_EQ); // result = result & 1;
cv::Mat foreground(image.size(), CV_8UC3, cv::Scalar(255, 255, 255));
image.copyTo(foreground, result);
cv::namedWindow("result");
cv::imshow("result", foreground);
cv::waitKey(0);
利用输入信息,GrabCut 算法通过以下步骤进行背景/前景分割。
首先,把所有未标记的像素临时标为前景(cv::GC_PR_FGD)。基于当前的分类情况,算法把像素划分为多个颜色相似的组(即K 个背景组和K 个前景组)。
下一步是通过引入前景和背景像素之间的边缘,确定背景/前景的分割,这将通过一个优化过程来实现。
在此过程中,将试图连接具有相似标记的像素,并且避免边缘出现在强度相对均匀的区域。使用Graph Cuts 算法可以高效地解决这个优化问题,它寻找最优解决方案的方法是:把问题表示成一幅连通的图形,然后在图形上进行切割,以形成最优的形态。分割完成后,像素会有新的标记。然后重复这个分组过程,找到新的最优分割方案,如此反复。
因此,GrabCut 算法是一个逐步改进分割结果的迭代过程。根据场景的复杂程度,找到最佳方案所需的迭代次数各不相同(如果情况简单,迭代一次就足够了)。
3.4 转换颜色表示法
利用RGB 色彩空间计算颜色之间的差距并不是衡量两个颜色相似度的最好方式。实际上,RGB 并不是感知均匀的色彩空间。也就是说,两种具有一定差距的颜色可能看起来非常接近,而另外两种具有同样差距的颜色看起来却差别很大。
为解决这个问题,引入了一些具有感知均匀特性的颜色表示法。CIE L*a*b*就是一种这样的颜色模型。把图像转换到这种表示法后,我们就可以真正地使用图像像素与目标颜色之间的欧几里得距离,来度量颜色之间的视觉相似度。本节将介绍如何转换颜色表示法,以便使用其他色彩空间。
使用OpenCV 的函数cv::cvtColor 可以轻松转换图像的色彩空间。
// 将目标颜色转换成Lab 色彩空间
cv::cvtColor(tmp, tmp, cv::COLOR_BGR2Lab);
在将图像从一个色彩空间转换到另一个色彩空间时,会在每个输入像素上做一个线性或非线性的转换,以得到输出像素。
实际的像素值范围取决于指定的色彩空间和目标图像的类型。比如说CIE L*a*b*色彩空间中的L 通道表示每个像素的亮度,范围是0~100;在使用8 位图像时,它的范围就会调整为0~255。a 通道和b 通道表示色度组件,这些通道包含了像素的颜色信息,与亮度无关。它们的值的范围是 -127~127;对于8 位图像,为了适应0~255 的区间,每个值会加上128。但是要注意,进行8 位颜色转换时会产生舍入误差,因此转换过程并不是完全可逆的。
CIE L*u*v*是另一种感知均匀的色彩空间。若想从BGR 转换成CIE L*u*v*,可使用代码COLOR_BGR2Luv。L*a*b*和L*u*v*对亮度通道使用同样的转换公式,但对色度通道则使用不同的表示法。另外,为了实现视觉感知上的均匀,这两种色彩空间都扭曲了RGB 的颜色范围,所以这些转换过程都是非线性的(因此计算量巨大)。
此外还有CIE XYZ 色彩空间(用代码CV_BGR2XYZ 表示)。它是一种标准色彩空间,用与设备无关的方式表示任何可见颜色。在L*a*b*和L*u*v*色彩空间的计算中,用XYZ 色彩空间作为一种中间表示法。RGB 与XYZ 之间的转换是线性的。还有一点非常有趣,就是Y 通道对应着图像的灰度版本。
HSV 和HLS 这两种色彩空间很有意思,它们把颜色分解成加值的色调和饱和度组件或亮度组件。人们用这种方式来描述的颜色会更加自然。下一节将介绍这种色彩空间。
3.5 用色调、饱和度和亮度表示颜色
为了能让用户用更直观的属性描述颜色,我们引入了基于色调、饱和度和亮度的色彩空间。本节将把色调、饱和度和亮度作为描述颜色的方法,并对这些概念加以探讨。
cv::cvtColor 函数把BGR 图像转换成另一种色彩空间。这里使用转换代码COLOR_BGR2HSV:
// 转换成HSV 色彩空间
cv::Mat hsv;
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
我们可以用代码COLOR_HSV2BGR 把图像转换回BGR 色彩空间。通过把图像的通道分割到三个独立的图像中,我们可以直观地看到每一种HSV 组件,方法如下所示:
// 把3 个通道分割进3 幅图像中
std::vector<cv::Mat> channels;
cv::split(hsv,channels);
// channels[0]是色调
// channels[1]是饱和度
// channels[2]是亮度
注意第三个通道表示颜色值,即颜色亮度的近似值。因为处理的是8 位图像,所以OpenCV会把通道值的范围重新调节为0255(色调除外,它的范围被调节为0180)。
城堡图的亮度通道显示如下。
饱和度通道显示如下。
色调通道如下。
之所以要引入色调/饱和度/亮度的色彩空间概念,是因为人们喜欢凭直觉分辨各种颜色,而它与这种方式吻合。
色调(hue)表示主色,我们使用的颜色名称(例如绿色、黄色和红色)就对应了不同的色调值;
饱和度(saturation)表示颜色的鲜艳程度,柔和的颜色饱和度较低,而彩虹的颜色饱和度就很高;
最后,亮度(brightness)是一个主观的属性,表示某种颜色的光亮程度。
OpenCV 建议的两种直觉色彩空间的实现是HSV 和HLS 色彩空间,它们的转换公式略有不同,但是结果非常相似。
亮度成分可能是最容易解释的。在OpenCV 对HSV 的实现中,它被定义为三个BGR 成分中的最大值。
OpenCV 用一个公式来计算饱和度,该公式基于BGR 组件的最小值和最大值:
灰度颜色包含的R、G、B 的成分是相等的,相当于一种极不饱和的颜色,因此它的饱和度是0(饱和度是一个0~1.0 的值)。对于8 位图像,饱和度被调节成一个0~255 的值,并且作为灰度图像显示的时候,较亮区域对应的颜色具有较高的饱和度。
在黑色区域中计算得到的饱和度是不可靠的,没有参考价值。
颜色的色调通常用0~360 的角度来表示,其中红色是0 度。对于8 位图像,OpenCV 把角度除以2,以适合单字节的存储范围。因此,每个色调值对应指定颜色的色彩,与亮度和饱和度无关。有一点要特别注意,如果颜色的饱和度很低,它计算出来的色调就不可靠。
HSB 色彩空间通常用一个圆锥体来表示,圆锥体内部的每个点代表一种特定的颜色,角度位置表示颜色的色调,到中轴线的距离表示饱和度,高度表示亮度。圆锥体的顶点表示黑色,它的色调和饱和度是没有意义的。
我们还可以人为生成一幅图像,用来说明各种色调/饱和度组合。
cv::Mat hs(128, 360, CV_8UC3);
for (int h = 0; h < 360; h++) {
for (int s = 0; s < 128; s++) {
hs.at<cv::Vec3b>(s, h)[0] = h / 2; // 所有色调角度
// 饱和度从高到低
hs.at<cv::Vec3b>(s, h)[1] = 255 - s * 2;
hs.at<cv::Vec3b>(s, h)[2] = 255; // 常数
}
}
从左到右表示不同的色调(0~180),从上到下表示不同的饱和度。图像顶端为饱和度最高的颜色,底部为饱和度最低的颜色。图中所有颜色的亮度都为255。
使用HSV 的值可以生成一些非常有趣的效果。一些用照片编辑软件生成的色彩特效就是用这个色彩空间实现的。你可以修改一幅图像,把它的所有像素都设置为一个固定的亮度,但不改变色调和饱和度。可以这样实现:
// 转换成HSV 色彩空间
cv::Mat hsv;
cv::cvtColor(image, hsv, CV_BGR2HSV);
// 将3 个通道分割到3 幅图像中
std::vector<cv::Mat> channels;
cv::split(hsv,channels);
// 所有像素的颜色亮度通道将变成255
channels[2]= 255;
// 重新合并通道
cv::merge(channels,hsv);
// 转换回BGR
cv::Mat newImage;
cv::cvtColor(hsv,newImage,CV_HSV2BGR);
颜色用于检测:肤色检测
在搜寻特定颜色的物体时,HSV 色彩空间也是非常实用的。一个例子是肤色检测,检测到的皮肤区域可作为图像中有人存在的标志。手势识别就经常使用肤色检测确定手的位置。
肤色检测领域的大量研究已经表明,来自不同人种的人群的皮肤颜色,可以在色调-饱和度色彩空间中很好地归类。这里,我们将只使用色调和饱和度值来识别肤色。
我们定义了一个基于数值区间(最小和最大色调、最小和最大饱和度)的函数,把图像中的像素分为皮肤和非皮肤两类:
void detectHScolor(const cv::Mat& image, // 输入图像
double minHue, double maxHue, // 色调区间
double minSat, double maxSat, // 饱和度区间
cv::Mat& mask) { // 输出掩码
// 转换到HSV 空间
cv::Mat hsv;
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// 将3 个通道分割到3 幅图像
std::vector<cv::Mat> channels;
cv::split(hsv, channels);
// 色调掩码
cv::Mat mask1; // 小于maxHue
cv::threshold(channels[0], mask1, maxHue, 255, cv::THRESH_BINARY_INV);
cv::Mat mask2; // 大于minHue
cv::threshold(channels[0], mask2, minHue, 255, cv::THRESH_BINARY);
cv::Mat hueMask; // 色调掩码
if (minHue < maxHue)
hueMask = mask1 & mask2;
else // 如果区间穿越0 度中轴线
hueMask = mask1 | mask2;
// 饱和度掩码
// 从minSat 到maxSat
cv::Mat satMask; // 饱和度掩码
cv::inRange(channels[1], minSat, maxSat, satMask);
// 组合掩码
mask = hueMask & satMask;
}
如果在处理时有了大量的皮肤(以及非皮肤)样本,我们就可以使用概率方法估算在皮肤样本中和非皮肤样本中发现指定颜色的可能性。此处,我们依据经验定义了一个合理的色调-0饱和度区间(记住,8 位版本的色调在0180,饱和度在0255):
// 检测肤色
cv::Mat mask;
detectHScolor(image, 160, 10, // 色调为320 度~20 度
25, 166, // 饱和度为~0.1~0.65
mask);
// 显示使用掩码后的图像
cv::Mat detected(image.size(), CV_8UC3, cv::Scalar(0, 0, 0));
image.copyTo(detected, mask);
注意,为了简化,我们在检测时没有考虑颜色的亮度。在实际应用中,排除较高亮度的颜色可以降低把明亮的淡红色误认为皮肤的可能性。显然,要想对皮肤颜色进行可靠和准确的检测,还需要更加精确的分析。对不同的图像进行检测,也很难保证效果都好,因为摄影时影响彩色再现的因素有很多,如白平衡和光照条件等。尽管如此,用这种只使用色调/饱和度信息做初步检测的方法也能得到一个比较令人满意的结果。