OpenCV系列——核心功能(核心模块)

系列文章目录

[OpenCV系列——核心功能(核心模块)]](https://blog.csdn.net/zmkzmkok/article/details/128762688)



Mat - 基本图像容器

目标

我们有多种方法可以从现实世界中获取数字图像:数码相机、扫描仪、计算机断层扫描和磁共振成像等等。在每种情况下,我们(人类)看到的都是图像。但是,当将其转换为我们的数字设备时,我们记录的是图像每个点的数值。
在这里插入图片描述

例如,在上图中,您可以看到汽车的镜子只不过是一个包含像素点所有强度值的矩阵。我们如何获取和存储像素值可能会根据我们的需要而有所不同,但最终计算机世界中的所有图像都可能被简化为数字矩阵和其他描述矩阵本身的信息。OpenCV是一个计算机视觉库,其主要重点是处理和操作这些信息。因此,您需要熟悉的第一件事是OpenCV如何存储和处理图像。

Mat

OpenCV自2001年以来一直存在。在那些日子里,库是围绕C接口构建的,为了将图像存储在内存中,他们使用了称为IplImage的C结构。这是您将在大多数旧教程和教育材料中看到的内容。这样做的问题在于它带来了 C 语言的所有缺点。最大的问题是手动内存管理。它建立在用户负责处理内存分配和释放的假设之上。虽然这对于较小的程序来说不是问题,但一旦你的代码库增长,处理所有这些问题将更加困难,而不是专注于解决你的开发目标。

幸运的是,C++出现了,并引入了类的概念,即通过自动内存管理(或多或少)使用户更容易。好消息是C++与 C 完全兼容,因此进行更改不会产生兼容性问题。因此,OpenCV 2.0引入了一个新的C++接口,它提供了一种新的做事方式,这意味着你不需要摆弄内存管理,使你的代码简洁(更少的编写,实现更多)。C++接口的主要缺点是目前许多嵌入式开发系统仅支持C。因此,除非你的目标是嵌入式平台,否则使用旧方法没有意义(除非你是一个受虐狂的程序员并且你自找麻烦)。

您需要了解的有关Mat的第一件事是,您不再需要手动分配其内存并在不需要时立即释放它。虽然这样做仍然是可能的,但大多数OpenCV函数将自动分配其输出数据。如果您传递一个已经存在的 Mat 对象,该对象已经为矩阵分配了所需的空间,这将被重用,这将是一个很好的奖励。换句话说,我们始终只使用执行任务所需的内存。

Mat 基本上是一个包含两个数据部分的类:矩阵标头(包含矩阵的大小、用于存储的方法、矩阵存储的地址等信息)和指向包含像素值的矩阵的指针(根据选择的存储方法采用任何维度)。矩阵标头大小是恒定的,但是矩阵本身的大小可能因图像而异,并且通常大几个数量级。

OpenCV是一个图像处理库。它包含图像处理函数的大量集合。为了解决计算挑战,大多数时候您最终会使用库的多个函数。因此,将图像传递给函数是一种常见的做法。我们不应该忘记我们正在谈论图像处理算法,这些算法往往计算量很大。我们要做的最后一件事是通过制作不必要的潜在大图像副本来进一步降低程序的速度。

为了解决这个问题,OpenCV使用了一个引用计数系统。这个想法是每个 Mat 对象都有自己的标头,但是可以通过让它们的矩阵指针指向同一地址在两个 Mat 对象之间共享矩阵。此外,复制运算符只会复制指向大矩阵的标头和指针,而不是数据本身。

Mat A, C;                          // creates just the header parts
A = imread(argv[1], IMREAD_COLOR); // here we'll know the method used (allocate matrix)
Mat B(A);                                 // Use the copy constructor
C = A;                                    // Assignment operator

最后,上述所有对象都指向同一个数据矩阵,使用其中任何一个进行修改也会影响所有其他对象。实际上,不同的对象只是为相同的基础数据提供不同的访问方法。然而,它们的标题部分是不同的。真正有趣的部分是,您可以创建仅引用完整数据子部分的标题。例如,要在图像中创建感兴趣区域 (ROI),您只需创建一个具有新边界的新标题:

Mat D (A, Rect(10, 10, 100, 100) ); // using a rectangle
Mat E = A(Range::all(), Range(1,3)); // using row and column boundaries

现在你可能会问 - 矩阵本身是否可能属于多个Mat对象,当不再需要它时,他们负责清理它。简短的回答是:使用它的最后一个对象。这是通过使用引用计数机制来处理的。每当有人复制 Mat 对象的标头时,都会为矩阵增加一个计数器。每当清理标头时,此计数器都会减少。当计数器达到零时,矩阵被释放。有时你也想复制矩阵本身,所以OpenCV提供了cv::Mat::clone()和cv::Mat::copyTo()函数。

Mat F = A.clone();
Mat G;
A.copyTo(G);

现在修改 F 或 G 不会影响 A 标头所指向的矩阵。您需要从这一切中记住的是:

  • OpenCV 函数的输出图像分配是自动的(除非另有说明)。
  • 您无需考虑使用OpenCV的C++接口进行内存管理。
  • 赋值运算符和复制构造函数仅复制标头。
  • 可以使用 cv::Mat::clone() 和 cv::Mat::copyTo() 函数复制图像的底层矩阵。

存储方法

这是关于如何存储像素值。您可以选择色彩空间和使用的数据类型。颜色空间是指我们如何组合颜色组件以对给定的颜色进行编码。最简单的是灰度,我们可以使用的颜色是黑白。这些的组合使我们能够创建许多灰色阴影。

对于丰富多彩的方式,我们有更多的方法可供选择。它们中的每一个都将其分解为三个或四个基本组件,我们可以使用这些组件的组合来创建其他组件。最受欢迎的是RGB,主要是因为这也是我们的眼睛建立颜色的方式。它的基础颜色是红色,绿色和蓝色。为了对颜色的透明度进行编码,有时会添加第四个元素:alpha (A)。

但是,还有许多其他颜色系统各有其优点:

  • RGB是最常见的,因为我们的眼睛使用类似的东西,但请记住,OpenCV标准显示系统使用BGR色彩空间(红色和蓝色通道交换位置)组成颜色。
  • HSV 和 HLS 将颜色分解为色调、饱和度和值/亮度分量,这是我们描述颜色的一种更自然的方式。例如,您可能会忽略最后一个组件,从而使您的算法对输入图像的光照条件不太敏感。
  • YCrCb 被流行的 JPEG 图像格式使用。
  • CIE Lab* 是一个感知均匀的颜色空间,如果您需要测量给定颜色到另一种颜色的距离,它会派上用场。

每个建筑构件都有自己的有效域。这会导致使用的数据类型。我们如何存储组件定义了我们对其域的控制。可能的最小数据类型是 char,这意味着一个字节或 8 位。这可能是无符号的(因此可以存储从 0 到 255 的值)或有符号的(从 -127 到 +127 的值)。尽管在三个组件的情况下,这已经提供了 1600 万种可能的颜色来表示(如 RGB 的情况),但我们可以通过为每个组件使用 float(4 字节 = 32 位)或双精度(8 字节 = 64 位)数据类型来获得更精细的控制。但是,请记住,增加组件的大小也会增加内存中整个图片的大小。

显式创建 Mat 对象

在加载、修改和保存图像教程中,您已经学习了如何使用 cv::imwrite() 函数将矩阵写入图像文件。但是,出于调试目的,查看实际值要方便得多。您可以使用 Mat 的<<运算符执行此操作。 请注意,这仅适用于二维矩阵。

虽然 Mat 作为图像容器效果很好,但它也是一个通用的矩阵类。因此,可以创建和操作多维矩阵。您可以通过多种方式创建 Mat 对象:

  • cv::Mat::Mat Constructor
    Mat M(2,2, CV_8UC3, Scalar(0,0,255));
    cout << "M = " << endl << " " << M << endl << endl;

在这里插入图片描述

对于二维和多通道图像,我们首先定义它们的大小:行和列计数宽度。

然后我们需要指定用于存储元素的数据类型以及每个矩阵点的通道数。为此,我们根据以下约定构建了多个定义:

CV_[The number of bits per item][Signed or Unsigned][Type Prefix]C[The channel number]

例如,CV_8UC3意味着我们使用 8 位长的无符号字符类型,每个像素有三个这样的类型来形成三个通道。有为最多四个通道预定义的类型。cv::标量是四元素短向量。指定它,您可以使用自定义值初始化所有矩阵点。如果需要更多,可以使用上部宏创建类型,在括号中设置通道号,如下所示。

  • Use C/C++ arrays and initialize via constructor
    int sz[3] = {2,2,2};
    Mat L(3,sz, CV_8UC(1), Scalar::all(0));

上面的示例演示如何创建具有两个以上维度的矩阵。指定其维度,然后传递一个包含每个维度大小的指针,其余维度保持不变。

  • cv::Mat::create function:
    M.create(4,4, CV_8UC(2));
    cout << "M = "<< endl << " "  << M << endl << endl;

在这里插入图片描述

不能使用此构造初始化矩阵值。只有当新尺寸不适合旧尺寸时,它才会重新分配其矩阵数据存储器。

  • MATLAB style initializer: cv::Mat::zeros , cv::Mat::ones , cv::Mat::eye . Specify size and data type to use:
    Mat E = Mat::eye(4, 4, CV_64F);
    cout << "E = " << endl << " " << E << endl << endl;
    Mat O = Mat::ones(2, 2, CV_32F);
    cout << "O = " << endl << " " << O << endl << endl;
    Mat Z = Mat::zeros(3,3, CV_8UC1);
    cout << "Z = " << endl << " " << Z << endl << endl;

在这里插入图片描述

  • For small matrices you may use comma separated initializers or initializer lists (C++11 support is required in the last case):
    Mat C = (Mat_<double>(3,3) << 0, -1, 0, -1, 5, -1, 0, -1, 0);
    cout << "C = " << endl << " " << C << endl << endl;
    C = (Mat_<double>({0, -1, 0, -1, 5, -1, 0, -1, 0})).reshape(3);
    cout << "C = " << endl << " " << C << endl << endl;

在这里插入图片描述

  • Create a new header for an existing Mat object and cv::Mat::clone or cv::Mat::copyTo it.
    Mat RowClone = C.row(1).clone();
    cout << "RowClone = " << endl << " " << RowClone << endl << endl;

在这里插入图片描述

注:
您可以使用 cv::randu() 函数用随机值填充矩阵。您需要为随机值指定下限和上限:

        Mat R = Mat(3, 2, CV_8UC3);
        randu(R, Scalar::all(0), Scalar::all(255));

输出格式

在上面的示例中,您可以看到默认格式选项。但是,OpenCV允许您格式化矩阵输出:

  • Default
    cout << "R (default) = " << endl <<        R           << endl << endl;

在这里插入图片描述

  • Python
    cout << "R (python)  = " << endl << format(R, Formatter::FMT_PYTHON) << endl << endl;

在这里插入图片描述

  • Comma separated values (CSV)
    cout << "R (csv)     = " << endl << format(R, Formatter::FMT_CSV   ) << endl << endl;

在这里插入图片描述

  • Numpy
    cout << "R (numpy)   = " << endl << format(R, Formatter::FMT_NUMPY ) << endl << endl;

在这里插入图片描述

  • C
    cout << "R (c)       = " << endl << format(R, Formatter::FMT_C     ) << endl << endl;

在这里插入图片描述

其他常见项目的输出

OpenCV也通过<<运算符支持其他常见OpenCV数据结构的输出:

  • 2D Point
    Point2f P(5, 1);
    cout << "Point (2D) = " << P << endl << endl;

在这里插入图片描述

  • 3D Point
    Point3f P3f(2, 6, 7);
    cout << "Point (3D) = " << P3f << endl << endl;

在这里插入图片描述

  • std::vector via cv::Mat
    vector<float> v;
    v.push_back( (float)CV_PI);   v.push_back(2);    v.push_back(3.01f);
    cout << "Vector of floats via Mat = " << Mat(v) << endl << endl;

在这里插入图片描述

  • std::vector of points
    vector<Point2f> vPoints(20);
    for (size_t i = 0; i < vPoints.size(); ++i)
        vPoints[i] = Point2f((float)(i * 5), (float)(i % 7));
    cout << "A vector of 2D Points = " << vPoints << endl << endl;

在这里插入图片描述

此处的大多数示例都包含在小型控制台应用程序中。您可以从此处或 cpp 示例的核心部分下载它。

您还可以在YouTube上找到有关此内容的快速视频演示。

如何使用OpenCV扫描图像,查找表和时间测量

目标

我们将寻求以下问题的答案:

  • 如何遍历图像的每个像素?
  • OpenCV矩阵值是如何存储的?
  • 如何衡量我们算法的性能?
  • 什么是查找表,为什么要使用它们?

我们的测试用例

让我们考虑一个简单的颜色还原方法。通过使用无符号字符 C 和 C++ 类型进行矩阵项存储,像素通道最多可以具有 256 个不同的值。对于三通道图像,这允许形成太多的颜色(确切地说是1600万)。使用如此多的色调可能会对我们的算法性能造成沉重打击。但是,有时使用更少的它们就足以获得相同的最终结果。
在这种情况下,我们通常会减少色彩空间。这意味着我们将颜色空间当前值除以新的输入值,最终得到更少的颜色。例如,0 到 9 之间的每个值都采用新值 0,10 到 19 之间的每个值都采用值 10,依此类推。
当您将 uchar(无符号字符 - 又名介于 0 和 255 之间的值)值与 int 值除以 时,结果也将是 char。这些值可能只是字符值。因此,任何分数都将向下舍入。利用这一事实,uchar 域中的上层操作可以表示为:
I n e w = ( I o l d 10 ) ∗ 10 I_{new} = (\frac{I_{old}}{10})*10 Inew=(10Iold)10
一个简单的色彩空间缩减算法将包括通过图像矩阵的每个像素并应用此公式。值得注意的是,我们进行除法和乘法运算。这些操作对于一个系统来说是血腥昂贵的。如果可能的话,通过使用更便宜的操作来避免它们,例如一些减法,加法或在最好的情况下是简单的分配。此外,请注意,对于上部操作,我们只有有限数量的输入值。在 uchar 系统的情况下,确切地说是 256。
因此,对于较大的图像,明智的做法是事先计算所有可能的值,并且在分配期间只需使用查找表进行分配即可。查找表是简单的数组(具有一个或多个维度),对于给定的输入值变化,它们保存最终输出值。它的优势在于我们不需要进行计算,我们只需要读取结果。
我们的测试用例程序(以及下面的代码示例)将执行以下操作:读取作为命令行参数传递的图像(可以是彩色或灰度),并使用给定的命令行参数整数值应用缩减。在OpenCV中,目前有三种主要方式可以逐像素地浏览图像。为了使事情更有趣,我们将使用这些方法中的每一种扫描图像,并打印出它需要多长时间。
您可以在此处下载完整的源代码,也可以在 OpenCV 的示例目录中查找核心部分的 cpp 教程代码。它的基本用法是:

how_to_scan_images imageName.jpg intValueToReduce [G]

最后一个参数是可选的。如果给定图像将以灰度格式加载,否则将使用BGR颜色空间。第一件事是计算查找表。

    int divideWith = 0; // convert our input string to number - C++ style
    stringstream s;
    s << argv[2];
    s >> divideWith;
    if (!s || !divideWith)
    {
        cout << "Invalid number entered for dividing. " << endl;
        return -1;
    }
    uchar table[256];
    for (int i = 0; i < 256; ++i)
       table[i] = (uchar)(divideWith * (i/divideWith));

这里我们首先使用 C++ stringstream 类将第三个命令行参数从文本转换为整数格式。然后我们使用一个简单的外观和上面的公式来计算查找表。这里没有OpenCV特定的东西。
另一个问题是我们如何测量时间?OpenCV提供了两个简单的函数来实现这个cv::getTickCount()和cv::getTickFrequency()。第一个返回系统CPU从某个事件(如启动系统以来)的时钟周期数。第二个返回 CPU 在一秒钟内发出时钟周期的次数。因此,测量两个操作之间经过的时间量非常简单:

double t = (double)getTickCount();
// do something ...
t = ((double)getTickCount() - t)/getTickFrequency();
cout << "Times passed in seconds: " << t << endl;

图像矩阵如何存储在内存中?

正如您已经在我的 Mat - 基本图像容器教程中读到的那样,矩阵的大小取决于所使用的颜色系统。更准确地说,这取决于使用的通道数量。如果是灰度图像,我们有这样的内容:
在这里插入图片描述对于多通道图像,列包含的子列数与通道数一样多。例如,在BGR颜色系统的情况下:
在这里插入图片描述请注意,通道的顺序是相反的:BGR 而不是 RGB。因为在许多情况下,内存足够大,可以连续存储行,这些行可能会一个接一个地跟随,从而创建一个长行。因为所有东西都在一个接一个的地方,所以这可能有助于加快扫描过程。我们可以使用 cv::Mat::isContinu() 函数来询问矩阵是否是这种情况。继续下一部分以查找示例。

高效方式

在性能方面,您无法击败经典的 C 样式运算符[](指针)访问。因此,我们可以推荐的最有效的分配方法是:

Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
    // accept only char type matrices
    CV_Assert(I.depth() == CV_8U);
    int channels = I.channels();
    int nRows = I.rows;
    int nCols = I.cols * channels;
    if (I.isContinuous())
    {
        nCols *= nRows;
        nRows = 1;
    }
    int i,j;
    uchar* p;
    for( i = 0; i < nRows; ++i)
    {
        p = I.ptr<uchar>(i);
        for ( j = 0; j < nCols; ++j)
        {
            p[j] = table[p[j]];
        }
    }
    return I;
}

