一、计算图像直方图
图像由各种数值的像素构成。例如在单通道灰度图像中,每个像素都有一个 0(黑色)~255(白色)的整数。对于每个灰度,都有不同数量的像素分布在图像内,具体取决于图片内容。
直方图是一个简单的表格,表示一幅图像(有时是一组图像)中具有某个值的像素的数量。因此,灰度图像的直方图有 256 个项目,也叫箱子(bin)。0 号箱子提供值为 0 的像素的数量,1 号箱子提供值为 1 的像素的数量,以此类推。很明显,如果把直方图的所有箱子进行累加,得到的结果就是像素的总数。你也可以把直方图归一化,即所有箱子的累加和等于 1。这时,每个箱子的数值表示对应的像素数量占总数的百分比。
【实现】
#include<opencv2/core.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace std;
using namespace cv;
//创建灰度图像的直方图
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 Histogram1D::getHistogram(const cv::Mat& image) {
cv::Mat hist;
//用calcHist函数计算一维直方图
cv::calcHist(&image, 1, //仅为一幅图像的直方图
channels, //使用的通道
cv::Mat(), //不使用掩码
hist, //作为结果的直方图
1, //这是一维的直方图
histSize, //箱子数量
ranges //像素值的范围
);
return hist;
}
int main()
{
//读取输入的图像
cv::Mat image = cv::imread("girl.jpg", 0);//以黑白方式打开
//直方图对象
Histogram1D h;
//计算直方图
cv::Mat histo = h.getHistogram(image);
//循环遍历每个箱子
for (int i = 0; i < 256; i++)
cout << "Value" << i << "="
<< histo.at<float>(i) << endl;
}
显然,只看这一系列数值很难得到任何有意义的信息。因此比较实用的做法是以函数的方式 显示直方图,例如用柱状图。
#include<opencv2/core.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace std;
using namespace cv;
//创建灰度图像的直方图
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 getHistogramImage(const cv::Mat& image, int zoom = 1);
static cv::Mat getImageOfHistogram(const cv::Mat& hist, int zoom);
};
//计算一维直方图
cv::Mat Histogram1D::getHistogram(const cv::Mat& image) {
cv::Mat hist;
//用calcHist函数计算一维直方图
cv::calcHist(&image, 1, //仅为一幅图像的直方图
channels, //使用的通道
cv::Mat(), //不使用掩码
hist, //作为结果的直方图
1, //这是一维的直方图
histSize, //箱子数量
ranges //像素值的范围
);
return hist;
}
//创建一个表示直方图的图像(静态方法)
cv::Mat Histogram1D::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;
}
//计算一维直方图,并返回它的图像
cv::Mat Histogram1D::getHistogramImage(const cv::Mat& image, int zoom) {
zoom = 1;
//先计算直方图
cv::Mat hist = getHistogram(image);
//创建图像
return getImageOfHistogram(hist, zoom);
}
int main()
{
//读取输入的图像
cv::Mat image = cv::imread("girl.jpg", 0);//以黑白方式打开
//直方图对象
Histogram1D h;
cv::namedWindow("Histogram");
cv::imshow("Histogram", h.getHistogramImage(image));
cv::waitKey(0);
}
从图形化的直方图可以看出,在中等灰度值处有一个大的尖峰,并且比中等值更黑的像素有很多。巧的是,这两部分像素分别对应了图像的背景和前景。要验证这点,可以在这两部分的汇合处进行阈值化处理。OpenCV 中的 cv::threshold 函数可以实现这个功能。上一章介绍过,它是一个很实用的函数。我们取直方图中在升高为尖峰之前的最小值的位置(灰度值为 70), 对其进行阈值化处理,得到二值图像。
int main()
{
//读取输入的图像
cv::Mat image = cv::imread("girl.jpg", 0);//以黑白方式打开
cv::Mat thresholded; //输出二值图像
cv::threshold(image, thresholded, 70, //阈值
255, //对超过阈值的像素赋值
cv::THRESH_BINARY); //阈值化类型
cv::namedWindow("Binary Image");
cv::imshow("Binary Image", thresholded);
cv::waitKey(0);
}
【实现原理】
为了适应各种场景,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::Mat)上的多通道图像。这也是把输入图像数组作为函数第一个参数的原因。第六个参数 dims 指明了直方图的维数,例如 1 表示一维直方图。在分析多通道图像时,可以只把它的部分通道用于计算直方图,将需要处理的通道放在维数确定的数组 channel 中。在这个类的实现中只有一个通道,默认为 0。直方图用每个维度上的箱子数量(即整数数组histSize)以及每个维度(由 ranges 数组提供,数组中每个元素又是一个二元素数组)上的最小值(含)和最大值(不含)来描述。你也可以定义一个不均匀的直方图(倒数第二个参数应 设为false),这时需要指定每个箱子的限值。
和很多 OpenCV 函数一样,可以使用掩码表示计算时用到的像素(所有掩码值为 0 的像素都不使用)。此外还可以指定两个布尔值类型的附加参数,第一个表示是否采用均匀的直方图(默认为 true),第二个表示是否允许累加多个直方图计算的结果。如果第二个参数为 true,那么图像中的像素数量会累加到输入直方图的当前值中。在计算一组图像的直方图时,就可以使用这个参数。
得到的直方图存储在 cv::Mat 的实例中。事实上,cv::Mat 类可用于操作通用的 N 维矩阵。第 2 章讲过,cv::Mat 类定义了适用于一维、二维和三维矩阵的 at 方法。正因如此,我们才可以在 getHistogramImage 方法中用下面的代码访问一维直方图的每个箱子:
float binVal = hist.at(h);
注意,直方图中的值存储为 float 值。
【扩展阅读】
我们可以用同一个 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
}
这里的直方图将会是三维的,因此需要为每个维度指定一个范围。本例中的 BGR 图像的三个通道范围都是[0,255]。准备好参数后,就可以用下面的方法计算颜色直方图了:
// 计算直方图
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::calcHist 函数具有返回这种矩阵的版本,因此只需要简单地修改一下前面的方法,即可使用 cv::SparseMatrix:
// 计算直方图
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;
}
这是一个三维直方图,画起来比较困难。我们也可以通过显示独立的 R、G 和 B 通道的直方图来说明图像中颜色的分布情况。
【遇到的问题】
扩展阅读里的代码不完整,其他的地方我不会修改,所以运行不起来。
二、利用查找表修改图像外观
图像直方图提供了利用现有像素强度值进行场景渲染的方法。通过分析图像中像素值的分布 情况,你可以利用这个信息来修改图像,甚至提高图像质量。本节将解释如何用一个简单的映射函数(称为查找表)来修改图像的像素值。我们即将看到,查找表通常根据直方分布图生成。
【实现】
查找表是个一对一(或多对一)的函数,定义了如何把像素值转换成新的值。它是一个一维数组,对于规则的灰度图像,它包含 256 个项目。利用查找表的项目 i,可得到对应灰度级的新强度值。
newIntensity = lookup[oldIntensity];
OpenCV 中的 cv::LUT 函数在图像上应用查找表生成一个新的图像。查找表通常根据直方图生成,以下是完整代码。
#include<opencv2/core.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace std;
using namespace cv;
class Histogram1D {
public:
static cv::Mat applyLookUp(const cv::Mat& image, const cv::Mat& lookup);
};
cv::Mat Histogram1D::applyLookUp(const cv::Mat& image, //输入图像
const cv::Mat& lookup) {//uchar类型的1x256数组
//输出图像
cv::Mat result;
//应用查找表
cv::LUT(image, lookup, result);
return result;
}
int main()
{
//读取输入的图像
cv::Mat image = cv::imread("girl.jpg");
//创建一个图像翻转的查找表
cv::Mat lut(1, 256, CV_8U);//256x1矩阵
for (int i = 0; i < 256; i++) {
//0变成255,1变成254,以此类推
lut.at<uchar>(i) = 255 - i;
}
//直方图对象
Histogram1D h;
cv::namedWindow("Negative image");
cv::imshow("Negative image", h.applyLookUp(image, lut));
cv::waitKey(0);
}
注意:这里我把上一部分的直方图代码删掉了,只存留了这次的查找表代码。其实也不一定非要定义一个类,直接用函数也可以实现。
【实现原理】
在图像上应用查找表后得到一个新图像,新图像的像素强度值被修改为查找表中规定的值。例如上述代码对像素强度进行了简单的反转,即强度 0 变成 255、1 变成 254、最后 255 变成0。对图像应用这种查找表后,会生成原始图像的反向图像。
【扩展阅读】
对于需要更换全部像素强度值的程序,都可以使用查找表。但是这个转换过程必须是针对整幅图像的。也就是说,一个强度值对应的全部像素都必须使用同一种转换方法。
1. 伸展直方图以提高图像对比度
定义一个修改原始图像直方图的查找表可以提高图像的对比度。例如,如果图中根本没有大于 200 的像素值。我们可以通过伸展直方图来生成一个对比度更高的图像。为此要使用一个百分比阈值,表示伸展后图像的最小强度值(0)和最大强度值(255) 像素的百分比。
我们必须在强度值中找到最小值(imin)和最大值(imax),使得所要求的最小的像素数量高于阈值指定的百分比。以下是完整代码:
#include<opencv2/core.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace std;
using namespace cv;
//创建灰度图像的直方图
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 getHistogramImage(const cv::Mat& image, int zoom = 1);
static cv::Mat getImageOfHistogram(const cv::Mat& hist, int zoom);
cv::Mat stretch(const Mat& image, int minValue = 0);
static cv::Mat applyLookUp(const cv::Mat& image, const cv::Mat& lookup);
};
//计算一维直方图
cv::Mat Histogram1D::getHistogram(const cv::Mat& image) {
cv::Mat hist;
//用calcHist函数计算一维直方图
cv::calcHist(&image, 1, //仅为一幅图像的直方图
channels, //使用的通道
cv::Mat(), //不使用掩码
hist, //作为结果的直方图
1, //这是一维的直方图
histSize, //箱子数量
ranges //像素值的范围
);
return hist;
}
//创建一个表示直方图的图像(静态方法)
cv::Mat Histogram1D::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;
}
//计算一维直方图,并返回它的图像
cv::Mat Histogram1D::getHistogramImage(const cv::Mat& image, int zoom) {
zoom = 1;
//先计算直方图
cv::Mat hist = getHistogram(image);
//创建图像
return getImageOfHistogram(hist, zoom);
}
cv::Mat Histogram1D::applyLookUp(const cv::Mat& image, //输入图像
const cv::Mat& lookup) {//uchar类型的1x256数组
//输出图像
cv::Mat result;
//应用查找表
cv::LUT(image, lookup, result);
return result;
}
//伸展直方图
cv::Mat Histogram1D::stretch(const Mat& image, int minValue) {
cv::Mat hist = getHistogram(image);
//找到直方图的左极限
int imin = 0;
for (; imin < 256; imin++) {
//小于或等于imin的像素数量必须>minValue
if ((hist.at<float>(imin)) > minValue)
break;
}
//找到直方图的右极限
int imax = 255;
for (; imax >= 0; imax--) {
//大于或等于imax的像素必须>minValue
if ((hist.at<float>(imax)) > minValue)
break;
}
//minValue代表的是次数、个数,像素值最小(0左右的)以及像素值最大(255左右的)
//这些极端的值都比较少,找到比较少的个数对应的像素值坐标(横坐标)
Mat lookup(1, 256, CV_8U); //LUT查找表的像素重映射的规则
for (int i = 0; i < 256; i++) //根据像素值大小划分
{
if (i < imin) //像素值(横坐标)imin左边的都置为0;极小的置0
lookup.at<uchar>(i) = 0;
else if (i > imax) //像素值(横坐标)右边的都置255;极大的置255
lookup.at<uchar>(i) = 255;
else
lookup.at<uchar>(i) = cvRound(255.0 * (i - imin) / (imax - imin)); //[min,max]重新分配 cvRound为取整,中间的重新映射
}
Mat result;
result = applyLookUp(image, lookup);
return result; //返回处理好的增强的对比度 图片
//这里需要分析下形参传进来的minValue。如果minValue过大,两边的0,255就会多;如果minValue过小,两边的0,255就会少
}
int main()
{
//读取输入的图像
cv::Mat image = cv::imread("bluesky.jpg");
//直方图对象
Histogram1D h;
cv::Mat streteched = h.stretch(image, 200);
cv::namedWindow("Streched Image");
cv::imshow("Streched Image", streteched);
cv::namedWindow("Original Image");
cv::imshow("Original Image", image);
cv::waitKey(0);
}
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);
}
这种减色方案之所以能起作用,是因为在多通道图像上应用一维查找表时,同一个查找表会独立地应用在所有通道上。如果查找表超过一个维度,那么它和所用图像的通道数必须相同。