OpenCV计算机视觉编程篇三《处理图像的颜色》

前言

前期回顾: OpenCV计算机视觉编程篇二《操作像素》
上面这篇里面写了操作像素相关。

本章包括以下内容:

  • 用策略设计模式比较颜色;
  • 用 GrabCut 算法分割图像;
  • 转换颜色表示法;
  • 用色调、饱和度和亮度表示颜色。

3.1 简介

人类视觉系统的一个重要特征就是能感知颜色。人眼的视网膜中有一种被称作视锥细胞的特 殊感光细胞,专门负责感知各种颜色。视锥细胞分为三种,分别负责不同波长的光线,人脑就是 通过这些细胞产生的信号来识别各种颜色的。大多数动物却只有视杆细胞,它对光线的敏感度更 高,但是覆盖了整个可见光的光谱,无法区分不同的颜色。人眼中的视杆细胞主要分布在视网膜 的边缘,而视锥细胞分布在视网膜的中心。

在数码摄影中,则是用加色法三原色(红、绿、蓝)来构建各种颜色,将它们组合起来可以 产生各种颜色,且色域很宽。实际上,选用这三种颜色也模仿了人类的颜色识别系统——人眼中 不同的视锥细胞分别负责红色、绿色和蓝色附近的光谱。本章将分析像素的颜色,并介绍如何用 颜色信息分割图像。此外,在处理彩色图像时,还可以使用其他的颜色表示法。

3.2 用策略设计模式比较颜色

假设我们要构建一个简单的算法,用来识别图像中具有某种颜色的所有像素。这个算法必须 输入一幅图像和一个颜色,并且返回一个二值图像,显示具有指定颜色的像素。在运行算法前, 还要指定一个参数,即能接受的颜色的公差。

本节将采用策略设计模式来实现这一目标,它是一种面向对象的设计模式,用很巧妙的方法 将算法封装进类。采用这种模式后,可以很轻松地替换算法,或者组合多个算法以实现更复杂的功能。而且这种模式能够尽可能地将算法的复杂性隐藏在一个直观的编程接口后面,更有利于算 法的部署。

3.2.1 如何实现

一旦用策略设计模式把算法封装进类,就可以通过创建类的实例来部署算法,实例通常是在 程序初始化的时候创建的。在运行构造函数时,类的实例会用默认值初始化算法的各种参数,使 其立即进入可用状态。我们还可以用适当的方法来读写算法的参数值。在 GUI 应用程序中,可 以用多种部件(文本框、滑动条等)显示和修改参数,用户操作起来很容易。

下一节将展示一个策略类的结构,这里先看一个部署和使用它的例子。写一个简单的主函数, 调用颜色检测算法:

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();
 return 0;
}

运行这个程序,检测第 2 章用过的彩色城堡图中的蓝天,输出结果如下所示。

在这里插入图片描述

这里的白色像素表示检测到指定的颜色,黑色表示没有检测到。

很明显,封装进这个类的算法相对简单(下面会看到它只是组合了一个扫描循环和一个公差 参数)。当算法的实现过程变得更加复杂、步骤繁多并且包含多个参数时,策略设计模式才会真 正展现出强大的威力。

3.2.2 实现原理

这个算法的核心过程非常简单,只是对每个像素进行循环扫描,把它的颜色和目标颜色做比 较。利用 2.3 节所学,可以这样写这个循环:

// 取得迭代器
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>();
// 对于每个像素
for ( ; it!= itend; ++it, ++itout) {
 // 比较与目标颜色的差距
 if (getDistanceToTargetColor(*it)<=maxDist) {
 *itout= 255;
 } else {
 *itout= 0;
 }
}

cv::Mat 类型的变量 image 表示输入图像,result 表示输出的二值图像。因此要先创建 迭代器,这样扫描循环就很容易实现了。注意,输入图像迭代器定义为常量,它们的元素无法修 改。在每个迭代步骤中计算当前像素的颜色与目标颜色的差距,检查它是否在公差(maxDist) 范围之内。如果是,就在输出图像中赋值 255(白色),否则就赋值 0(黑色)。这里用 getDistance ToTargetColor 方法来计算与目标颜色的差距。