在这里,我们基本上只是获取一个指向每行开头的指针,并遍历它直到它结束。在矩阵以连续方式存储的特殊情况下,我们只需要请求一次指针并一直到最后。我们需要注意彩色图像:我们有三个通道,因此我们需要在每行中传递三倍以上的项目。
还有另一种方式。Mat 对象的数据数据成员返回指向第一行、第一列的指针。如果此指针为 null,则该对象中没有有效的输入。检查这是检查图像加载是否成功的最简单方法。如果存储是连续的,我们可以使用它来遍历整个数据指针。如果是灰度图像,这看起来像:

uchar* p = I.data;
for( unsigned int i = 0; i < ncol*nrows; ++i)
    *p++ = table[*p];

你会得到相同的结果。但是,此代码以后更难阅读。如果你在那里有一些更先进的技术,那就更难了。此外,在实践中,我观察到你会得到相同的性能结果(因为大多数现代编译器可能会自动为你制作这个小的优化技巧)。

迭代器(安全)方法

在有效的方法的情况下,确保您通过适当数量的 uchar 字段并跳过行之间可能发生的间隙是您的责任。迭代器方法被认为是一种更安全的方法,因为它从用户那里接管了这些任务。您需要做的就是询问图像矩阵的开始和结束,然后增加开始迭代器,直到到达终点。要获取迭代器指向的值,请使用 * 运算符(将其添加到它之前)。

Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table)
{
    // accept only char type matrices
    CV_Assert(I.depth() == CV_8U);
    const int channels = I.channels();
    switch(channels)
    {
    case 1:
        {
            MatIterator_<uchar> it, end;
            for( it = I.begin<uchar>(), end = I.end<uchar>(); it != end; ++it)
                *it = table[*it];
            break;
        }
    case 3:
        {
            MatIterator_<Vec3b> it, end;
            for( it = I.begin<Vec3b>(), end = I.end<Vec3b>(); it != end; ++it)
            {
                (*it)[0] = table[(*it)[0]];
                (*it)[1] = table[(*it)[1]];
                (*it)[2] = table[(*it)[2]];
            }
        }
    }
    return I;
}

