OpenCV实战(8)——直方图详解

时间:2023-01-28 12:53:15

0. 前言

图像由不同值(颜色)的像素组成,图像中像素值的分布构成了该图像的一个重要特征。本节介绍图像直方图的概念,学习如何计算直方图以及如何使用它来修改图像的外观。直方图还可用于表征图像的内容并检测图像中的特定对象或纹理。

1. 直方图概念

一张图像由若干像素组成,每个像素如果包含一个值(一个通道),则可以组成一张灰度图像;或者如果每个像素包含三个值(三个通道),则可以组成一张彩色图像。每个通道的取值范围为 0255。根据图像的内容,每个灰度值具有不同数量。
图像直方图是一种反映图像色调分布的直方图,其绘制每个色调值的像素数,每个色调值的像素数也称为频率 (frequency)。因此,灰度图像的直方图有 256 个条目(也称柱条或 bin)。bin 0 表示值为 0 的像素数,bin 1 表示值为 1 的像素数,依此类推。显然,如果对直方图的所有 bin 求和,可以得到图像中的像素总数。直方图也可以归一化,使得 bin 的总和等于 1,在这种情况下,每个 bin 都表示图像中具有此色调值的像素数所占百分比。
使用 cv::calcHist() 函数可以方便的计算图像直方图。这是一个通用函数,可以计算任何像素值类型和范围的多通道图像的直方图。

2. 直方图计算

2.1 灰度图像直方图计算

本节中,我们通过为单通道灰度图像的情况创建一个直方图类来使其更易于使用。对于其他类型的图像,可以直接使用 cv::calcHist() 函数,在下一小节中将解释它的每个参数。

(1) 首先,创建一个直方图类 Histogram1D

// 创建灰度图像直方图
class Histogram1D {
    private:
        int histSize[1];    // 直方图中 bin 的数量
        float hranges[2];   // 值的范围
        const float* ranges[1];     // 指向不同值范围的指针
        int channels[1];            // 通道数量
    public:
        Histogram1D() {
            // 默认参数
            histSize[0] = 256;      // 256 bins
            hranges[0] = 0.0;       // 从 0 开始
            hranges[1] = 256.0;     // 到 256 结束
            ranges[0] = hranges;
            channels[0] = 0;        // 使用通道 0
        }

(2) 使用定义的成员变量,可以使用以下方法完成灰度直方图的计算,该方法在 Histogram1D 类中实现:

// 计算 1D 直方图
cv:: Mat getHistogram(const cv::Mat& image) {
    cv::Mat hist;
    cv::calcHist(&image,
                1,              // 仅使用1张图像计算直方图
                channels,       // 所用通道
                cv::Mat(),      // 不使用掩码
                hist,           // 直方图
                1,              // 1D 直方图
                histSize,       // bins 的数量
                ranges);        // 像素范围
    return hist;
}

(3) 打开一个图像,创建一个 Histogram1D 实例,并调用 getHistogram 方法:

// 读取输入图像
cv::Mat image = cv::imread("1.png", 0);
Histogram1D h;
// 计算直方图
cv::Mat histo = h.getHistogram(image);

(4) 这里的 histo 对象是一个简单的一维数组,有 256bin;因此,可以通过简单地循环遍历此数组来读取每个 bin

for (int i=0; i<256; i++) {
    cout << "Value" << i << " = " << histo.at<float>(i) << endl;
}

执行以上程序,像素值的像素数输出如下:

...
Value117 = 13
Value118 = 14
Value119 = 16
Value120 = 21
...

显然很难从这个值序列中提取任何直观的含义。因此,将直方图进行可视化有利于直观观察图像像素值分布。

(5) 编写 getHistogramImage 方法来可视化直方图:

// 计算 1D 直方图并返回其图像
cv::Mat getHistogramImage(const cv::Mat& image, int zoom=1) {
    cv::Mat hist = getHistogram(image);
    return Histogram1D::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));
    // 设定图像高度
    int hpt = static_cast<int>(0.9*histSize);
    // 绘制每个 bin
    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;
}

(6) 使用 getImageOfHistogram 方法,可以以条形图的形式获取直方图的图像:

// 显示直方图
cv::namedWindow("Histogram");
cv::imshow("Histogram", h.getHistogramImage(image));

程序执行结果如下图所示:

OpenCV实战(8)——直方图详解
从以上直方图可以看出,图像具有较大的中等灰度值和大量较暗的像素,这两组值分别对应图像的前景和背景,可以通过在这两组值之间的过渡处对图像进行阈值处理来验证(可以使用 OpenCV 函数 cv::threshold() 进行阈值处理)。