也有其他可以计算这个差距的方法,例如计算包含 RGB 颜色值的三个向量之间的欧几里得 距离。为了简化计算过程,我们把 RGB 值差距的绝对值(也称为城区距离)进行累加。注意, 在现代体系结构中,浮点数的欧几里得距离的计算速度可能比简单的城区距离更快(还可以采用 平方欧氏距离,以避免耗时的平方根运算),在做设计时也要考虑到这点。另外,为了增加灵活 性,我们依据 getColorDistance 方法来编写 getDistanceToTargetColor 方法:

// 计算与目标颜色的差距
int getDistanceToTargetColor(const cv::Vec3b& color) const {
 return getColorDistance(color, 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]);
} 

我们用 cv::Vec3d 存储三个无符号字符型,即颜色的 RGB 值。变量 target 表示指定的目 标颜色,是算法类的成员变量。现在来定义处理方法。用户提供一个输入图像,图像扫描完成后 即返回结果:

cv::Mat ColorDetector::process(const cv::Mat &image) {
 // 必要时重新分配二值映像
 // 与输入图像的尺寸相同,不过是单通道
 result.create(image.size(),CV_8U);
 // 在这里放前面的处理循环
 return result;
} 

在调用这个方法时,一定要检查输出图像(包含二值映像)是否需要重新分配,以匹配输入 图像的尺寸。因此我们使用了 cv::Mat 的 create 方法。注意,只有在指定的尺寸或深度与当 前图像结构不匹配时,它才会进行重新分配。

我们已经定义了核心的处理方法,下面就看一下为了部署该算法,还需要添加哪些额外方法。 前面已经明确了算法需要的输入和输出数据,因此要定义类的属性来存储这些数据:

class ColorDetector {
 private:
 // 允许的最小差距
 int maxDist;
 // 目标颜色
 cv::Vec3b target;
 // 存储二值映像结果的图像
 cv::Mat result; 

要为封装了算法的类(已命名为 ColorDetector)创建实例,就需要定义一个构造函数。 使用策略设计模式的原因之一,就是让算法的部署尽可能简单。最简单的构造函数当然是空函数, 它会创建一个算法类的实例,并处于有效状态。然后在构造函数中初始化全部输入参数,设置为 默认值(或采用通常会带来好结果的值)。这里认为通常能接受的公差参数是 100。我们还需要 设置默认的目标颜色,这里选用黑色(选用黑色没有什么特别的原因),总的原则是要确保输入 值可预测并且有效。

// 空构造函数
// 在此初始化默认参数
ColorDetector() : maxDist(100), target(0,0,0) {}

也可以不使用空的构造函数,而是采用复杂的构造函数,要求用户输入目标颜色和颜色距离:

// 另一种构造函数,使用目标颜色和颜色距离作为参数
ColorDetector(uchar blue, uchar green, uchar red, int mxDist); 

创建该算法类的用户此时可以立即调用处理方法并传入一个有效的图像,然后得到一个有效 的输出。这是策略设计模式的另一个目的,即只要保证参数正确,算法就能正常运行。用户显然 希望使用个性化设置,我们可以用相应的设置方法和获取方法来实现这个功能。首先要实现 color 公差参数的定制:

// 设置颜色差距的阈值
// 阈值必须是正数,否则就设为 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) {
 // 次序为 BGR
 target = cv::Vec3b(blue, green, red);
}
// 设置需要检测的颜色
void setTargetColor(cv::Vec3b color) {
 target= color;
}
// 取得需要检测的颜色
cv::Vec3b getTargetColor() const {
 return target;
} 

这次我们提供了 setTargetColor 方法的两种定义,第一个版本用三个参数表示三个颜色组件,第二个版本用 cv::Vec3b 保存颜色值。再次强调,这么做是为了让算法类更便于使用, 使用户只需要选择最合适的设置函数。

3.2.3 扩展阅读

例子中的算法可识别出图像中与指定目标颜色足够接近的像素。过程中已经完成了计算步骤。有趣的是,OpenCV 中有一个具有类似功能的函数,可以从图像中提取出与特定颜色相关联的部件。另外,我们也可以用函数对象来补充策略设计模式。OpenCV 中定义了一个基类 cv::Algorithm,实现策略设计模式的概念。

  1. 计算两个颜色向量间的距离