对于彩色图像,我们每列有三个 uchar 项目。这可以被认为是 uchar 项目的简短向量,在 OpenCV 中以 Vec3b 名称进行了洗礼。要访问第 n 个子列,我们使用简单的 operator[] 访问。重要的是要记住,OpenCV 迭代器会遍历列并自动跳到下一行。因此,对于彩色图像,如果您使用简单的 uchar 迭代器,您将只能访问蓝色通道值。

带有引用返回的即时地址计算

不建议使用最后一种方法进行扫描。它是为了获取或修改图像中的随机元素而制作的。它的基本用法是指定要访问的项目的行号和列号。在我们早期的扫描方法中,您可能已经注意到,通过我们查看图像的类型很重要。这里没有什么不同,因为您需要手动指定在自动查找中使用的类型。对于以下源代码的灰度图像,您可以观察到这一点(+ cv::Mat::at() 函数的使用):

Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table)
{
    // accept only char type matrices
    CV_Assert(I.depth() == CV_8U);
    const int channels = I.channels();
    switch(channels)
    {
    case 1:
        {
            for( int i = 0; i < I.rows; ++i)
                for( int j = 0; j < I.cols; ++j )
                    I.at<uchar>(i,j) = table[I.at<uchar>(i,j)];
            break;
        }
    case 3:
        {
         Mat_<Vec3b> _I = I;
         for( int i = 0; i < I.rows; ++i)
            for( int j = 0; j < I.cols; ++j )
               {
                   _I(i,j)[0] = table[_I(i,j)[0]];
                   _I(i,j)[1] = table[_I(i,j)[1]];
                   _I(i,j)[2] = table[_I(i,j)[2]];
            }
         I = _I;
         break;
        }
    }
    return I;
}

该函数采用您的输入类型,并协调并计算查询项的地址。然后返回对此的引用。获取值时,这可能是一个常量,在设置值时,这可能是非常量。作为仅在调试模式下*的安全步骤,执行的检查是输入坐标是否有效且确实存在。如果不是这种情况,您将在标准错误输出流上收到一条很好的输出消息。与发布模式下的有效方式相比,使用它的唯一区别是,对于图像的每个元素,您都会获得一个新的行指针,我们使用 C 运算符 [] 来获取列元素。
如果需要使用此方法对图像进行多次查找,则为每个访问输入类型和 at 关键字可能既麻烦又耗时。为了解决这个问题,OpenCV有一个cv::Mat_数据类型。它与 Mat 相同,但需要额外的需求是在定义时您需要通过查看数据矩阵的内容来指定数据类型,但是作为回报,您可以使用 operator() 快速访问项目。为了使事情变得更好,这很容易从通常的cv::Mat数据类型转换。您可以在上述函数的彩色图像中看到其示例用法。尽管如此,重要的是要注意,相同的操作(具有相同的运行时速度)可以使用 cv::Mat::at 函数完成。只是为懒惰的程序员技巧而写的少了。

核心功能

这是在图像中实现查找表修改的奖励方法。在图像处理中,您希望将所有给定的图像值修改为其他值是很常见的。OpenCV提供了修改图像值的功能,无需编写图像的扫描逻辑。我们使用核心模块的 cv::LUT() 函数。首先,我们构建查找表的 Mat 类型:

    Mat lookUpTable(1, 256, CV_8U);
    uchar* p = lookUpTable.ptr();
    for( int i = 0; i < 256; ++i)
        p[i] = table[i];

最后调用函数(I 是我们的输入图像,J 是输出图像):

        LUT(I, lookUpTable, J);

性能差异

为了获得最佳结果,请编译程序并自行运行。为了使差异更加清晰,我使用了相当大的(2560 X 1600)图像。此处介绍的性能适用于彩色图像。为了获得更准确的值,我已经将我从函数调用中获得的值平均了一百次。