(7) 为了创建二值图像,我们将图像向直方图的峰值(灰度值 60 )增加之前的灰度值作为阈值:

// 创建二值图像
cv::Mat threshold;
cv::threshold(image, threshold, 70, 255, cv::THRESH_BINARY);

生成的二值图像清楚地显示了背景/前景分割结果:

OpenCV实战(8)——直方图详解
cv::calcHist 函数有许多参数以在多种上下文中使用:

void calcHist(const Mat* images, int nimages,
        const int* channels, InputArray mask, OutputArray hist,
        int dims, const int* histSize, const float** ranges,
        bool uniform=true, bool accumulate=false)

大多数情况下,直方图是单个单通道或三通道图像。但是,cv::calcHist() 函数也可以指定分布在多个图像上的多通道图像,这就是为什么将图像数组输入到此函数中的原因。参数 dims 用于指定直方图的维数,例如,1 表示一维直方图,即使在分析多通道图像时,也不必在计算直方图时使用其所有通道,要计算的通道列在具有指定维度的通道数组中,单通道默认是通道 0;直方图本身由每个维度中的 bin 数(参数 histSize )以及每个维度中最小(包含)和最大(不包含)值(由两个元素组成的参数 ranges )描述。还可以定义非均匀直方图,在这种情况下,需要指定每个 bin 的限制。
与许多 OpenCV 函数一样,cv::calcHist() 函数也可以指定掩码,指示要包含在计数中的像素,忽略掩码值为 0 的像素。还可以指定两个额外的可选参数,它们都是布尔值,第一个指示直方图是否均匀(默认为均匀);第二个参数用于累积多个直方图计算的结果,如果此参数值为 true,则图像的像素数将添加到输入直方图的对应 bin 中。
结果直方图存储在 cv::Mat 实例中。事实上,cv::Mat 类可用于操作一般的 N 维矩阵,cv::Mat 类为矩阵定义了 at 方法,使我们能够在 getHistogramImage 方法中访问一维直方图的每个 bin

float binVal = hist.at<float>(h);

需要注意的是,直方图中的值存储为浮点值。本节中定义的 Histogram1D 类通过将其限制为一维直方图简化了 cv::calcHist 函数,这对灰度图像很有用,接下来,我们继续考虑彩色图像直方图。

2.2 彩色图像直方图计算

使用 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;
            hranges[1] = 256.0;
            ranges[0] = hranges;
            ranges[1] = hranges;
            ranges[2] = hranges;
            channels[0] = 0;
            channels[1] = 1;
            channels[2] = 2;
        }

在这种情况下,直方图将是三维的。因此,我们需要为每一维度指定一个范围。对于 BGR 图像,三个通道具有相同的范围 [0, 255]。定义好参数后,颜色直方图通过以下方法计算:

cv::Mat getHistogram(const cv::Mat &image) {
    // 计算直方图
    cv::Mat hist;
    hranges[0] = 0.0;
    hranges[1] = 256.0;
    channels[0] = 0;
    channels[1] = 1;
    channels[2] = 2;
    cv::calcHist(&image,
            1,              // 使用一张图片计算直方图
            channels,       // 使用的通道
            cv::Mat(),      // 不使用掩码
            hist,           // 结果
            3,              // 3D 直方图
            histSize,       // bins 数量
            ranges          // 像素值范围
    );
    return hist;
}

以上函数可以返回一个三维 cv::Mat 实例。 当选择具有 256 个条目的直方图时,此矩阵有 25 6 3 ≈ 16000000 256^3≈16000000 256316000000 个元素,表示 1600 多万个 bins,在应用中最好减少直方图计算中的 bin 数量。我们也可以使用 cv::SparseMat 数据结构,该数据结构旨在表示大型稀疏矩阵(即非零元素非常少的矩阵),而不会消耗太多内存。cv::calcHist 函数有一个可以返回稀疏矩阵的版本,为了返回 cv::SparseMatrix 需要修改 getHistogram 方法:

// 计算直方图
cv::SparseMat getSparseHistogram(const cv::Mat& image) {
    cv::SparseMat hist(3, histSize, CV_32F);
    hranges[0] = 0.0;
    hranges[1] = 256.0;
    channels[0] = 0;
    channels[1] = 1;
    channels[2] = 2;
    // 计算直方图
    cv:: calcHist(
        &image,
        1,
        channels,
        cv::Mat(),
        hist,
        3,
        histSize,
        ranges
    );
    return hist;
}