要计算两个颜色向量间的距离,可使用这个简单的公式:

return 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]))); 

改用这种方式定义 getDistance 方法后,得到的结果与原来的非常接近。这里之所以使用 cv::Vec3i(三个向量的整型数组),是因为减法运算得到的是整数值。

还有一点非常有趣,回顾一下第 2 章的内容,我们会发现 OpenCV 中矩阵和向量等数据结构 定义了基本的算术运算符。因此,有人会想这样计算距离:

return static_cast<int>( cv::norm<uchar,3>(color-target));// 错误!

这种做法看上去好像是对的,但实际上是错误的,因为为了确保结果在输入数据类型的范围 之内(这里是 uchar),这些运算符通常都调用了 saturate_cast(详情请参见 2.6 节)。因此 在 target 的值比 color 大的时候,结果就会是 0 而不是负数。正确的做法应该是:

cv::Vec3b dist;
cv::absdiff(color,target,dist);
return cv::sum(dist)[0]; 

不过在计算三个数组间距离时调用这两个函数的效率并不高。

  1. 使用 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 函数提取出存放差距的图像的单个通道(详情请参见 2.7.4 节) 以便求和。注意,累加值有可能超过 255,但因为饱和度对值范围有要求,所以最终结果不会超 过 255。这样做的结果,就是这里的 maxDist 参数也必须小于 256。如果你觉得这样不合理, 可以进行修改。

最后一步是用 cv::threshold 函数创建一个二值图像。这个函数通常用于将所有像素与某 个阈值(第三个参数)进行比较,并且在常规阈值化模式(cv::THRESH_BINARY)下,将所有 大于指定阈值的像素赋值为预定的最大值(第四个参数),将其他像素赋值为 0。这里使用相反 的模式(cv::THRESH_BINARY_INV)把小于或等于阈值的像素赋值为预定的最大值。此外还 有 cv::THRESH_TOZERO 和 cv::THRESH_TOZERO_INV 模式,它们使大于或小于阈值的像素保持 不变。

一般来说,最好直接使用 OpenCV 函数。它可以快速建立复杂程序,减少潜在的错误,而且 程序的运行效率通常也比较高(得益于 OpenCV 项目参与者做的优化工作)。不过这样会执行很 多的中间步骤,消耗更多内存。

  1. 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)所处的位置是天空。函数会检查所有的相邻像素,颜色接近的像素会 被重绘成第三个参数指定的新颜色。为了判断颜色是否接近,需要分别定义比参考色更高或更低 的值作为阈值。这里使用固定范围模式,即所有像素都与亚像素的颜色进行对比,默认模式是将 每个像素与和它邻近的像素进行对比。得到的结果如下图所示。

在这里插入图片描述

这种算法重绘了一个独立的连续区域(这里是把天空画成白色)。即使其他地方有颜色接近 的像素(例如水面),除非它们与天空相连,否则也不会被识别出来。

  1. 仿函数或函数对象

利用 C++的操作符重载功能,我们可以让类的实例表现得像函数。它的原理是重载 operator()方法,让调用类的处理方法就像调用纯粹的函数一样。这种类的实例被称为函数对 象或者仿函数(functor)。一个仿函数通常包含一个完整的构造函数,因此能够在创建后立即使 用。例如,可以在 ColorDetector 类中添加完整的构造函数:

// 完整的构造函数
ColorDetector(uchar blue, uchar green, uchar red, int maxDist=100):
 maxDist(maxDist) { 
  // 目标颜色
 setTargetColor(blue, green, red);
} 

很显然,前面定义的获取方法和设置方法仍然可以使用。可以这样定义仿函数方法:

cv::Mat operator()(const cv::Mat &image) {
 // 这里放检测颜色的代码
} 

若想用仿函数方法检测指定的颜色,只需要用这样的代码片段:

ColorDetector colordetector(230,190,130, // 颜色
 100); // 阈值
cv::Mat result= colordetector(image); // 调用仿函数

可以看到,这里对颜色检测方法的调用类似于对某个函数的调用。

  1. OpenCV 的算法基类