MethodTime
Efficient Way79.4717 milliseconds
Iterator83.7201 milliseconds
On-The-Fly RA93.7878 milliseconds
LUT function32.5759 milliseconds

我们可以总结几件事。如果可能的话,使用OpenCV已经完成的功能(而不是重新发明这些)。最快的方法是LUT功能。这是因为 OpenCV 库是通过英特尔线程构建模块启用多线程的。但是,如果您需要编写简单的图像扫描,则首选指针方法。迭代器是一个更安全的赌注,但速度要慢得多。在调试模式下,使用动态参考访问方法进行完整映像扫描的成本最高。在发布模式下,它可能会击败迭代器方法,但它肯定会为此牺牲迭代器的安全特性。
最后,您可以在我们的YouTube频道上发布的视频中观看该程序的示例运行。

矩阵的掩码操作

矩阵上的掩码操作非常简单。这个想法是,我们根据掩码矩阵(也称为内核)重新计算图像中每个像素的值。此蒙版包含的值将调整相邻像素(和当前像素)对新像素值的影响程度。从数学的角度来看,我们使用指定的值进行加权平均值。

我们的测试用例

让我们考虑图像对比度增强方法的问题。基本上,我们希望对图像的每个像素应用以下公式:
I ( i , j ) = 5 ∗ I ( i , j ) − [ I ( i − 1 , j ) + I ( i + 1 , j ) + I ( i , j − 1 ) + I ( i , j + 1 ) ] ⟺ I ( i , j ) ∗ M , w h e r e M = i ∖ j − 1 0 + 1 − 1 0 − 1 0 0 − 1 5 − 1 + 1 0 − 1 0 I(i,j)=5*I(i,j)-[I(i-1,j)+I(i+1,j)+I(i,j-1)+I(i,j+1)] \\ ⟺I(i,j)∗M,where M= \begin{matrix}i∖j & −1 & 0 & +1 \\ −1 & 0 & −1 & 0 \\ 0 & −1 & 5 & −1 \\ +1 & 0 & −1& 0 \end{matrix} I(i,j)=5I(i,j)[I(i1,j)+I(i+1,j)+I(i,j1)+I(i,j+1)]I(i,j)M,whereM=ij10+110100151+1010
第一种表示法是使用公式,而第二种是使用掩码的第一种表示法的压缩版本。通过将掩码矩阵的中心(大写字母中由零零索引表示)放在要计算的像素上,并将像素值乘以重叠的矩阵值相加来使用掩码。这是一回事,但是在大型矩阵的情况下,后一种表示法更容易查看。

代码

您可以从此处下载此源代码,或查看 samples/cpp/tutorial_code/core/mat_mask_operations/mat_mask_operations.cpp 的 OpenCV 源代码库示例目录。

#include <opencv2/imgcodecs.hpp>
#include <opencv2/highgui.hpp>
#include <opencv2/imgproc.hpp>
#include <iostream>
using namespace std;
using namespace cv;
static void help(char* progName)
{
    cout << endl
        <<  "This program shows how to filter images with mask: the write it yourself and the"
        << "filter2d way. " << endl
        <<  "Usage:"                                                                        << endl
        << progName << " [image_path -- default lena.jpg] [G -- grayscale] "        << endl << endl;
}
void Sharpen(const Mat& myImage,Mat& Result);
int main( int argc, char* argv[])
{
    help(argv[0]);
    const char* filename = argc >=2 ? argv[1] : "lena.jpg";
    Mat src, dst0, dst1;
    if (argc >= 3 && !strcmp("G", argv[2]))
        src = imread( samples::findFile( filename ), IMREAD_GRAYSCALE);
    else
        src = imread( samples::findFile( filename ), IMREAD_COLOR);
    if (src.empty())
    {
        cerr << "Can't open image ["  << filename << "]" << endl;
        return EXIT_FAILURE;
    }
    namedWindow("Input", WINDOW_AUTOSIZE);
    namedWindow("Output", WINDOW_AUTOSIZE);
    imshow( "Input", src );
    double t = (double)getTickCount();
    Sharpen( src, dst0 );
    t = ((double)getTickCount() - t)/getTickFrequency();
    cout << "Hand written function time passed in seconds: " << t << endl;
    imshow( "Output", dst0 );
    waitKey();
    Mat kernel = (Mat_<char>(3,3) <<  0, -1,  0,
                                   -1,  5, -1,
                                    0, -1,  0);
    t = (double)getTickCount();
    filter2D( src, dst1, src.depth(), kernel );
    t = ((double)getTickCount() - t)/getTickFrequency();
    cout << "Built-in filter2D time passed in seconds:     " << t << endl;
    imshow( "Output", dst1 );
    waitKey();
    return EXIT_SUCCESS;
}
void Sharpen(const Mat& myImage,Mat& Result)
{
    CV_Assert(myImage.depth() == CV_8U);  // accept only uchar images
    const int nChannels = myImage.channels();
    Result.create(myImage.size(),myImage.type());
    for(int j = 1 ; j < myImage.rows-1; ++j)
    {
        const uchar* previous = myImage.ptr<uchar>(j - 1);
        const uchar* current  = myImage.ptr<uchar>(j    );
        const uchar* next     = myImage.ptr<uchar>(j + 1);
        uchar* output = Result.ptr<uchar>(j);
        for(int i= nChannels;i < nChannels*(myImage.cols-1); ++i)
        {
            *output++ = saturate_cast<uchar>(5*current[i]
                         -current[i-nChannels] - current[i+nChannels] - previous[i] - next[i]);
        }
    }
    Result.row(0).setTo(Scalar(0));
    Result.row(Result.rows-1).setTo(Scalar(0));
    Result.col(0).setTo(Scalar(0));
    Result.col(Result.cols-1).setTo(Scalar(0));
}

基本方法

现在让我们看看如何通过使用基本的像素访问方法或使用 filter2D() 函数来实现这一点。

下面是一个可以执行此操作的函数:

void Sharpen(const Mat& myImage,Mat& Result)
{
    CV_Assert(myImage.depth() == CV_8U);  // accept only uchar images
    const int nChannels = myImage.channels();
    Result.create(myImage.size(),myImage.type());
    for(int j = 1 ; j < myImage.rows-1; ++j)
    {
        const uchar* previous = myImage.ptr<uchar>(j - 1);
        const uchar* current  = myImage.ptr<uchar>(j    );
        const uchar* next     = myImage.ptr<uchar>(j + 1);
        uchar* output = Result.ptr<uchar>(j);
        for(int i= nChannels;i < nChannels*(myImage.cols-1); ++i)
        {
            *output++ = saturate_cast<uchar>(5*current[i]
                         -current[i-nChannels] - current[i+nChannels] - previous[i] - next[i]);
        }
    }
    Result.row(0).setTo(Scalar(0));
    Result.row(Result.rows-1).setTo(Scalar(0));
    Result.col(0).setTo(Scalar(0));
    Result.col(Result.cols-1).setTo(Scalar(0));
}

首先,我们确保输入图像数据采用无符号字符格式。为此,我们使用 cv::CV_Assert 函数,当其中的表达式为 false 时,该函数会抛出错误。

    CV_Assert(myImage.depth() == CV_8U);  // accept only uchar images

我们创建一个与输入大小和类型相同的输出图像。正如您在存储部分看到的,根据通道的数量,我们可能会有一个或多个子列。

我们将通过指针遍历它们,因此元素的总数取决于这个数字。

    const int nChannels = myImage.channels();
    Result.create(myImage.size(),myImage.type());

