本章包括以下内容:
- 计算图像直方图;
- 利用查找表修改图像外观;
- 直方图均衡化;
- 反向投影直方图检测特定图像内容;
- 用均值平移算法查找目标;
- 比较直方图搜索相似图像;
- 用积分图像统计像素。
4.2 计算图像直方图
要在OpenCV 中计算直方图,可简单地调用cv::calcHist 函数。这是一个通用的直方图计算函数,可处理包含任何值类型和范围的多通道图像。
为了简化,这里指定一个专门用于处理单通道灰度图像的类。
这个专用类的初始化代码为:
class Histogram1D {
private:
int histSize[1]; // 直方图中箱子的数量
float hranges[2]; // 值范围
const float* ranges[1]; // 值范围的指针
int channels[1]; // 要检查的通道数量
public:
Histogram1D() { // 准备一维直方图的默认参数
histSize[0] = 256; // 256 个箱子
hranges[0] = 0.0; // 从0 开始(含)
hranges[1] = 256.0; // 到256(不含)
ranges[0] = hranges;
channels[0] = 0; // 先关注通道0
}
定义好成员变量后,就可以用下面的方法计算灰度直方图了:
// 计算一维直方图
cv::Mat getHistogram(const cv::Mat& image) {
cv::Mat hist;
// 用calcHist 函数计算一维直方图
cv::calcHist(&image, 1, // 仅为一幅图像的直方图
channels, // 使用的通道
cv::Mat(), // 不使用掩码
hist, // 作为结果的直方图
1, // 这是一维的直方图
histSize, // 箱子数量
ranges // 像素值的范围
);
return hist;
}
程序只需要打开一幅图像,创建一个Histogram1D 实例,然后调用getHistogram 方法即可:
// 读取输入的图像
cv::Mat image = cv::imread("group.jpg", 0); // 以黑白方式打开
// 直方图对象
Histogram1D h;
// 计算直方图
cv::Mat histo = h.getHistogram(image);
这里的histo 对象是一个一维数组,包含256 个项目。因此只需遍历这个数组,就可以读取每个箱子:
for (int i=0; i<256; i++)
cout << "Value " << i << " = "
<<histo.at<float>(i) << endl;
比较实用的做法是以函数的方式显示直方图,例如用柱状图。用下面这几种方法可创建这种图形:
cv::Mat getHistogramImage(const cv::Mat& image, int zoom = 1) {
// 先计算直方图
cv::Mat hist = getHistogram(image);
// 创建图像
return getImageOfHistogram(hist, zoom);
}
// 创建一个表示直方图的图像(静态方法)
static cv::Mat getImageOfHistogram(const cv::Mat& hist, int zoom) {
// 取得箱子值的最大值和最小值
double maxVal = 0;
double minVal = 0;
cv::minMaxLoc(hist, &minVal, &maxVal, 0, 0);
// 取得直方图的大小
int histSize = hist.rows;
// 用于显示直方图的方形图像
cv::Mat histImg(histSize * zoom, histSize * zoom,
CV_8U, cv::Scalar(255));
// 设置最高点为90%(即图像高度)的箱子个数
int hpt = static_cast<int>(0.9 * histSize);
// 为每个箱子画垂直线
for (int h = 0; h < histSize; h++) {
float binVal = hist.at<float>(h);
if (binVal > 0) {
int intensity = static_cast<int>(binVal * hpt / maxVal);
cv::line(histImg, cv::Point(h * zoom, histSize * zoom),
cv::Point(h * zoom, (histSize - intensity) * zoom),
cv::Scalar(0), zoom);
}
}
return histImg;
}
使用getImageOfHistogram 方法可以得到直方图图像。它用线条画成,以柱状图形式展现:
// 以图像形式显示直方图
cv::namedWindow("Histogram");
cv::imshow("Histogram",h.getHistogramImage(image));
得到的结果如下图所示。
从上面图形化的直方图可以看出,在中等灰度值处有一个大的尖峰,并且比中等值更黑的像素有很多。巧的是,这两部分像素分别对应了图像的背景和前景。要验证这点,可以在这两部分的汇合处进行阈值化处理。
OpenCV 中的cv::threshold 函数可以实现这个功能。。我们取直方图中在升高为尖峰之前的最小值的位置(灰度值为70),对其进行阈值化处理,得到二值图像:
cv::Mat thresholded; // 输出二值图像
cv::threshold(image, thresholded, 70, // 阈值
255, // 对超过阈值的像素赋值
cv::THRESH_BINARY); // 阈值化类型
cv::calcHist
为了适应各种场景,cv::calcHist 函数带有很多参数:
void calcHist(const Mat* images, // 源图像
int nimages, // 源图像的个数(通常为1)
const int* channels, // 列出通道
InputArray mask, // 输入掩码(需处理的像素)
OutputArray hist, // 输出直方图
int dims, // 直方图的维度(通道数量)
const int* histSize, // 每个维度位数
const float** ranges, // 每个维度的范围
bool uniform = true, // true 表示箱子间距相同
bool accumulate = false) // 是否在多次调用时进行累加
计算彩色图像的直方图
我们可以用同一个cv::calcHist 函数计算多通道图像的直方图。例如,若想计算彩色BGR图像的直方图,可以这样定义一个类:
class ColorHistogram {
private:
int histSize[3]; // 每个维度的大小
float hranges[2]; // 值的范围(三个维度用同一个值)
const float* ranges[3]; // 每个维度的范围
int channels[3]; // 需要处理的通道
public:
ColorHistogram() {
// 准备用于彩色图像的默认参数
// 每个维度的大小和范围是相等的
histSize[0] = histSize[1] = histSize[2] = 256;
hranges[0] = 0.0; // BGR 范围为0~256
hranges[1] = 256.0;
ranges[0] = hranges; // 这个类中
ranges[1] = hranges; // 所有通道的范围都相等
ranges[2] = hranges;
channels[0] = 0; // 三个通道:B
channels[1] = 1; // G
channels[2] = 2; // R
}
准备好参数后,就可以用下面的方法计算颜色直方图了:
// 计算直方图
cv::Mat getHistogram(const cv::Mat& image) {
cv::Mat hist;
// 计算直方图
cv::calcHist(&image, 1, // 单幅图像的直方图
channels, // 用到的通道
cv::Mat(), // 不使用掩码
hist, // 得到的直方图
3, // 这是一个三维直方图
histSize, // 箱子数量
ranges // 像素值的范围
);
return hist;
}
上述方法返回一个三维的cv::Mat 实例。如果选用含有256 个箱子的直方图,这个矩阵就有(256)^3 个元素,表示超过1600 万个项目。在很多应用程序中,最好在计算直方图时减少箱子的数量。
也可以使用数据结构cv::SparseMat 表示大型稀疏矩阵(即非零元素非常稀少的矩阵),这样不会消耗过多的内存。
// 计算直方图
cv::SparseMat getSparseHistogram(const cv::Mat& image) {
cv::SparseMat hist(3, // 维数
histSize, // 每个维度的大小
CV_32F);
// 计算直方图
cv::calcHist(&image, 1, // 单幅图像的直方图
channels, // 用到的通道
cv::Mat(), // 不使用掩码
hist, // 得到的直方图
3, // 这是三维直方图
histSize, // 箱子数量
ranges // 像素值的范围
);
return hist;
}
4.3 利用查找表修改图像外观
本节将解释如何用一个简单的映射函数(称为查找表)来修改图像的像素值。我们即将看到,查找表通常根据直方分布图生成。
查找表是个一对一(或多对一)的函数,定义了如何把像素值转换成新的值。它是一个一维数组,对于规则的灰度图像,它包含256 个项目。利用查找表的项目i,可得到对应灰度级的新强度值,如下所示:
newIntensity= lookup[oldIntensity];
OpenCV 中的cv::LUT 函数在图像上应用查找表生成一个新的图像。查找表通常根据直方图生成,因此在Histogram1D 类中加入了这个函数:
static cv::Mat applyLookUp(const cv::Mat& image, // 输入图像
const cv::Mat& lookup) {// uchar 类型的1×256 数组
// 输出图像
cv::Mat result;
// 应用查找表
cv::LUT(image, lookup, result);
return result;
}
下面是一个简单的转换过程:
// 创建一个图像反转的查找表
cv::Mat lut(1, 256, CV_8U); // 256×1 矩阵
for (int i = 0; i < 256; i++) {
// 0 变成255、1 变成254,以此类推
lut.at<uchar>(i) = 255 - i;
}
这个转换过程对像素强度进行了简单的反转,即强度0 变成255、1 变成254、最后255 变成0。对图像应用这种查找表后,会生成原始图像的反向图像。得到的结果如下所示。
伸展直方图以提高图像对比度
定义一个修改原始图像直方图的查找表可以提高图像的对比度。例如,观察4.2 节的图像直方图可以发现,图中根本没有大于200 的像素值。我们可以通过伸展直方图来生成一个对比度更高的图像。为此要使用一个百分比阈值,表示伸展后图像的最小强度值(0)和最大强度值(255)像素的百分比。
我们必须在强度值中找到最小值(imin)和最大值(imax),使得所要求的最小的像素数量高于阈值指定的百分比。这可以用以下几个循环(其中hist 是计算得到的一维直方图)实现:
// 像素的百分比
float number = image.total() * percentile;
// 找到直方图的左极限
int imin = 0;
for (float count = 0.0; imin < 256; imin++) {
// 小于或等于imin 的像素数量必须>number
if ((count += hist.at<float>(imin)) >= number)
break;
}
// 找到直方图的右极限
int imax = 255;
for (float count = 0.0; imax >= 0; imax--) {
// 大于或等于imax 的像素数量必须> number
if ((count += hist.at<float>(imax)) >= number)
break;
}
然后重新映射强度值,使imin 的值变成强度值0,imax 的值变成强度值255。两者之间的 i 进行线性映射:
255.0*(i-imin)/(imax-imin);
伸展1%后的图像如下所示。
伸展过的直方图如下所示。
在彩色图像上应用查找表
第2 章定义了一个减色函数,通过修改图像中的BGR 值减少可能的颜色数量。当时的实现方法是循环遍历图像中的像素,并对每个像素应用减色函数。实际上,更高效的做法是预先计算好所有的减色值,然后用查找表修改每个像素。利用本节的方法,这很容易实现。下面是新的减色函数:
void colorReduce(cv::Mat& image, int div = 64) {
// 创建一维查找表
cv::Mat lookup(1, 256, CV_8U);
// 定义减色查找表的值
for (int i = 0; i < 256; i++)
lookup.at<uchar>(i) = i / div * div + div / 2;
// 对每个通道应用查找表
cv::LUT(image, lookup, image);
}
这种减色方案之所以能起作用,是因为在多通道图像上应用一维查找表时,同一个查找表会独立地应用在所有通道上。如果查找表超过一个维度,那么它和所用图像的通道数必须相同。
4.4 直方图均衡化
OpenCV 提供了一个易用的函数,用于直方图均衡化处理。这个函数的调用方式为:
cv::equalizeHist(image,result);
对图像应用该函数后,得到的结果如下所示。
均衡化后图像的直方图如下所示。
在一个完全均衡的直方图中,所有箱子所包含的像素数量是相等的。这意味着50%像素的强度值小于128(强度中值),25%像素的强度值小于64,以此类推。这个现象可以用一条规则来表示:p%像素的强度值必须小于或等于255*p%。这条规则用于直方图均衡化处理,表示强度值i 的映像对应强度值小于i 的像素所占的百分比。因此可以用下面的语句构建所需的查找表:
lookup.at<uchar>(i)= static_cast<uchar>(255.0*p[i]/image.total());
一般来说,直方图均衡化会大大改进图像外观,但是改进的效果会因图像可视内容的不同而不同。
4.5 反向投影直方图检测特定图像内容
如果图像的某个区域含有特定的纹理或物体,这个区域的直方图就可以看作一个函数,该函数返回某个像素属于这个特殊纹理或物体的概率。本节将介绍如何运用直方图反向投影的概念方便地检测特定的图像内容。
假设你希望在某幅图像中检测出特定的内容(例如检测出下图中天上的云彩),首先要做的就是选择一个包含所需样本的感兴趣区域。下图中的该区域就在矩形内部。
cv::rectangle(image, cv::Rect(216, 33, 24, 30), cv::Scalar(0, 255, 0));
在程序中用下面的方法可以得到这个感兴趣区域:
cv::Mat imageROI;
imageROI= image(cv::Rect(216,33,24,30)); // 云彩区域
接着提取该ROI 的直方图。
Histogram1D h;
cv::Mat hist= h.getHistogram(imageROI);
通过归一化直方图,我们可得到一个函数,由此可得到特定强度值的像素属于这个区域的概率:
cv::normalize(histogram,histogram,1.0);
反向投影直方图的过程包括:从归一化后的直方图中读取概率值并把输入图像中的每个像素替换成与之对应的概率值。OpenCV 中有一个函数可完成此任务:
cv::calcBackProject(&image,
1, // 一幅图像
channels, // 用到的通道,取决于直方图的维度
histogram, // 需要反向投影的直方图
result, // 反向投影得到的结果
ranges, // 值的范围
255.0 // 选用的换算系数
// 把概率值从1 映射到255
);
得到的结果就是下面的概率分布图。为提高可读性,对图像做了反色处理,属于该区域的概率从亮(低概率)到暗(高概率),如下所示。
如果对此图做阈值化处理,就能得到最有可能是“云彩”的像素:
cv::threshold(result, result, 30, 255, cv::THRESH_BINARY);
前面的结果并不令人满意。因为除了云彩,其他区域也被错误地检测到了。这个概率函数是从一个简单的灰度直方图提取的,理解这点很重要。很多其他像素的强度值与云彩像素的强度值是相同的,在对直方图进行反向投影时会用相同的概率值替换具有相同强度值的像素。
有一种方案可提高检测效果,那就是使用色彩信息。要实现这点,需改变对cv::calBackProject 的调用方式。
反向映射颜色直方图
多维度直方图也可以在图像上进行反向映射。我们定义一个封装反向映射过程的类,首先定义必需的参数并初始化:
class ContentFinder {
private:
// 直方图参数
float hranges[2];
const float* ranges[3];
int channels[3];
float threshold; // 判断阈值
cv::Mat histogram; // 输入直方图
public:
ContentFinder() : threshold(0.1f) {
// 本类中所有通道的范围相同
ranges[0] = hranges;
ranges[1] = hranges;
ranges[2] = hranges;
}
这里引入了一个阈值参数,用于创建显示检测结果的二值分布图。如果这个参数设为负数,就会返回原始的概率分布图。输入的直方图用下面的方法归一化(但这不是必须的):
void setHistogram(const cv::Mat& h) {
histogram = h;
cv::normalize(histogram, histogram, 1.0);
}
方法find 可以进行反向投影。它有两个版本,一个使用图像的三个通道,并调用通用版本的方法:
cv::Mat find(const cv::Mat& image) {
cv::Mat result;
hranges[0] = 0.0; // 默认范围 [0,256]
hranges[1] = 256.0;
channels[0] = 0; // 三个通道
channels[1] = 1;
channels[2] = 2;
return find(image, hranges[0], hranges[1], channels);
}
// 查找属于直方图的像素
cv::Mat find(const cv::Mat& image, float minValue, float maxValue, int* channels) {
cv::Mat result;
hranges[0] = minValue;
hranges[1] = maxValue;
// 直方图的维度数与通道列表一致
for (int i = 0; i < histogram.dims; i++)
this->channels[i] = channels[i];
cv::calcBackProject(&image, 1, // 只使用一幅图像
channels, // 通道
histogram, // 直方图
result, // 反向投影的图像
ranges, // 每个维度的值范围
255.0 // 选用的换算系数
// 把概率值从1 映射到255
);
}
// 对反向投影结果做阈值化,得到二值图像
if (threshold > 0.0)
cv::threshold(result, result, 255.0 * threshold,
255.0, cv::THRESH_BINARY);
return result;
}
现在把前面用过的图像换成彩色版本(访问本书的网站查看彩色图像),并使用一个BGR 直方图。这次来检测天空区域。首先装载彩色图像,定义ROI,然后计算经过缩减的色彩空间上的3D 直方图,代码如下所示:
// 装载彩色图像
ColorHistogram hc;
cv::Mat image = cv::imread("H:\\opencv_c++\\waves.jpg");
if (image.empty())
return 0;
cv::Mat imageROI;
// 提取ROI
imageROI = image(cv::Rect(0, 0, 100, 45)); // 蓝色天空区域
// 取得3D 颜色直方图(每个通道含8 个箱子)
hc.setSize(8); // 8×8×8
cv::Mat shist = hc.getHistogram(imageROI);
下一步是计算直方图,并用find 方法检测图像中的天空区域:
// 创建内容搜寻器
ContentFinder finder;
// 设置用来反向投影的直方图
finder.setHistogram(shist);
finder.setThreshold(0.05f);
// 取得颜色直方图的反向投影
cv::Mat result = finder.find(image);
彩色图像的检测结果如下所示。
通常来说,采用BGR 色彩空间识别图像中的彩色物体并不是最好的方法。为了提高可靠性,我们在计算直方图之前减少了颜色的数量(要知道原始BGR 色彩空间有超过1600 万种颜色)。提取的直方图代表了天空区域的典型颜色分布情况。用它在其他图像上反向投影,也能检测到天空区域。注意,用多个天空图像构建直方图可以提高检测的准确性。
4.6 用均值平移算法查找目标
直方图反向投影的结果是一个概率分布图,表示一个指定图像片段出现在特定位置的概率。
如果我们已经知道图像中某个物体的大致位置,就可以用概率分布图找到物体的准确位置。窗口中概率最大的位置就是物体最可能出现的位置。因此,我们可以从一个初始位置开始,在周围反复移动以提高局部匹配概率,也许就能找到物体的准确位置。这个实现方法称为均值平移算法。
假设我们已经识别出一个感兴趣的物体(例如狒狒的脸),如下图所示:
这次采用HSV 色彩空间的色调通道来描述物体。这意味着需要把图像转换成HSV 色彩空间并提取色调通道,然后计算指定ROI 的一维色调直方图。参见以下代码:
// 读取参考图像
cv::Mat image= cv::imread("baboon01.jpg");
// 狒狒脸部的ROI
cv::Rect rect(110, 45, 35, 45);
cv::Mat imageROI= image(rect);
// 得到狒狒脸部的直方图
int minSat=65;
ColorHistogram hc;
cv::Mat colorhist= hc.getHueHistogram(imageROI,minSat);
我们在ColorHistogram 类中增加了一个简便的方法来获得色调直方图,代码如下所示:
cv::Mat getHueHistogram(const cv::Mat& image,
int minSaturation = 0) {
cv::Mat hist;
// 转换成HSV 色彩空间
cv::Mat hsv;
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// 掩码(可能用到,也可能用不到)
cv::Mat mask;
// creating the mask if required
if (minSaturation > 0) {
// 将3 个通道分割进3 幅图像
std::vector<cv::Mat> v;
cv::split(hsv, v);
// 屏蔽低饱和度的像素
cv::threshold(v[1], mask, minSaturation, 255,
cv::THRESH_BINARY);
}
// 准备一维色调直方图的参数
hranges[0] = 0.0; // 范围为0~180
hranges[1] = 180.0;
channels[0] = 0; // 色调通道
// 计算直方图
cv::calcHist(&hsv,
1, // 只有一幅图像的直方图
channels, // 用到的通道
mask, // 二值掩码
hist, // 生成的直方图
1, // 这是一维直方图
histSize, // 箱子数量
ranges // 像素值范围
);
return hist;
}
然后把得到的直方图传给ContentFinder 类的实例,代码如下所示:
ContentFinder finder;
finder.setHistogram(colorhist);
现在打开第二幅图像,我们想在它上面定位狒狒的脸部。首先,需要把这幅图像转换成HSV色彩空间,然后对第一幅图像的直方图做反向投影,参见下面的代码:
image = cv::imread("baboon02.jpg");
if (image.empty())
return 0;
// 转换成HSV 色彩空间
cv::Mat hsv;
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// 得到色调直方图的反向投影
int ch[1] = { 0 };
finder.setThreshold(-1.0f); // 不做阈值化
cv::Mat result = finder.find(hsv, 0.0f, 180.0f, ch);
rect 对象是一个初始矩形区域(即初始图像中狒狒脸部的位置),现在OpenCV 的cv::meanShift 算法将会把它修改成狒狒脸部的新位置,代码如下所示:
#include <opencv2/video/tracking.hpp>
// 用均值偏移法搜索物体
cv::TermCriteria criteria(
cv::TermCriteria::MAX_ITER | cv::TermCriteria::EPS,
10, // 最多迭代10 次
1); // 或者重心移动距离小于1 个像素
cv::meanShift(result, rect, criteria);
脸部的初始位置(红色框)和新位置(绿色框)显示如下。
本例为了突出被寻找物体的特征,使用了HSV 色彩空间的色调分量。之所以这样做,是因为狒狒脸部有非常独特的粉红色,使用像素的色调很容易标识狒狒脸部。
在使用颜色的色调分量时,要把它的饱和度考虑在内(饱和度是向量的第二个入口),这一点通常很重要。如果颜色的饱和度很低,它的色调信息就会变得不稳定且不可靠。这是因为低饱和度颜色的B、G 和R 分量几乎是相等的,这导致很难确定它所表示的准确颜色。因此,我们决定忽略低饱和度颜色的色彩分量,也就是不把它们统计进直方图中(在getHueHistogram 方法中使用minSat 参数可屏蔽掉饱和度低于此阈值的像素)。
均值偏移算法是一个迭代过程,用于定位概率函数的局部最大值,方法是寻找预定义窗口内部数据点的重心或加权平均值。然后,把窗口移动到重心的位置,并重复该过程,直到窗口中心收敛到一个稳定的点。
OpenCV 实现该算法时定义了两个停止条件:迭代次数达到最大值(MAX_ITER);窗口中心的偏移值小于某个限值(EPS),可认为该位置收敛到一个稳定点。这两个条件存储在一个cv::TermCriteria 实例中。cv::meanShift 函数返回已经执行的迭代次数。
显然,结果的好坏取决于指定初始位置提供的概率分布图的质量。注意,这里用颜色直方图表示图像的外观。也可以用其他特征的直方图(例如边界方向直方图)来表示物体。
4.7 比较直方图搜索相似图像
我们已经学过,直方图是标识图像内容的一种有效方式,因此值得研究一下能否用它来解决基于内容的图像检索问题。
这里的关键是,要仅靠比较它们的直方图就测量出两幅图像的相似度。我们需要定义一个测量函数,来评估两个直方图之间的差异程度或相似程度。人们已经提出了很多测量方法,OpenCV在cv::compareHist 函数的实现过程中使用了其中的一些方法。
为了将一个基准图像与一批图像进行对比并找出其中与它最相似的图像,我们创建了类ImageComparator。
这个类引用了一个基准图像和一个输入图像(连同它们的直方图)。另外,因为要用颜色直方图来进行比较,因此ImageComparator 中用到了ColorHistogram 类:
class ImageComparator {
private:
cv::Mat refH; // 基准直方图
cv::Mat inputH; // 输入图像的直方图
ColorHistogram hist; // 生成直方图
int nBins; // 每个颜色通道使用的箱子数量
public:
ImageComparator() :nBins(8) {
}
为了得到更加可靠的相似度测量结果,需要在计算直方图时减少箱子的数量。可以在类中指定每个BGR 通道所用的箱子数量。
用一个适当的设置函数指定基准图像,同时计算参考直方图,代码如下所示:
void setReferenceImage(const cv::Mat& image) {
hist.setSize(nBins);
refH = hist.getHistogram(image);
}
最后,compare 方法会将基准图像和指定的输入图像进行对比。下面的方法返回一个分数,表示两幅图像的相似程度:
double compare(const cv::Mat& image) {
inputH = hist.getHistogram(image);
// 用交叉法比较直方图
return cv::compareHist(refH, inputH, cv::HISTCMP_INTERSECT);
}
前面的类可用来检索与给定的基准图像类似的图像。类的实例中使用了基准图像,代码如下所示:
ImageComparator c;
c.setReferenceImage(image);
cv::Mat input = cv::imread("H:\\opencv_c++\\marais.jpg");
std::cout << "waves vs dog: " << c.compare(input) << std::endl;
大多数直方图比较方法都是基于逐个箱子进行比较的。正因如此,在测量两个颜色直方图的相似度时,把邻近颜色组合进同一个箱子显得十分重要。
对cv::compareHist 的调用非常简单,只需要输入两个直方图,函数就会返回它们的差距。你可以通过一个标志参数指定想要使用的测
量方法。ImageComparator 类使用了交叉点方法(带有cv::HISTCMP_INTERSECT 标志)。该方法只是逐个箱子地比较每个直方图中的数值,并保存最小的值。然后把这些最小值累加,作为相似度测量值。因此,两个没有相同颜色的直方图得到的交叉值为0,而两个完全相同的直方图得到的值就等于像素总数。
4.8 用积分图像统计像素
现在假设需要对图像中的多个感兴趣区域计算几个此类直方图,这些计算过程马上都会变得非常耗时。这种情况下,有一个工具可以极大地提高统计图像子区域像素的效率,那就是积分图像。
本节将使用下面的图像来做演示,识别出图像中的一个感兴趣区域,区域内容为一个骑自行车的女孩。
在累加多个图像区域的像素时,积分图像显得非常有用。通常来说,要获得感兴趣区域全部像素的累加和,常规的代码如下所示:
// 打开图像
cv::Mat image= cv::imread("bike55.bmp",0);
// 定义图像的ROI(这里为骑自行车的女孩)
int xo=97, yo=112;
int width=25, height=30;
cv::Mat roi(image,cv::Rect(xo,yo,width,height));
// 计算累加值
// 返回一个多通道图像下的Scalar 数值
cv::Scalar sum= cv::sum(roi);
cv::sum 函数只是遍历区域内的所有像素,并计算累加和。使用积分图像后,只需要三次加法运算即可实现该功能。不过你得先计算积分图像,代码如下所示:
// 计算积分图像
cv::Mat integralImage;
cv::integral(image,integralImage,CV_32S);
可以在积分图像上用简单的算术表达式获得同样的结果(下一节会详细解释),代码为:
// 用三个加/减运算得到一个区域的累加值
int sumInt = integralImage.at<int>(yo + height, xo + width)–
integralImage.at<int>(yo + height, xo)–
integralImage.at<int>(yo, xo + width) +
integralImage.at<int>(yo, xo);
两种做法得到的结果是一样的。但计算积分图像需要遍历全部像素,因此速度比较慢。关键在于,一旦这个初始计算完成,你只需要添加四个像素就能得到感兴趣区域的累加和,与区域大小无关。 因此,如果需要在多个尺寸不同的区域上计算像素累加和,最好采用积分图像。
为了理解积分图像的实现原理,我们先对它下一个定义:
取图像左上方的全部像素计算累加和,并用这个累加和替换图像中的每一个像素,用这种方式得到的图像称为积分图像。
计算积分图像时,只需对图像扫描一次。实际上,当前像素的积分值等于上方像素的积分值加上当前行的累计值。因此积分图像就是一个包含像素累加和的新图像。
为防止溢出,积分图像的值通常采用int 类型(CV_32S)或float 类型(CV_32F)。例如在下图中,积分图像的像素A 包含左上角区域,即双阴影线图案标识的区域的像素的累加和。
计算由A、B、C、D 四个像素表示区域的像素累加和,先读取D的积分值,然后减去B 的像素值和C 的左手边区域的像素值。但是这样就把A 左上角的像素累加和减了两次,因此需要重新加上A 的积分值。所以计算A、B、C、D 区域内的像素累加的正式公式为:A-B-C+D。
不管感兴趣区域的尺寸有多大,使用这种方法计算的复杂度是恒定不变的。注意,为了简化,这里使用了cv::Mat 类的at 方法,它访问像素值的效率并不是最高的。
自适应的阈值化
通过对图像应用阈值来创建二值图像是从图像中提取有意义元素的好方法。假设有下面这个关于本书的图像。
为了分析图像中的文字,对该图像应用一个阈值,代码如下所示:
// 使用固定的阈值
cv::Mat binaryFixed;
cv::threshold(image,binaryFixed,70,255,cv::THRESH_BINARY);
得到如下结果。
实际上,不管选用什么阈值,图像都会丢失一部分文本,还有部分文本会消失在阴影下。
要解决这个问题,有一个办法就是采用局部阈值,即根据每个像素的邻域计算阈值。这种策略称为自适应阈值化,将每个像素的值与邻域的平均值进行比较。如果某像素的值与它的局部平均值差别很大,就会被当作异常值在阈值化过程中剔除。
因此自适应阈值化需要计算每个像素周围的局部平均值。这需要多次计算图像窗口的累计值,可以通过积分图像提高计算效率。正因为如此,方法的第一步就是计算积分图像:
现在就可以遍历全部像素,并计算方形邻域的平均值了。我们也可以使用IntegralImage类来实现这个功能,但是这个类在访问像素时使用了效率很低的at 方法。我们可以使用指针遍历图像以提高效率,循环代码如下所示:
int nl = image.rows; // 行数
int nc = image.cols * image.channels();
int blockSize = 21; // 邻域的尺寸
int threshold = 10; // 像素将与(mean-threshold)进行比较
// 逐行
int halfSize = blockSize / 2;
for (int j = halfSize; j < nl - halfSize - 1; j++) {
// 得到第j 行的地址
uchar* data = image.ptr<uchar>(j);
int* idata1 = iimage.ptr<int>(j - halfSize);
int* idata2 = iimage.ptr<int>(j + halfSize + 1);
// 一个线条的每个像素
for (int i = halfSize; i < nc - halfSize - 1; i++) {
// 计算累加值
int sum = (idata2[i + halfSize + 1] - idata2[i - halfSize] -
idata1[i + halfSize + 1] + idata1[i - halfSize])
/ (blockSize * blockSize);
// 应用自适应阈值
if (data[i] < (sum - threshold))
data[i] = 0;
else
data[i] = 255;
}
}
本例使用了21×21 的邻域。为计算每个平均值,我们需要访问界定正方形邻域的四个积分像素:两个在标有idata1 的线条上,另两个在标有idata2 的线条上。将当前像素与计算得到的平均值进行比较。为了确保被剔除的像素与局部平均值有明显的差距,这个平均值要减去阈值(这里设为10)。由此得到下面的二值图像。
很明显,这比用固定阈值得到的结果好得多。自适应阈值化是一种常用的图像处理技术。OpenCV 中也实现了这种方法:
cv::adaptiveThreshold(image, // 输入图像
binaryAdaptive, // 输出二值图像
255, // 输出的最大值
cv::ADAPTIVE_THRESH_MEAN_C, // 方法
cv::THRESH_BINARY, // 阈值类型
blockSize, // 块的大小
threshold); // 使用的阈值
最后需要注意,我们也可以用OpenCV 的图像运算符来编写自适应阈值化过程。具体方法如下所示:
cv::Mat filtered;
cv::Mat binaryFiltered;
// boxFilter 计算矩形区域内像素的平均值
cv::boxFilter(image,filtered,CV_8U,cv::Size(blockSize,blockSize));
// 检查像素是否大于(mean + threshold)
binaryFiltered= image>= (filtered-threshold);