为实现计算机视觉的各项功能,OpenCV 提供了很多算法。为方便使用,大多数算法都被封 装成了通用基类 cv::Algorithm 的子类。这体现了策略设计模式的一些概念。首先,所有算法 都在专门的静态方法中动态地创建,以确保创建的算法总是有效的(即每个缺少的参数都有有效 的默认值)。来看一个例子,即它的其中一个子类 cv::ORB(用于兴趣点运算,详情请参见 8.5 节)。这里只把它作为一个算法示例。

用下面的方法创建一个算法实例:

cv::Ptr<cv::ORB> ptrORB = cv::ORB::create(); // 默认状态

算法一旦创建完毕,就可以开始使用,例如通用方法 read 和 write 可用于装载或存储算 法的状态值。算法也有一些专用方法(例如 ORB 的方法 detect 和 compute 用于触发它的主体 计算单元),也有专门用来设置内部参数的设置方法。需要注意的是,你可以把指针类型定为 cv::Ptr,但那样就无法使用它的专用方法了。

3.3 用 GrabCut 算法分割图像

上一节介绍了如何利用颜色信息,根据场景中的特定元素分割图像。物体通常有自己特有的 颜色,通过识别颜色接近的区域,通常可以提取出这些颜色。OpenCV 提供了一种常用的图像分割算法,即 GrabCut 算法。GrabCut 算法比较复杂,计算量也很大,但结果通常很精确。如果要 从静态图像中提取前景物体(例如从图像中剪切一个物体,并粘贴到另一幅图像),最好采用 GrabCut 算法。

3.3.1 如何实现

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 标志作为最后一个参数,表示将使用带边框的矩形模型(3.3.2 节会讨论其他模式)。输入/输出的分割图像可以是以下四个值之一。

  • 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 像素),所 以得到的结果是一样的。

得到的图像如下所示。

在这里插入图片描述

3.3.2 实现原理

在前面的例子中,只需要指定一个包含前景物体(城堡)的矩形,GrabCut 算法就能提取出 它。此外,还可以把输入图像中的几个特定像素赋值为 cv::GC_BGD 和 cv::GC_FGD,以掩码 图像的形式提供这些值,作为 cv::grabCut 函数的第二个参数。同时要把输入模式标志指定为 GC_INIT_WITH_MASK。获得这些输入标签的方法有很多种,例如可以提示用户在图像中交互式 地标记一些元素。当然,将这两种输入模式结合使用也未尝不可。

利用输入信息,GrabCut 算法通过以下步骤进行背景/前景分割。首先,把所有未标记的像素 临时标为前景(cv::GC_PR_FGD)。基于当前的分类情况,算法把像素划分为多个颜色相似的组 (即 K 个背景组和 K 个前景组)。下一步是通过引入前景和背景像素之间的边缘,确定背景/前景 的分割,这将通过一个优化过程来实现。在此过程中,将试图连接具有相似标记的像素,并且避 免边缘出现在强度相对均匀的区域。使用 Graph Cuts 算法可以高效地解决这个优化问题,它寻找 最优解决方案的方法是:把问题表示成一幅连通的图形,然后在图形上进行切割,以形成最优的 形态。分割完成后,像素会有新的标记。然后重复这个分组过程,找到新的最优分割方案,如此 反复。因此,GrabCut 算法是一个逐步改进分割结果的迭代过程。根据场景的复杂程度,找到最 佳方案所需的迭代次数各不相同(如果情况简单,迭代一次就足够了)。

这解释了函数中用来表示迭代次数的参数。结合代码看,原意应该是:先把参数传递给函数, 函数返回时会修改参数的值。因此,如果希望通过执行额外的迭代过程来改进分割结果,可以在 调用函数时重复使用上次运行的模型。

3.4 转换颜色表示法

RGB 色彩空间的基础是对加色法三原色(红、绿、蓝)的应用。本章最开始就说过,选用 这三种颜色作为三原色,是因为将它们组合后可以产生色域很宽的各种颜色,与人类视觉系统对 应。这通常是数字成像中默认的色彩空间,因为这就是用红绿蓝三种滤波器生成彩色图像的方式。 红绿蓝三个通道还要做归一化处理,当三种颜色强度相同时就会取得灰度,即从黑色(0, 0, 0)到白 色(255, 255, 255)。