我们将使用纯 C [] 运算符来访问像素。因为我们需要同时访问多行,所以我们将获取每行的指针(上一行、当前行和下一行)。我们需要另一个指向要保存计算的位置的指针。然后只需使用 [] 运算符访问正确的项目即可。为了向前移动输出指针,我们只需在每次操作后增加它(一个字节):

    for(int j = 1 ; j < myImage.rows-1; ++j)
    {
        const uchar* previous = myImage.ptr<uchar>(j - 1);
        const uchar* current  = myImage.ptr<uchar>(j    );
        const uchar* next     = myImage.ptr<uchar>(j + 1);
        uchar* output = Result.ptr<uchar>(j);
        for(int i= nChannels;i < nChannels*(myImage.cols-1); ++i)
        {
            *output++ = saturate_cast<uchar>(5*current[i]
                         -current[i-nChannels] - current[i+nChannels] - previous[i] - next[i]);
        }
    }

在图像的边界上,上面的符号会导致不存在的像素位置(如减一 - 减一)。在这些方面,我们的公式是不确定的。一个简单的解决方案是不在这些点上应用内核,例如,将边框上的像素设置为零:

    Result.row(0).setTo(Scalar(0));
    Result.row(Result.rows-1).setTo(Scalar(0));
    Result.col(0).setTo(Scalar(0));
    Result.col(Result.cols-1).setTo(Scalar(0));

过滤器2D函数

应用这种过滤器在图像处理中非常普遍,以至于在 OpenCV 中有一个函数可以处理应用掩码(在某些地方也称为内核)。为此,您首先需要定义一个保存掩码的对象:

    Mat kernel = (Mat_<char>(3,3) <<  0, -1,  0,
                                   -1,  5, -1,
                                    0, -1,  0);

然后调用 filter2D() 函数,指定要使用的输入、输出图像和内核:

    filter2D( src, dst1, src.depth(), kernel );

该函数甚至有第五个可选参数来指定内核的中心,第六个参数用于在将过滤像素存储在 K 中之前向过滤像素添加可选值,第七个参数用于确定在未定义操作的区域(边界)中要做什么。

此函数更短,不那么冗长,并且由于有一些优化,因此通常比手动编码方法更快。例如,在我的测试中,第二个只花了 13 毫秒,第一个大约花了 31 毫秒。相当不同的。

例如:
在这里插入图片描述查看在我们的YouTube频道上运行该程序的实例。

操作图像

输入/输出

图像

从文件加载图像:

        Mat img = imread(filename);

如果读取 jpg 文件,则默认创建 3 通道图像。如果需要灰度图像,请使用:

        Mat img = imread(filename, IMREAD_GRAYSCALE);

注意
文件的格式由其内容(前几个字节)决定。要将图像保存到文件:

        imwrite(filename, img);

注意
文件的格式由其扩展名决定。
使用 cv::imdecode 和 cv::imencode 从内存而不是文件读取和写入图像。

图像的基本操作

访问像素强度值

为了获得像素强度值,您必须知道图像的类型和通道数。以下是单通道灰度图像(类型 8UC1)和像素坐标 x 和 y 的示例:

            Scalar intensity = img.at<uchar>(y, x);

仅限C++版本:intensity.val[0] 包含一个从 0 到 255 的值。请注意 x 和 y 的顺序。由于在 OpenCV 中,图像由与矩阵相同的结构表示,因此我们对这两种情况使用相同的约定 - 从 0 开始的行索引(或 y 坐标)排在第一位,从 0 开始的列索引(或 x 坐标)紧随其后。或者,您可以使用以下表示法(仅限C++):

            Scalar intensity = img.at<uchar>(Point(x, y));

现在让我们考虑一个带有 BGR 颜色排序的 3 通道图像(imread 返回的默认格式):

C++ 代码

            Vec3b intensity = img.at<Vec3b>(y, x);
            uchar blue = intensity.val[0];
            uchar green = intensity.val[1];
            uchar red = intensity.val[2];

Python Python

    _blue = img[y,x,0]
    _green = img[y,x,1]
    _red = img[y,x,2]

您可以对浮点图像使用相同的方法(例如,您可以通过在 3 通道图像上运行 Sobel 来获取此类图像)(仅限C++):

            Vec3f intensity = img.at<Vec3f>(y, x);
            float blue = intensity.val[0];
            float green = intensity.val[1];
            float red = intensity.val[2];

相同的方法可用于更改像素强度:

            img.at<uchar>(y, x) = 128;

OpenCV中有一些函数,特别是来自calib3d模块的函数,例如cv::p rojectPoints,它们以Mat的形式获取2D或3D点的数组。 矩阵应该只包含一列,每行对应一个点,矩阵类型应该相应地为32FC2或32FC3。这样的矩阵可以很容易地从 std::vector 构造(仅限C++):

            vector<Point2f> points;
            //... fill the array
            Mat pointsMat = Mat(points);

可以使用相同的方法 Mat::at (仅限C++) 访问此矩阵中的点:

            Point2f point = pointsMat.at<Point2f>(i, 0);

内存管理和引用计数

Mat 是一种保持矩阵/图像特征(行和列数、数据类型等)和数据指针的结构。因此,没有什么能阻止我们拥有多个对应于相同数据的 Mat 实例。Mat 保留一个引用计数,该计数告知在销毁特定 Mat 实例时是否必须释放数据。下面是在不复制数据的情况下创建两个矩阵的示例(仅限C++):

        std::vector<Point3f> points;
        // .. fill the array
        Mat pointsMat = Mat(points).reshape(1);

结果,我们得到一个 32FC1 矩阵,有 3 列,而不是 32FC3 矩阵有 1 列。pointsMat 使用来自点的数据,并且在销毁时不会释放内存。然而,在这种特殊情况下,开发人员必须确保点的生存期比点的生存期长,如果我们需要复制数据,则可以使用 cv::Mat::copyTo 或 cv::Mat::clone 来完成:

        Mat img = imread("image.jpg");
        Mat img1 = img.clone();

与必须由开发人员创建的输出映像的 C API 相反,可以为每个函数提供一个空的输出 Mat。每个实现都为目标矩阵调用 Mat::create。如果矩阵为空,此方法将为矩阵分配数据。如果它不为空并且具有正确的大小和类型,则该方法不执行任何操作。但是,如果大小或类型与输入参数不同,则会释放(并丢失)数据并分配新数据。例如:

        Mat img = imread("image.jpg");
        Mat sobelx;
        Sobel(img, sobelx, CV_32F, 1, 0);

基元操作

矩阵上定义了许多方便的运算符。例如,以下是我们如何从现有的灰度图像 img 制作黑色图像

            img = Scalar(0);

选择感兴趣的区域:

            Rect r(10, 10, 100, 100);
            Mat smallImg = img(r);

从 Mat 到 C API 数据结构的转换(仅限 C++):

        Mat img = imread("image.jpg");
        IplImage img1 = cvIplImage(img);
        CvMat m = cvMat(img);

请注意,此处没有数据复制。

从彩色到灰度的转换:

        Mat img = imread("image.jpg"); // loading a 8UC3 image
        Mat grey;
        cvtColor(img, grey, COLOR_BGR2GRAY);

将映像类型从 8UC1 更改为 32FC1:

        src.convertTo(dst, CV_32F);

可视化图像

在开发过程中查看算法的中间结果非常有用。OpenCV提供了一种可视化图像的便捷方式。可以使用以下命令显示 8U 映像:

        Mat img = imread("image.jpg");
        namedWindow("image", WINDOW_AUTOSIZE);
        imshow("image", img);
        waitKey();

对waitKey()的调用启动一个消息传递循环,该循环等待“图像”窗口中的击键。32F 图像需要转换为 8U 类型。例如:

        Mat img = imread("image.jpg");
        Mat grey;
        cvtColor(img, grey, COLOR_BGR2GRAY);
        Mat sobelx;
        Sobel(grey, sobelx, CV_32F, 1, 0);
        double minVal, maxVal;
        minMaxLoc(sobelx, &minVal, &maxVal); //find minimum and maximum intensities
        Mat draw;
        sobelx.convertTo(draw, CV_8U, 255.0/(maxVal - minVal), -minVal * 255.0/(maxVal - minVal));
        namedWindow("image", WINDOW_AUTOSIZE);
        imshow("image", draw);
        waitKey();