显然,也可以通过显示单独的 RGB 直方图来说明图像中的颜色分布。

3. 应用查找表修改图像

图像直方图使用可用的像素强度值捕捉场景的渲染方式。通过分析图像上像素值的分布,可以修改并改进图像。在本节中,将介绍如何使用由查找表表示的简单映射函数来修改图像的像素值,查找表通常是根据直方图分布定义的。

3.1 查找表

查找表是一个简单的一对一(或多对一)函数,它定义了如何将像素值转换为新值,查找表是一个一维数组。

(1) 表中第 i i i 项给出对应灰度的新强度值:

newIntensity= lookup[oldIntensity];

(2) OpenCV 中的 cv::LUT 函数将查找表应用于图像以生成新图像。我们可以将这个函数添加到 Histogram1D 类中:

// 应用查找表将输入图像转换为单通道图像
static cv::Mat applyLookUp(const cv::Mat& image, const cv::Mat& lookup) {
    cv::Mat result;
    cv::LUT(image, lookup, result);
    return result;
}

(3) 当查找表应用于图像时,它会产生一个新图像,其中像素强度值按照查找表的规则进行修改,例如,一个简单的转换如下:

// 创建图像反转表
int dim(256);
cv::Mat lut(1, &dim, CV_8U);
for (int i=0; i<256; i++) {
    lut.at<uchar>(i) = 255 - i;
}

这种变换只是简单地反转像素强度,即强度 0 变为 2551 变为 254,依此类推。在图像上应用该查找表可以得到原始图像的负片效果,结果如下所示:

OpenCV实战(8)——直方图详解
查找表对于所有像素强度都被赋予新强度值的应用程序非常有用。但这种转换必须是全局性的,也就是说,具有相同强度值的所有像素都会经过相同的变换。

3.2 拉伸直方图提高图像对比度

可以通过修改原始图像直方图的查找表来提高图像的对比度。例如,上一节中图像的直方图并没有使用所有像素强度值(未使用较亮的强度值)。因此,可以通过拉伸直方图以生成具有较大对比度的图像。为此,我们使用百分比阈值来定义拉伸图像中黑白像素百分比,我们必须找到最低 (imin) 和最高 (imax) 强度值,以便我们得到低于或高于指定百分位数所需的最小像素数。然后可以重新映射强度值,以便将 imin 值重新定位为强度 0,并为 imax 值重映射为 255,图像中的中间的像素强度 i i i 进行简单地线性重新映射:

255.0*(i-imin)/(imax-imin);

计算完之后调用 applyLookUp 方法, 此外,在实践中,不仅可以忽略值为 0bin,还可以忽略计数小于给定值的 bin (此处定义为 minValue),调用方法如下:

cv::Mat streteched = h.stretch(image,0.01f);

得到的拉伸图像及其直方图如下:

OpenCV实战(8)——直方图详解

3.3 在彩色图像上应用查找表

像素操作一节中,我们定义了一个颜色减少函数,该函数修改图像的 BGR 值以减少可能的颜色数量,我们通过遍历图像的像素并对每个像素应用色彩还原函数来做到这一点。事实上,预先计算所有颜色减少后的新颜色值,然后使用查找表修改每个像素会更有效。新的减色函数可以修改如下:

void colorReduce(cv::Mat &image, int div=64) {
    // 创建 1D 查找表
    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. 图像直方图均衡化

在上一小节中,我们学习了如何通过拉伸直方图以扩展图像可用强度值的范围来提高图像的对比度,这是一种可以有效改善图像质量的简单方法。然而,在很多情况下,图像的视觉缺陷并不在于它使用的强度范围太小;反而是由于某些强度值的使用相较于其它强度值过于频繁。事实上,高质量的图像应该尽可能平衡利用所有可用的像素强度,这就是直方图均衡化概念的核心思想,即令图像直方图尽可能平坦。
OpenCV 提供了一个易于使用的函数来执行直方图均衡化:

cv::equalizeHist(image, result);

将其应用于图像后,可以得到以下结果:

OpenCV实战(8)——直方图详解

当然,由于查找表是全局多对一转换,直方图不可能完全平坦,但是,可以看直方图的分布比原始图像更均匀。
在完全均匀的直方图中,所有 bin 都具有相同数量的像素。这意味着 50% 的像素值强度低于 12825% 的像素强度低于 64,依此类推。可以使用以下规则描述:在均匀直方图中,p% 的像素必须具有低于或等于 255*p% 的强度值。因此,所需的查找表可以使用以下方式构建:

lookup.at<uchar>(i)= static_cast<uchar>(255.0*p[i]/image.total());

其中,p[i] 是强度小于或等于 i 的像素数,p[i] 通常称为累积直方图;也就是说,它是一个包含低于或等于给定强度的像素计数的直方图,而不是包含具有特定强度值的像素计数。image.total() 返回图像中的像素数,因此可以使用 p[i]/image.total() 得到像素的百分比。
通常直方图均衡化可以极大地改善图像的质量,但根据视觉内容,结果的质量会因图像而异。

5. 完整代码

代码包含两部分,首先是头文件 histogram.h

#if !defined HISTOGRAM
#define HISTOGRAM

#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>

// 创建灰度图像直方图
class Histogram1D {
    private:
        int histSize[1];    // 直方图中 bin 的数量
        float hranges[2];   // 值的范围
        const float* ranges[1];     // 指向不同值范围的指针
        int channels[1];            // 通道数量
    public:
        Histogram1D() {
            // 默认参数
            histSize[0] = 256;      // 256 bins
            hranges[0] = 0.0;       // 从 0 开始
            hranges[1] = 256.0;     // 到 256 结束
            ranges[0] = hranges;
            channels[0] = 0;        // 使用通道 0
        }
        // 设定需要计算的通道
        void setChannel(int c) {
            channels[0] = c;
        }
        // 获取所用通道
        int getChannel() {
            return channels[0];
        }
        // 获取最小像素值
        float getMinValue() {
            return hranges[0];
        }
        // 获取最大像素值
        float getMaxValue() {
            return hranges[1];
        }
        // 设定直方图中的 bin 数量
        void setNBins(int nbins) {
            histSize[0] = nbins;
        }
        // 获取直方图中的 bin 数量
        int getNBins() {
            return histSize[0];
        }
        // 计算 1D 直方图
        cv:: Mat getHistogram(const cv::Mat& image) {
            cv::Mat hist;
            cv::calcHist(&image,
                        1,              // 仅使用1张图像计算直方图
                        channels,       // 所用通道
                        cv::Mat(),      // 不使用掩码
                        hist,           // 直方图
                        1,              // 1D 直方图
                        histSize,       // bins 的数量
                        ranges);        // 像素范围
            return hist;
        }
        // 计算 1D 直方图并返回其图像
        cv::Mat getHistogramImage(const cv::Mat& image, int zoom=1) {
            cv::Mat hist = getHistogram(image);
            return Histogram1D::getImageOfHistogram(hist, zoom);
        }
        // 使用具有最小值的 bin 拉伸图像
        cv::Mat stretch(const cv::Mat& image, int minValue=0) {
            // 计算直方图
            cv::Mat hist = getHistogram(image);
            // 找到直方图的左端
            int imin = 0;
            for (; imin<histSize[0]; imin++) {
                // 忽略小于 minValue 的 bins
                if (hist.at<float>(imin) > minValue) break;
            }
            // 找到直方图的右端
            int imax = histSize[0] - 1;
            for (; imax>=0; imax--) {
                // 忽略小于 minValue 的 bins
                if (hist.at<float>(imax) > minValue) break;
            }
            // 创建查找表
            int dims[1] = {256};
            cv::Mat lookup(1, dims, CV_8U);
            for (int i=0; i<256; i++) {
                if (i<imin) lookup.at<uchar>(i) = 0;
                else if (i>imax) lookup.at<uchar>(i) = 255;
                else lookup.at<uchar>(i) = cvRound(255.0*(i-imin)/(imax-imin));
            }
            // 应用查找表
            cv::Mat result;
            result = applyLookUp(image, lookup);
            return result;
        }
        // 使用百分比拉伸图像
        cv::Mat stretch(const cv::Mat& image, float percentile) {
            float number = image.total()*percentile;
            cv::Mat hist = getHistogram(image);
            // 找到直方图的左端
            int imin = 0;
            for (float count=0.0; imin<256; imin++) {
                if ((count+=hist.at<float>(imin)) >= number) break;
            }
            // 找到直方图的右端
            int imax = 255;
            for (float count=0.0; imax>=0; imax--) {
                if ((count+=hist.at<float>(imax)) >= number) break;
            }
            // 创建查找表
            int dims[1] = {256};
            cv::Mat lookup(1, dims, CV_8U);
            for (int i=0; i<256; i++) {
                if (i<imin) lookup.at<uchar>(i) = 0;
                else if (i>imax) lookup.at<uchar>(i) = 255;
                else lookup.at<uchar>(i) = cvRound(255.0*(i-imin)/(imax-imin));
            }
            // 应用查找表
            cv::Mat result;
            result = applyLookUp(image, lookup);
            return result;
        }
        // 不创建用于表示图像的直方图
        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));
            // 设定图像高度
            int hpt = static_cast<int>(0.9*histSize);
            // 绘制每个 bin
            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;
        }
        // 归一化
        static cv::Mat equalize(const cv::Mat& image) {
            cv::Mat result;
            cv::equalizeHist(image, result);
            return result;
        }
        // 应用查找表将输入图像转换为单通道图像
        static cv::Mat applyLookUp(const cv::Mat& image, const cv::Mat& lookup) {
            cv::Mat result;
            cv::LUT(image, lookup, result);
            return result;
        }
        // 使用迭代器应用查找表将输入图像转换为单通道图像
        static cv::Mat applyLookUpWithIterator(const cv::Mat& image, const cv::Mat& lookup) {
            cv::Mat result(image.rows, image.cols, CV_8U);
            cv::Mat_<uchar>::iterator itr = result.begin<uchar>();
            cv::Mat_<uchar>::const_iterator it = image.begin<uchar>();
            cv::Mat_<uchar>::const_iterator itend = image.end<uchar>();
            // 对每一像素应用查找表
            for (; it!=itend; ++it, ++itr) {
                *itr = lookup.at<uchar>(*it);
            }
            return result;
        }
};

