要在 OpenCV 中计算直方图1,可简单地调用 cv::calcHist 函数。这是一个通用的直方图计算函数,可处理包含任何值类型和范围的多通道图像。为了简化,这里指定一个专门用于处理单通道灰度图像的类。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); // 阈值化类型
图像直方图提供了利用现有像素强度值进行场景渲染的方法。通过分析图像中像素值的分布情况,你可以利用这个信息来修改图像,甚至提高图像质量。本节将解释如何用一个简单的映射函数(称为查找表2)来修改图像的像素值。我们即将看到,查找表通常根据直方分布图生成。
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;
}
很多时候,图像的视觉缺陷并不因为它使用的强度值范围太窄,而是因为部分强度值的使用频率远高于其他强度值。4.2 节显示的直方图就是此类现象的一个很好的例子——中等灰度的强度值非常多,而较暗和较亮的像素值则非常稀少。
因此,均衡对所有像素强度值的使用频率可以作为提高图像质量的一种手段。这正是直方图均衡化这一概念背后的思想,也就是让图像的直方图尽可能地平稳。
OpenCV 提供了一个易用的函数,用于直方图均衡化处理。这个函数的调用方式为:
cv::equalizeHist(image,result);
对图像应用该函数后,得到的结果如下所示。
直方图是图像内容的一个重要特性。如果图像的某个区域含有特定的纹理或物体,这个区域的直方图就可以看作一个函数,该函数返回某个像素属于这个特殊纹理或物体的概率。本节将介绍如何运用直方图反向投影的概念方便地检测特定的图像内容。
在程序中用下面的方法可以得到这个感兴趣区域:
cv::Mat imageROI;
imageROI= image(cv::Rect(216,33,24,30)); // 云彩区域
接着提取该 ROI 的直方图。使用 4.2 节的 Histogram1D 类,能轻松获得该直方图:
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, threshold, 255, cv::THRESH_BINARY);
我们可以从一个初始位置开始,在周围反复移动以提高局部匹配概率,也许就能找到物体的准确位置。这个实现方法称为均值平移算法。
// 读取参考图像
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);
然后把得到的直方图传给 ContentFinder 类的实例,代码如下所示:
ContentFinder finder;
finder.setHistogram(colorhist);
现在打开第二幅图像,我们想在它上面定位狒狒的脸部。首先,需要把这幅图像转换成 HSV色彩空间,然后对第一幅图像的直方图做反向投影,参见下面的代码
image= cv::imread("baboon3.jpg");
// 转换成 HSV 色彩空间
cv::cvtColor(image, hsv, CV_BGR2HSV);
// 得到色调直方图的反向投影
int ch[1]={0};
finder.setThreshold(-1.0f); // 不做阈值化
cv::Mat result= finder.find(hsv,0.0f,180.0f,ch);
rect 对象是一个初始矩形区域(即初始图像中狒狒脸部的位置),现在 OpenCV 的 cv::
meanShift 算法将会把它修改成狒狒脸部的新位置,代码如下所示:
// 窗口初始位置
cv::Rect rect(110,260,35,40);
// 用均值偏移法搜索物体
cv::TermCriteria criteria(
cv::TermCriteria::MAX_ITER | cv::TermCriteria::EPS,
10, // 最多迭代 10 次
1); // 或者重心移动距离小于 1 个像素
cv::meanShift(result,rect,criteria);
均值偏移算法是一个迭代过程,用于定位概率函数的局部最大值,方法是寻找预定义窗口内部数据点的重心或加权平均值。然后,把窗口移动到重心的位置,并重复该过程,直到窗口中心收敛到一个稳定的点。OpenCV 实现该算法时定义了两个停止条件:迭代次数达到最大(MAX_ITER);窗口中心的偏移值小于某个限值(EPS),可认为该位置收敛到一个稳定点。这两个条件存储在一个 cv::TermCriteria 实例中。cv::meanShift 函数返回已经执行的迭代次数。显然,结果的好坏取决于指定初始位置提供的概率分布图的质量。注意,这里用颜色直方图表示图像的外观。也可以用其他特征的直方图(例如边界方向直方图)来表示物体。
基于内容的图像检索是计算机视觉的一个重要课题。它包括根据一个已有的基准图像,找出一批内容相似的图像。我们已经学过,直方图是标识图像内容的一种有效方式,因此值得研究一下能否用它来解决基于内容的图像检索问题。
这里的关键是,要仅靠比较它们的直方图就测量出两幅图像的相似度。我们需要定义一个测量函数,来评估两个直方图之间的差异程度或相似程度。人们已经提出了很多测量方法,OpenCV在 cv::compareHist 函数的实现过程中使用了其中的一些方法。
颜色直方图类
class ImageComparator {
private:
cv::Mat refH; // 基准直方图
cv::Mat inputH; // 输入图像的直方图
ColorHistogram hist; // 生成直方图
int nBins; // 每个颜色通道使用的箱子数量
public:
ImageComparator() :nBins(8) {
}
指定基准图像
// 设置并计算基准图像的直方图
void setReferenceImage(const cv::Mat& image) {
hist.setSize(nBins);
refH= hist.getHistogram(image);
}
最后,compare 方法会将基准图像和指定的输入图像进行对比。下面的方法返回一个分数,表示两幅图像的相似程度:
// 用 BGR 直方图比较图像
double compare(const cv::Mat& image) {
inputH= hist.getHistogram(image);
// 用交叉法比较直方图
return cv::compareHist(refH,inputH, cv::HISTCMP_INTERSECT);
}
实际上,累计图像某个子区域内的像素总数是很多计算机视觉算法中的常见过程。现在假设需要对图像中的多个感兴趣区域计算几个此类直方图,这些计算过程马上都会变得非常耗时。这种情况下,有一个工具可以极大地提高统计图像子区域像素的效率,那就是积分图像。
通常来说,要获得感兴趣区域全部像素的累加和,常规的代码如下所示:
// 打开图像
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);
两种做法得到的结果是一样的。但计算积分图像需要遍历全部像素,因此速度比较慢。关键在于,一旦这个初始计算完成,你只需要添加四个像素就能得到感兴趣区域的累加和,与区域大小无关。 因此,如果需要在多个尺寸不同的区域上计算像素累加和,最好采用积分图像。
是一个简单的表格,表示一幅图像(有时是一组图像)中具有某个值的像素的数量。因此,灰度图像的直方图有 256 个项目,也叫箱子(bin)。0 号箱子提供值为 0 的像素的数量,1 号箱子提供值为 1 的像素的数量,以此类推。很明显,如果把直方图的所有箱子进行累加,得到的结果就是像素的总数。你也可以把直方图归一化,即所有箱子的累加和等于 1。这时,每个箱子的数值表示对应的像素数量占总数的百分比。
一个具有注脚的文本。 ↩︎
查找表是个一对一(或多对一)的函数,定义了如何把像素值转换成新的值。它是一个一维
数组,对于规则的灰度图像,它包含 256 个项目。利用查找表的项目 i,可得到对应灰度级的新
强度值,如下所示:
newIntensity= lookup[oldIntensity]; ↩︎