但利用 RGB 色彩空间计算颜色之间的差距并不是衡量两个颜色相似度的最好方式。实际上,RGB 并不是感知均匀的色彩空间。也就是说,两种具有一定差距的颜色可能看起来非常接近, 而另外两种具有同样差距的颜色看起来却差别很大。

为解决这个问题,引入了一些具有感知均匀特性的颜色表示法。CIE Lab*就是一种这样的 颜色模型。把图像转换到这种表示法后,我们就可以真正地使用图像像素与目标颜色之间的欧几 里得距离,来度量颜色之间的视觉相似度。本节将介绍如何转换颜色表示法,以便使用其他色彩空间。

3.4.1 如何实现

使用 OpenCV 的函数 cv::cvtColor 可以轻松转换图像的色彩空间。回顾一下 3.2 节提到的 ColorDetector 类。在 process 方法中先把输入图像转换成 CIE Lab*色彩空间:

cv::Mat ColorDetector::process(const cv::Mat &image) {
 // 必要时重新分配二值图像
 // 与输入图像的尺寸相同,但用单通道
 result.create(image.rows,image.cols,CV_8U);
 // 转换成 Lab 色彩空间
 cv::cvtColor(image, converted, CV_BGR2Lab);
 // 取得转换图像的迭代器
 cv::Mat_<cv::Vec3b>::iterator it= converted.begin<cv::Vec3b>();
 cv::Mat_<cv::Vec3b>::iterator itend= converted.end<cv::Vec3b>();
 // 取得输出图像的迭代器
 cv::Mat_<uchar>::iterator itout= result.begin<uchar>();
 // 针对每个像素
 for ( ; it!= itend; ++it, ++itout) { 

转换后的变量包含颜色转换后的图像,被定义为类 ColorDetector 的一个属性:

class ColorDetector {
 private:
 // 颜色转换后的图像
 cv::Mat converted;

输入的目标颜色也需要进行转换——通过创建一个只有单个像素的临时图像,可以实现这种 转换。注意,需要让函数保持与前面几节一样的签名,即用户提供的目标颜色仍然是 RGB 格式:

// 设置需要检测的颜色
void setTargetColor(unsigned char red, unsigned char green,
 unsigned char blue) {
 // 临时的单像素图像
 cv::Mat tmp(1,1,CV_8UC3);
 tmp.at<cv::Vec3b>(0,0)= cv::Vec3b(blue, green, red); 
  // 将目标颜色转换成 Lab 色彩空间
 cv::cvtColor(tmp, tmp, CV_BGR2Lab);
 target= tmp.at<cv::Vec3b>(0,0);
} 

如果在上一节的程序中使用这个修改过的类,它就会在检测符合目标颜色的像素时,使用 CIE Lab*颜色模型。

3.4.2 实现原理

在将图像从一个色彩空间转换到另一个色彩空间时,会在每个输入像素上做一个线性或非线 性的转换,以得到输出像素。输出图像的像素类型与输入图像是一致的。即使你经常使用 8 位像 素,也可以用浮点数图像(通常假定像素值的范围是 0~1.0)或整数图像(像素值范围通常是 0~65 535)进行颜色转换。但是,实际的像素值范围取决于指定的色彩空间和目标图像的类型。 比如说 CIE Lab*色彩空间中的 L 通道表示每个像素的亮度,范围是 0~100;在使用 8 位图像时, 它的范围就会调整为 0~255。a 通道和 b 通道表示色度组件,这些通道包含了像素的颜色信息, 与亮度无关。它们的值的范围是127~127;对于 8 位图像,为了适应 0~255 的区间,每个值会加 上 128。但是要注意,进行 8 位颜色转换时会产生舍入误差,因此转换过程并不是完全可逆的。

大多数常用的色彩空间都是可以转换的。你只需要在 OpenCV 函数中指定正确的色彩空间转 换代码(CIE Lab*的代码为 CV_BGR2Lab),其中就有 YCrCb,它是在 JPEG 压缩中使用的色 彩空间。把色彩空间从 BGR 转换成 YCrCb 的代码为 CV_BGR2YCrCb。注意,所有涉及三原色(红、 绿、蓝)的转换过程都可以用 RGB 和 BGR 的次序。

CIE Luv是另一种感知均匀的色彩空间。若想从 BGR 转换成 CIE Luv,可使用代码 CV_BGR2Luv。Lab和 Luv对亮度通道使用同样的转换公式,但对色度通道则使用不同的表 示法。另外,为了实现视觉感知上的均匀,这两种色彩空间都扭曲了 RGB 的颜色范围,所以这 些转换过程都是非线性的(因此计算量巨大)。

此外还有 CIE XYZ 色彩空间(用代码 CV_BGR2XYZ 表示)。它是一种标准色彩空间,用与设 备无关的方式表示任何可见颜色。在 Lab和 Luv色彩空间的计算中,用 XYZ 色彩空间作 为一种中间表示法。RGB 与 XYZ 之间的转换是线性的。还有一点非常有趣,就是 Y 通道对应着 图像的灰度版本。

HSV 和 HLS 这两种色彩空间很有意思,它们把颜色分解成加值的色调和饱和度组件或亮度 组件。人们用这种方式来描述的颜色会更加自然。下一节将介绍这种色彩空间。

你可以把彩色图像转换成灰度图像,输出是一个单通道图像:

cv::cvtColor(color, gray, CV_BGR2Gray); 

也可以进行反向的转换,但是那样得到的彩色图像的三个通道是相同的,都是灰度图像中对 应的值。

3.4.3 参阅

  • 4.6 节将使用 HSV 色彩空间来寻找图像中的目标。
  • 关于色彩空间理论的参考资料有很多,其中有一套完整的资料:The Structure and Properties of Color Spaces and the Representation of Color Images(E. Dubois 著,Morgan & Claypool, 2009 年出版)。

3.5 用色调、饱和度和亮度表示颜色

本章处理了图像的颜色,使用了不同的色彩空间,并且设法识别出图像中具有均匀颜色的区 域。RGB 是一种被广泛接受的色彩空间。虽然它被视为一种在电子成像系统中采集和显示颜色 的有效方法,但它其实并不直观,也并不符合人类对于颜色的感知方式——我们更习惯用色彩、 亮度或彩度(即表示该颜色是鲜艳的还是柔和的)来描述颜色。为了能让用户用更直观的属性描 述颜色,我们引入了基于色调、饱和度和亮度的色彩空间。本节将把色调、饱和度和亮度作为描 述颜色的方法,并对这些概念加以探讨。

3.5.1 如何实现

上一节讲过,可用 cv::cvtColor 函数把 BGR 图像转换成另一种色彩空间。这里使用转换 代码 CV_BGR2HSV:

// 转换成 HSV 色彩空间
cv::Mat hsv;
cv::cvtColor(image, hsv, CV_BGR2HSV); 

我们可以用代码 CV_HSV2BGR 把图像转换回 BGR 色彩空间。通过把图像的通道分割到三个 独立的图像中,我们可以直观地看到每一种 HSV 组件,方法如下所示:

// 把 3 个通道分割进 3 幅图像中
std::vector<cv::Mat> channels;
cv::split(hsv,channels);
// channels[0]是色调
// channels[1]是饱和度
// channels[2]是亮度

注意第三个通道表示颜色值,即颜色亮度的近似值。因为处理的是 8 位图像,所以 OpenCV 会把通道值的范围重新调节为 0~255(色调除外,它的范围被调节为 0~180,下节会解释原因)。 这个方法非常实用,因为我们可以把这几个通道作为灰度图像进行显示。

城堡图的亮度通道显示如下

在这里插入图片描述

该图像的饱和度通道显示如下。

在这里插入图片描述

最后是该图像的色调通道。

在这里插入图片描述

下一节会对这几幅图像进行解释。

3.5.2 实现原理

之所以要引入色调/饱和度/亮度的色彩空间概念,是因为人们喜欢凭直觉分辨各种颜色,而 它与这种方式吻合。实际上,人类更喜欢用色彩、彩度、亮度等直观的属性来描述颜色,而大多 数直觉色彩空间正是基于这三个属性。色调(hue)表示主色,我们使用的颜色名称(例如绿色、 黄色和红色)就对应了不同的色调值;饱和度(saturation)表示颜色的鲜艳程度,柔和的颜色饱 和度较低,而彩虹的颜色饱和度就很高;最后,亮度(brightness)是一个主观的属性,表示某种 颜色的光亮程度。其他直觉色彩空间使用颜色明度(value)或颜色亮度(lightness)的概念描述 有关颜色的强度。

利用这些颜色概念,能尽可能地模拟人类对颜色的直观感知。因此,它们没有标准的定义。 根据文献资料,色调、饱和度和亮度都有多种不同的定义和计算公式。OpenCV 建议的两种直觉 色彩空间的实现是 HSV 和 HLS 色彩空间,它们的转换公式略有不同,但是结果非常相似。

亮度成分可能是最容易解释的。在 OpenCV 对 HSV 的实现中,它被定义为三个 BGR 成分中 的最大值,以非常简化的方式实现了亮度的概念。为了让定义更符合人类视觉系统,应该使用均 匀感知的色彩空间 Lab和 Luv的 L 通道。举个例子,L 通道已经考虑到了,在强度相同的 情况下,人们会觉得绿色比蓝色等颜色的亮度更高。

OpenCV 用一个公式来计算饱和度,该公式基于 BGR 组件的最小值和最大值:

在这里插入图片描述

其原理是:灰度颜色包含的 R、G、B 的成分是相等的,相当于一种极不饱和的颜色,因此 它的饱和度是 0(饱和度是一个 0~1.0 的值)。对于 8 位图像,饱和度被调节成一个 0~255 的值, 并且作为灰度图像显示的时候,较亮区域对应的颜色具有较高的饱和度。

举个例子,在前面的饱和度图片中,水的蓝色比天空的柔和浅蓝色的饱和度高,这和我们的 推断是一致的。根据定义,各种灰色阴影的饱和度都是 0(因为它们的三种 BGR 组件是相等的)。 从城堡的屋顶能看到这种现象,因为屋顶是由深灰色石头砌成的。最后,你还会在饱和度图像中 看到一些白色的斑点,它们对应着原始图像中非常暗的区域。这是由饱和度的定义引起的——饱 和度只计算 BGR 中最大值和最小值的相对差距,因此像 (1, 0, 0) 这样的组合就会得到饱和度 1.0, 尽管这个颜色看起来是黑的。因此,在黑色区域中计算得到的饱和度是不可靠的,没有参考价值。

颜色的色调通常用 0~360 的角度来表示,其中红色是 0 度。对于 8 位图像,OpenCV 把角度 除以 2,以适合单字节的存储范围。因此,每个色调值对应指定颜色的色彩,与亮度和饱和度无 关。例如天空和水的色调是一样的,都约为 200 度(强度 100),对应色度为蓝色;背景树林的 色调约为 90 度,对应色度为绿色。有一点要特别注意,如果颜色的饱和度很低,它计算出来的 色调就不可靠。

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); 

得到的结果如下图所示,看起来像是一幅绘画作品。

在这里插入图片描述

3.5.3 拓展阅读

在搜寻特定颜色的物体时,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_BGR2HSV);
 // 将 3 个通道分割到 3 幅图像
 std::vector<cv::Mat> channels;
 cv::split(hsv, channels);
 // channels[0]是色调
 // channels[1]是饱和度
 // channels[2]是亮度
  // 色调掩码
 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;
} 

如果在处理时有了大量的皮肤(以及非皮肤)样本,我们就可以使用概率方法估算在皮肤样 本中和非皮肤样本中发现指定颜色的可能性。此处,我们依据经验定义了一个合理的色调饱和 度区间,用于这里的测试图像(记住,8 位版本的色调在 0~180,饱和度在 0~255):

// 检测肤色
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); 

得到下面的检测图像。

在这里插入图片描述

注意,为了简化,我们在检测时没有考虑颜色的亮度。在实际应用中,排除较高亮度的颜色 可以降低把明亮的淡红色误认为皮肤的可能性。显然,要想对皮肤颜色进行可靠和准确的检测, 还需要更加精确的分析。对不同的图像进行检测,也很难保证效果都好,因为摄影时影响彩色再 现的因素有很多,如白平衡和光照条件等。尽管如此,用这种只使用色调/饱和度信息做初步检 测的方法也能得到一个比较令人满意的结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

虚坏叔叔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值