#endif

然后是主函数代码文件 hist.cpp

#include <iostream>
using namespace std;

#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include "histogram.h"

int main() {
    // 读取输入图像
    cv::Mat image = cv::imread("1.png", 0);
    if (!image.data) return 0;
    cv::imwrite("1GS.png", image);
    cv::namedWindow("Image");
    cv::imshow("Image", image);
    Histogram1D h;
    // 计算直方图
    cv::Mat histo = h.getHistogram(image);
    for (int i=0; i<256; i++) {
        cout << "Value" << i << " = " << histo.at<float>(i) << endl;
    }
    // 显示直方图
    cv::namedWindow("Histogram");
    cv::imshow("Histogram", h.getHistogramImage(image));
    cv::Mat hi = h.getHistogramImage(image);
    cv::line(hi, cv::Point(70, 0), cv::Point(70, 255), cv::Scalar(128));
    cv::namedWindow("Histogram with threshold value");
    cv::imshow("Histogram with threshold value", hi);
    // 创建二值图像
    cv::Mat threshold;
    cv::threshold(image, threshold, 70, 255, cv::THRESH_BINARY);
    cv::namedWindow("Binary Image");
    cv::imshow("Binary Image", threshold);
    threshold = 255 - threshold;
    cv::imwrite("binary.png", threshold);
    // 直方图归一化
    cv::Mat eq = h.equalize(image);
    cv::namedWindow("Equalized Image");
    cv::imshow("Equalized Image", eq);
    cv::namedWindow("Equalized H");
    cv::imshow("Equalized H", h.getHistogramImage(eq));
    // 拉伸图像
    cv::Mat str = h.stretch(image, 0.01f);
    cv::namedWindow("Stretched Image");
    cv::imshow("Stretched Image", str);
    cv::namedWindow("Stretched H");
    cv::imshow("Stretched H", h.getHistogramImage(str));
    // 创建图像反转表
    cv::Mat lut(1, 256, CV_8U);
    // 或
    // int dim(256);
    // cv::Mat lut(1, &dim, CV_8U);
    for (int i=0; i<256; i++) {
        lut.at<uchar>(i) = 255 - i;
    }
    cv::namedWindow("Negative image");
    cv::imshow("Negative image", h.applyLookUp(image, lut));
    cv::waitKey();
    return 0;
}

小结

图像直方图是一种反映图像色调分布的直方图,绘制每个色调值的像素数。我们可以使用 cv2::calcHist() 函数来计算一个或多个数组的直方图,将其应用于单通道图像和多通道图像,利用查找表可以修改图像的视觉效果,使用 cv::equalizeHist() 函数可以对图像直方图执行均衡化,提高图像质量。

系列链接

OpenCV实战(1)——OpenCV与图像处理基础
OpenCV实战(2)——OpenCV核心数据结构
OpenCV实战(3)——图像感兴趣区域
OpenCV实战(4)——像素操作
OpenCV实战(5)——图像运算详解
OpenCV实战(6)——OpenCV策略设计模式
OpenCV实战(7)——OpenCV色彩空间转换