注意

这里 cv::namedWindow 不是必需的,因为它后面紧跟着 cv::imshow。尽管如此,它可以用来更改窗口属性或使用 cv::createTrackbar 时

使用OpenCV混合两张图像

目标

在本教程中,您将学习:

  • 什么是线性混合以及为什么它有用;
  • 如何使用 addWeighted() 添加两个图像

理论

注意
下面的解释属于Richard Szeliski的《计算机视觉:算法和应用》一书。

从我们之前的教程中,我们已经了解了一些像素运算符。一个有趣的二元(双输入)运算符是线性混合运算符:

g ( x ) = ( 1 − α ) f 0 ( x ) + α f 1 ( x ) g(x)=(1−α)f_0(x)+αf_1(x) g(x)=(1α)f0(x)+αf1(x)

通过改变 0→1 之间的α,此运算符可用于在两个图像或视频之间执行时间交叉溶解,如幻灯片和电影制作中所见(很酷,嗯?

源代码

此处下载源代码。

#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"
#include <iostream>
using namespace cv;
// we're NOT "using namespace std;" here, to avoid collisions between the beta variable and std::beta in c++17
using std::cin;
using std::cout;
using std::endl;
int main( void )
{
   double alpha = 0.5; double beta; double input;
   Mat src1, src2, dst;
   cout << " Simple Linear Blender " << endl;
   cout << "-----------------------" << endl;
   cout << "* Enter alpha [0.0-1.0]: ";
   cin >> input;
   // We use the alpha provided by the user if it is between 0 and 1
   if( input >= 0 && input <= 1 )
     { alpha = input; }
   src1 = imread( samples::findFile("LinuxLogo.jpg") );
   src2 = imread( samples::findFile("WindowsLogo.jpg") );
   if( src1.empty() ) { cout << "Error loading src1" << endl; return EXIT_FAILURE; }
   if( src2.empty() ) { cout << "Error loading src2" << endl; return EXIT_FAILURE; }
   beta = ( 1.0 - alpha );
   addWeighted( src1, alpha, src2, beta, 0.0, dst);
   imshow( "Linear Blend", dst );
   waitKey(0);
   return 0;
}

解释

由于我们将要执行:
g ( x ) = ( 1 − α ) f 0 ( x ) + α f 1 ( x ) g(x)=(1−α)f_0(x)+αf_1(x) g(x)=(1α)f0(x)+αf1(x)
我们需要两个源图像(f0(x)和f1(x))。因此,我们以通常的方式加载它们:

   src1 = imread( samples::findFile("LinuxLogo.jpg") );
   src2 = imread( samples::findFile("WindowsLogo.jpg") );

我们使用了以下图像:LinuxLogo.jpgWindowsLogo.jpg

警告
由于我们要添加 src1 和 src2,因此它们的大小(宽度和高度)和类型必须相同。

现在我们需要生成 g(x) 图像。为此,函数addWeighted()非常方便:

   beta = ( 1.0 - alpha );
   addWeighted( src1, alpha, src2, beta, 0.0, dst);

因为 addWeighted() 产生:

d s t = α ⋅ s r c 1 + β ⋅ s r c 2 + γ dst=α⋅src1+β⋅src2+γ dst=αsrc1+βsrc2+γ

在本例中,gamma 是上面代码中的参数 0.0。

创建窗口,显示图像并等待用户结束程序。

   imshow( "Linear Blend", dst );
   waitKey(0);

结果

在这里插入图片描述

调整图像对比度与亮度

目标

在本教程中,您将学习如何:

  • 访问像素值
  • 用零初始化矩阵
  • 了解 cv::saturate_cast 的作用以及为什么它有用
  • 获取有关像素变换的一些很酷的信息
  • 在实际示例中提高图像的亮度

理论

注意
下面的解释属于Richard Szeliski的《计算机视觉:算法和应用》一书。

图像处理

  • 通用图像处理运算符是获取一个或多个输入图像并生成输出图像的函数。
  • 图像转换可以看作是:
    • 点运算符(像素变换)
    • 邻里(基于区域)运营商

像素变换

  • 在这种图像处理转换中,每个输出像素的值仅取决于相应的输入像素值(加上一些全局收集的信息或参数)。
  • 此类运算符的示例包括亮度和对比度调整以及颜色校正和转换。

亮度和对比度调整

  • 两个常用的点过程是乘法和加法,常数:
    g ( x ) = α f ( x ) + β g(x)=αf(x)+β g(x)=αf(x)+β

  • 参数α>0和β通常称为增益和偏置参数;有时这些参数据说分别控制对比度和亮度。

  • 您可以将 f(x) 视为源图像像素,将 g(x) 视为输出图像像素。然后,更方便地我们可以将表达式编写为:

g ( i , j ) = α ⋅ f ( i , j ) + β g(i,j)=α⋅f(i,j)+β g(ij)=αf(ij)+β

  • 其中 i 和 j 表示像素位于第 i 行和第 j 列中。

代码

  • 可下载代码:单击此处
  • 下面的代码执行操作 g(i,j)=α⋅f(i,j)+β :
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"
#include <iostream>
// we're NOT "using namespace std;" here, to avoid collisions between the beta variable and std::beta in c++17
using std::cin;
using std::cout;
using std::endl;
using namespace cv;
int main( int argc, char** argv )
{
    CommandLineParser parser( argc, argv, "{@input | lena.jpg | input image}" );
    Mat image = imread( samples::findFile( parser.get<String>( "@input" ) ) );
    if( image.empty() )
    {
      cout << "Could not open or find the image!\n" << endl;
      cout << "Usage: " << argv[0] << " <Input image>" << endl;
      return -1;
    }
    Mat new_image = Mat::zeros( image.size(), image.type() );
    double alpha = 1.0; /*< Simple contrast control */
    int beta = 0;       /*< Simple brightness control */
    cout << " Basic Linear Transforms " << endl;
    cout << "-------------------------" << endl;
    cout << "* Enter the alpha value [1.0-3.0]: "; cin >> alpha;
    cout << "* Enter the beta value [0-100]: ";    cin >> beta;
    for( int y = 0; y < image.rows; y++ ) {
        for( int x = 0; x < image.cols; x++ ) {
            for( int c = 0; c < image.channels(); c++ ) {
                new_image.at<Vec3b>(y,x)[c] =
                  saturate_cast<uchar>( alpha*image.at<Vec3b>(y,x)[c] + beta );
            }
        }
    }
    imshow("Original Image", image);
    imshow("New Image", new_image);
    waitKey();
    return 0;
}

解释

  • 我们使用 cv::imread 加载图像并将其保存在 Mat 对象中:
    CommandLineParser parser( argc, argv, "{@input | lena.jpg | input image}" );
    Mat image = imread( samples::findFile( parser.get<String>( "@input" ) ) );
    if( image.empty() )
    {
      cout << "Could not open or find the image!\n" << endl;
      cout << "Usage: " << argv[0] << " <Input image>" << endl;
      return -1;
    }
  • 现在,由于我们将对此图像进行一些转换,因此我们需要一个新的 Mat 对象来存储它。此外,我们希望它具有以下功能:

    • 初始像素值等于零
    • 与原始图像相同的大小和类型
    Mat new_image = Mat::zeros( image.size(), image.type() );

我们观察到 cv::Mat::zeros 返回一个基于 image.size() 和 image.type() 的 Matlab 风格的零初始值设定项

  • 现在,我们要求用户输入α和β的值:
    double alpha = 1.0; /*< Simple contrast control */
    int beta = 0;       /*< Simple brightness control */
    cout << " Basic Linear Transforms " << endl;
    cout << "-------------------------" << endl;
    cout << "* Enter the alpha value [1.0-3.0]: "; cin >> alpha;
    cout << "* Enter the beta value [0-100]: ";    cin >> beta;
  • 现在,要执行操作 g(i,j)=α⋅f(i,j)+β我们将访问图像中的每个像素。由于我们正在处理 BGR 图像,因此每个像素将有三个值(B、G 和 R),因此我们也将分别访问它们。这是一段代码:
    for( int y = 0; y < image.rows; y++ ) {
        for( int x = 0; x < image.cols; x++ ) {
            for( int c = 0; c < image.channels(); c++ ) {
                new_image.at<Vec3b>(y,x)[c] =
                  saturate_cast<uchar>( alpha*image.at<Vec3b>(y,x)[c] + beta );
            }
        }
    }

请注意以下内容(仅C++代码):

  • 为了访问图像中的每个像素,我们使用以下语法:image.at(y,x)[c],其中y是行,x是列,c是B,G或R(0,1或2)。
  • 由于运算 α⋅p(i,j)+β 可以给出超出范围的值或不是整数(如果α是浮点数),我们使用 cv::saturate_cast 来确保值有效。
  • 最后,我们创建窗口并按照通常的方式显示图像。
    imshow("Original Image", image);
    imshow("New Image", new_image);
    waitKey();

注意
与其使用 for 循环来访问每个像素,我们可以简单地使用以下命令:

image.convertTo(new_image, -1, alpha, beta);

其中 cv::Mat::convertTo 将有效地执行 new_image = aimage + beta*。但是,我们想向您展示如何访问每个像素。无论如何,这两种方法都会给出相同的结果,但 convertTo 更优化并且工作速度更快。

结果

  • 运行我们的代码并使用 α=2.2 和 β=50
$ ./BasicLinearTransforms lena.jpg
Basic Linear Transforms
-------------------------
* Enter the alpha value [1.0-3.0]: 2.2
* Enter the beta value [0-100]: 50
  • 我们得到这个:
    在这里插入图片描述

实例

在本段中,我们将通过调整图像的亮度和对比度来实践我们学到的校正曝光不足的图像。我们还将看到另一种校正图像亮度的技术,称为伽马校正。

亮度和对比度调整

增加(/减少)β值将为每个像素增加(/减去)一个常量值。超出 [0 ; 255] 范围的像素值将饱和(即高于(/小于)255 (/ 0) 的像素值将被钳位为 255 (/ 0))。
在这里插入图片描述
浅灰色为原始图像的直方图,当亮度 = 80 时为深灰色,为 Gimp

直方图表示每个颜色级别具有该颜色级别的像素数。深色图像将具有许多低颜色值的像素,因此直方图将在其左侧呈现峰值。添加常数偏差时,直方图向右移动,因为我们为所有像素添加了恒定偏差。

α 参数将修改水平的分布方式。如果α<1,则颜色级别将被压缩,结果将是对比度较低的图像。

在这里插入图片描述浅灰色,原始图像的直方图,深灰色,当 Gimp 中的对比度<为 0 时,深灰色

请注意,这些直方图是使用 GIMP 软件中的亮度对比度工具获得的。亮度工具应与β偏置参数相同,但对比度工具似乎与输出范围似乎以 Gimp 为中心的α增益不同(如您在上一个直方图中注意到的那样)。

可能会发生玩β偏差会提高亮度的情况,但同时随着对比度的降低,图像会出现轻微的面纱。α增益可用于缩小这种效果,但由于饱和度,我们将丢失原始明亮区域的一些细节。

伽玛校正

Gamma 校正可用于通过在输入值和映射输出值之间进行非线性变换来校正图像的亮度:
O = ( I 255 ) γ × 255 O=(\frac{I}{255})^γ×255 O=(255I)γ×255
由于这种关系是非线性的,因此所有像素的效果都不会相同,并且取决于它们的原始值。
在这里插入图片描述当γ<1时,原始暗区将更亮,直方图将向右移动,而与γ>1相反。

校正曝光不足的图像

下图已更正为:α=1.3 和 β=40。
在这里插入图片描述作者:Visem (自己的作品) [CC BY-SA 3.0],通过维基共享资源

整体亮度有所提高,但您可以注意到,由于所使用的实现(摄影中的高光剪切)的数字饱和度,云现在已大大饱和。

下图已更正为:γ=0.4。

在这里插入图片描述作者:Visem (自己的作品) [CC BY-SA 3.0],通过维基共享资源

伽马校正应该倾向于增加较少的饱和度效应,因为映射是非线性的,并且没有像以前的方法那样可能出现数值饱和度。

在这里插入图片描述左:阿尔法后的直方图,贝塔校正;中心:原始图像的直方图;右:伽马校正后的直方图

上图比较了三个图像的直方图(三个直方图之间的 y 范围不同)。您可以注意到,大多数像素值位于原始图像直方图的下部。经过α,β修正,我们可以观察到由于饱和和右移而在 255 处出现大峰值。伽马校正后,直方图向右移动,但暗区的像素比明亮区域的像素移位更大(参见伽马曲线图)。

在本教程中,您已经看到了两种调整图像对比度和亮度的简单方法。它们是基本技术,不打算用作光栅图形编辑器的替代品!

代码

本教程的代码在这里

伽马校正代码:

    Mat lookUpTable(1, 256, CV_8U);
    uchar* p = lookUpTable.ptr();
    for( int i = 0; i < 256; ++i)
        p[i] = saturate_cast<uchar>(pow(i / 255.0, gamma_) * 255.0);
    Mat res = img.clone();
    LUT(img, lookUpTable, res);

查找表用于提高计算性能,因为只需要计算一次 256 个值。

其他资源

  • 图形渲染中的伽玛校正
  • 伽玛校正和CRT显示器上显示的图像
  • 数字曝光技术

离散傅里叶变换

目标

我们将寻求以下问题的答案:

  • 什么是傅里叶变换,为什么要使用它?
  • 如何在OpenCV中做到这一点?
  • 函数的使用,例如:copyMakeBorder() , merge() , dft() , getOptimalDFTSize() , log() 和 normalize() 。

源代码

您可以从此处下载,也可以在OpenCV源代码库的示例/cpp/tutorial_code/core/discrete_fourier_transform/discrete_fourier_transform.cpp中找到它。

下面是 dft() 的示例用法:

#include "opencv2/core.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"
#include <iostream>
using namespace cv;
using namespace std;
static void help(char ** argv)
{
    cout << endl
        <<  "This program demonstrated the use of the discrete Fourier transform (DFT). " << endl
        <<  "The dft of an image is taken and it's power spectrum is displayed."  << endl << endl
        <<  "Usage:"                                                                      << endl
        << argv[0] << " [image_name -- default lena.jpg]" << endl << endl;
}
int main(int argc, char ** argv)
{
    help(argv);
    const char* filename = argc >=2 ? argv[1] : "lena.jpg";
    Mat I = imread( samples::findFile( filename ), IMREAD_GRAYSCALE);
    if( I.empty()){
        cout << "Error opening image" << endl;
        return EXIT_FAILURE;
    }
    Mat padded;                            //expand input image to optimal size
    int m = getOptimalDFTSize( I.rows );
    int n = getOptimalDFTSize( I.cols ); // on the border add zero values
    copyMakeBorder(I, padded, 0, m - I.rows, 0, n - I.cols, BORDER_CONSTANT, Scalar::all(0));
    Mat planes[] = {Mat_<float>(padded), Mat::zeros(padded.size(), CV_32F)};
    Mat complexI;
    merge(planes, 2, complexI);         // Add to the expanded another plane with zeros
    dft(complexI, complexI);            // this way the result may fit in the source matrix
    // compute the magnitude and switch to logarithmic scale
    // => log(1 + sqrt(Re(DFT(I))^2 + Im(DFT(I))^2))
    split(complexI, planes);                   // planes[0] = Re(DFT(I), planes[1] = Im(DFT(I))
    magnitude(planes[0], planes[1], planes[0]);// planes[0] = magnitude
    Mat magI = planes[0];
    magI += Scalar::all(1);                    // switch to logarithmic scale
    log(magI, magI);
    // crop the spectrum, if it has an odd number of rows or columns
    magI = magI(Rect(0, 0, magI.cols & -2, magI.rows & -2));
    // rearrange the quadrants of Fourier image  so that the origin is at the image center
    int cx = magI.cols/2;
    int cy = magI.rows/2;
    Mat q0(magI, Rect(0, 0, cx, cy));   // Top-Left - Create a ROI per quadrant
    Mat q1(magI, Rect(cx, 0, cx, cy));  // Top-Right
    Mat q2(magI, Rect(0, cy, cx, cy));  // Bottom-Left
    Mat q3(magI, Rect(cx, cy, cx, cy)); // Bottom-Right
    Mat tmp;                           // swap quadrants (Top-Left with Bottom-Right)
    q0.copyTo(tmp);
    q3.copyTo(q0);
    tmp.copyTo(q3);
    q1.copyTo(tmp);                    // swap quadrant (Top-Right with Bottom-Left)
    q2.copyTo(q1);
    tmp.copyTo(q2);
    normalize(magI, magI, 0, 1, NORM_MINMAX); // Transform the matrix with float values into a
                                            // viewable image form (float between values 0 and 1).
    imshow("Input Image"       , I   );    // Show the result
    imshow("spectrum magnitude", magI);
    waitKey();
    return EXIT_SUCCESS;
}

解释

傅里叶变换会将图像分解为其正弦和余弦分量。换句话说,它将图像从其空间域转换为其频域。这个想法是,任何函数都可以用无限正弦和余弦函数的总和精确地近似。傅里叶变换是一种如何做到这一点的方法。在数学上,二维图像傅里叶变换为:
F ( k , l ) = ∑ i = 0 N − 1 ∑ j = 0 N − 1 f ( i , j ) e − i 2 π ( k i N + l j N ) F(k,l)=\sum_{i=0}^{N-1}\sum_{j=0}^{N-1}f(i,j)e^{-i2\pi(\frac{ki}{N}+\frac{lj}{N})} F(k,l)=i=0N1j=0N1f(i,j)ei2π(Nki+Nlj)
e i x = c o s ( x ) + i s i n ( x ) e^{ix}=cos(x)+i sin(x) eix=cos(x)+isin(x)

这里 f 是其空间域中的图像值,F 是其频域中的图像值。转换的结果是复数。可以通过真实图像和复杂图像或通过幅度和相位图像来显示这一点。然而,在整个图像处理算法中,只有幅度图像是有趣的,因为它包含了我们需要的关于图像几何结构的所有信息。但是,如果您打算以这些形式对图像进行一些修改,然后需要重新转换它,则需要保留这两种形式。

在此示例中,我将展示如何计算和显示傅里叶变换的幅度图像。在数字图像的情况下是离散的。这意味着它们可能会从给定的域值中获取值。例如,在基本的灰度图像中,值通常在 0 到 255 之间。因此,傅里叶变换也需要是离散类型,从而产生离散傅里叶变换(DFT)。每当需要从几何角度确定图像的结构时,您都需要使用它。以下是要遵循的步骤(在灰度输入图像 I 的情况下):

将图像扩展到最佳大小

DFT 的性能取决于图像大小。对于数字 2、3 和 5 的倍数的图像大小,它往往是最快的。因此,为了获得最大的性能,通常最好将边框值填充到图像上以获得具有此类特征的大小。getOptimalDFTSize() 返回此最佳大小,我们可以使用 copyMakeBorder() 函数来扩展图像的边框(附加的像素初始化为零):

    Mat padded;                            //expand input image to optimal size
    int m = getOptimalDFTSize( I.rows );
    int n = getOptimalDFTSize( I.cols ); // on the border add zero values
    copyMakeBorder(I, padded, 0, m - I.rows, 0, n - I.cols, BORDER_CONSTANT, Scalar::all(0));

为复杂价值和真实价值腾出空间

傅里叶变换的结果很复杂。这意味着对于每个图像值,结果是两个图像值(每个组件一个)。此外,频域范围远大于其空间范围。因此,我们通常至少以浮点格式存储这些。因此,我们将输入图像转换为此类型,并使用另一个通道对其进行扩展以保存复杂值:

    Mat planes[] = {Mat_<float>(padded), Mat::zeros(padded.size(), CV_32F)};
    Mat complexI;
    merge(planes, 2, complexI);         // Add to the expanded another plane with zeros

进行离散傅里叶变换

可以是就地计算(与输出相同的输入):

    dft(complexI, complexI);            // this way the result may fit in the source matrix

将实值和复数值转换为量级

复数有一个实数(Re)和一个复数(虚数 - Im)部分。DFT 的结果是复数。DFT的大小为:
M = R e ( D F T ( I ) ) 2 + I m ( D F T ( I ) ) 2 M=\sqrt{Re(DFT(I))^2 + Im(DFT(I))^2} M=Re(DFT(I))2+Im(DFT(I))2
翻译成 OpenCV 代码:

    split(complexI, planes);                   // planes[0] = Re(DFT(I), planes[1] = Im(DFT(I))
    magnitude(planes[0], planes[1], planes[0]);// planes[0] = magnitude
    Mat magI = planes[0];

切换到对数刻度

事实证明,傅里叶系数的动态范围太大,无法显示在屏幕上。我们有一些小的和一些高变化的值,我们无法像这样观察。因此,高值将全部变为白点,而小值将变为黑色。要使用灰度值进行可视化,我们可以将线性刻度转换为对数刻度:
M 1 = l o g ( 1 + M ) M_1=log(1+M) M1=log(1+M)
翻译成 OpenCV 代码:

    magI += Scalar::all(1);                    // switch to logarithmic scale
    log(magI, magI);

裁剪和重新排列

还记得,在第一步,我们扩展了图像吗?好吧,是时候扔掉新引入的价值观了。出于可视化目的,我们还可以重新排列结果的象限,使原点(零,零)与图像中心相对应。

    // crop the spectrum, if it has an odd number of rows or columns
    magI = magI(Rect(0, 0, magI.cols & -2, magI.rows & -2));
    // rearrange the quadrants of Fourier image  so that the origin is at the image center
    int cx = magI.cols/2;
    int cy = magI.rows/2;
    Mat q0(magI, Rect(0, 0, cx, cy));   // Top-Left - Create a ROI per quadrant
    Mat q1(magI, Rect(cx, 0, cx, cy));  // Top-Right
    Mat q2(magI, Rect(0, cy, cx, cy));  // Bottom-Left
    Mat q3(magI, Rect(cx, cy, cx, cy)); // Bottom-Right
    Mat tmp;                           // swap quadrants (Top-Left with Bottom-Right)
    q0.copyTo(tmp);
    q3.copyTo(q0);
    tmp.copyTo(q3);
    q1.copyTo(tmp);                    // swap quadrant (Top-Right with Bottom-Left)
    q2.copyTo(q1);
    tmp.copyTo(q2);

正规化

出于可视化目的,再次执行此操作。我们现在有了星等,但这仍然超出了我们从0到1的图像显示范围。我们使用 cv::normalize() 函数将我们的值规范化到这个范围。

    normalize(magI, magI, 0, 1, NORM_MINMAX); // Transform the matrix with float values into a
                                            // viewable image form (float between values 0 and 1).

结果

一个应用思路是确定图像中存在的几何方向。例如,让我们找出文本是否水平?查看一些文本,您会注意到文本行也形成水平线,字母形成垂直线。文本片段的这两个主要组成部分也可以在傅里叶变换的情况下看到。让我们使用这个水平和这个关于文本的旋转图像。

如果是横排文本:
在这里插入图片描述如果是旋转文本:
在这里插入图片描述您可以看到频域中影响最大的分量(幅度图像上最亮的点)遵循图像上对象的几何旋转。由此我们可以计算偏移量并执行图像旋转以纠正最终的未命中对齐。

使用XML和YAML格式的文件输入与输出

详见。

与 OpenCV 1 的互操作性

详见。

如何使用OpenCV parallel并行化你的代码

详见。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值