一. 本章目标
① 目标
本教程中我们将学习如何:
访问像素值
用零初始化矩阵
学习cv::saturate_cast是做什么的,以及它有什么用
获取关于像素变换的一些比较酷的信息
提高图像亮度的一个实际例子
② 理论
注意:
下面的解释来自
Richard Szeliski
的《计算机视觉:算法和应用》一书
图像处理
- 一般的图像处理运算符是一个函数,它接收一个或多个输入图像并生成输出图像.
- 图像变换可以看成是这样的
-点运算符(像素变换)
-邻域(基于区域)的运算符
像素变换
- 在这种图像处理变换中,每个输出像素的值仅依赖于相应的输入像素值(可能还要加上一些全局采集的信息或者参数)
- 这里操作的例子包括亮度和对比度的调整以及颜色校正和变换.
亮度和对比度调整:
-
两种常用的点处理方法是乘以一个常数再加一个常数
-
通常来说 α > 0 和 β 被称为增益和偏值参数.有时候这些参数也被分别称为控制对比度和亮度参数
-
你可以认为f(x)是源图像像素,g(x)是输出图像像素. 然后,更直观的表达式可以写成:
其中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 EXIT_FAILURE;
}
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(0);
return EXIT_SUCCESS;
}
解释
我们使用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对象来存储它.此外,我们希望它具有如下的特性:
- 初始像素值全部是0
- 大小和类型与原图像相同
Mat new_image = Mat::zeros( image.size(), image.type() );
我们注意到cv::Mat::zeros
返回一个基于image.size()
和image.type()
的matlab
风格的全部为0的初始化器.
我们现在要求用户输入α和β的值:
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<Vec3b>(y,x)[c]
,其中y
是行,x
是列,c
是B
,G
,R
(0,2,或2)- 由于操作
α⋅p(i,j)+β
可以给出范围外的值或不是整数(如果α是浮点数),我们使用cv::saturate_cast
来确保这些值是有效的- 最后,我们创建窗口并显示图像,通常的做法是:
imshow("Original Image", image);
imshow("New Image", new_image);
waitKey(0);
注意:
我们可以简单地使用以下的命令,而不是使用
for
循环来访问每个像素
image.convertTo(new_image, -1, alpha, beta);
其中cv::Mat::convertTo
将有效地执行new_image = a * image +beta
. 然而,我们想向您展示如何访问每个像素.在任何情况下,这两种方式得到的结果是相同的.但是convertTo
更优化,工作速度更快.
结果:
运行我们的代码并且使用 α=2.2 and β=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))
浅灰色为原始图像的直方图,深灰色为在Gimp中亮度=80的时候
直方图表示每个颜色级别对应的像素数.深色图像会有很多颜色值较低的像素,因此直方图的左侧会小狐仙一个峰值.当添加一个恒定的偏差时,直方图会向右移动,因为我们已经为所有的像素添加了一个恒鼎的偏差.
α参数将改变能级的扩散方式。 如果α<1,颜色等级将被压缩,结果将是一个对比度较低的图像
浅灰色为原始图像的直方图,深灰色是在Gimp中对比度小于0的时候
请注意,这些直方图是使用Gimp软件中的亮度-对比度工具获取的.亮度工具应该与β偏置参数相同,但是对比度工具视乎和α增益不同,其中输出范文似乎以Gimp
为中心(正如你可以在之前的直方图注意到的那样).
使用β偏置可以提高亮度,但同时图像会小狐仙轻微的遮盖,因为对比度会降低.α增益可以用来减少这种效果,但由于饱和度,我们将失去在原来的明亮区域的一些细节.
② 伽马校正
伽马校正可以通过使用输入值和映射输出值之间的非线性变换来矫正图像的亮度:
由于这种关系是非线性的,因此效果对所有像素都不一样,取决于它们的原始值.
绘制不同伽马值的结果图
当γ<1,原来的暗区域会变量,直方图会向右移动,而当γ>1的时候则相反.
③ 纠正曝光不足的图像
下面的图像已经被修正: α=1.3, β=40。
整体亮度提高了,但是你可以注意到,由于使用了数值饱和度(照片中的高光剪切),云层现在非常的饱和.
下面的图像被纠正:
γ=0.4 .
由于映射是非线性的,并且不可能像前一种方法那样存在数值饱和,因此伽马校正应该倾向于增加较少的饱和效果.
上图比较了三幅图像的直方图(三幅直方图的y范围不相同).你可以注意到大多数像素值都位于原始图像直方图的下方. 经过α, β校正后,由于饱和导致右移,我们可以在255处观察到一个较大的峰.高兴过伽马校正后,直方图向右偏移,但暗区域的相比两区域的像素偏移更大(见伽马曲线图)
在本教程中,你已经看到了两种简单的方法来调整图像的对比度和亮度.他们是基本的技术,不打算用来代替光栅图形编辑器
代码:
伽马校正的代码
Mat lookUpTable(1,256,CU_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个值
本教程的完整代码
#include <iostream>
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"
// we're NOT "using namespace std;" here, to avoid collisions between the beta variable and std::beta in c++17
using std::cout;
using std::endl;
using namespace cv;
namespace
{
/* Global Variables*/
int alpha = 100;
int beta = 100;
int gammaCor = 100;
Mat imgOriginal, imgCorrected, imgGammaCorrected;
void basicLinearTransform(const Mat &img, const double alpha_, const int beta_)
{
Mat res;
img.convertTo(res, -1, alpha_, beta_);
hconcat(img, res, imgCorrected); // 图像拼接
imshow("Brightness and contrast adjustments", imgCorrected);
}
void gammaCorrection(const Mat &img, const double gamma_)
{
CV_Assert(gamma_ >= 0);
//! [changing-contrast-brightness-gamma-correction]
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);
//! [changing-contrast-brightness-gamma-correction]
hconcat(img, res, imgGammaCorrected);
imshow("Gamma correction", imgGammaCorrected);
}
void onLinearTransformAlphaTrackbar(int, void *)
{
double alphaValue = alpha / 100.0;
int betaValue = beta - 100;
basicLinearTransform(imgOriginal, alphaValue, betaValue);
}
void onLinearTransformBetaTrackbar(int, void *)
{
double alphaValue = alpha / 100.0;
int betaValue = beta - 100;
basicLinearTransform(imgOriginal, alphaValue, betaValue);
}
void onGammaCorrectionTrackbar(int, void *)
{
double gammaValue = gammaCor / 100.0;
gammaCorrection(imgOriginal, gammaValue);
}
}
int main(int argc,char** argv)
{
CommandLineParser parser(argc, argv, "{@input | lena.jpg | input image}");
imgOriginal = imread(samples::findFile(parser.get<String>("@input")));
if (imgOriginal.empty())
{
cout << "Could not open or find the image!\n" << endl;
cout << "Usage: " << argv[0] << "<Input image> " << endl;
return EXIT_FAILURE;
}
imgCorrected = Mat(imgOriginal.rows, imgOriginal.cols * 2, imgOriginal.type());
imgGammaCorrected = Mat(imgOriginal.rows, imgOriginal.cols * 2, imgOriginal.type());
hconcat(imgOriginal, imgOriginal, imgCorrected);
hconcat(imgOriginal, imgOriginal, imgGammaCorrected);
namedWindow("Brightness and constrast adjustments");
namedWindow("Gamma correction");
createTrackbar("Alpha gain(contrast)", "Brightness and constrast adjustments",
&alpha, 500, onLinearTransformAlphaTrackbar);
createTrackbar("Beta bias(brightness)", "Brightness and constrast adjustments",
&beta, 200, onLinearTransformBetaTrackbar);
createTrackbar("Gamma correction", "Gamma correction",
&gammaCor, 200, onGammaCorrectionTrackbar);
onLinearTransformAlphaTrackbar(0, 0);
onGammaCorrectionTrackbar(0, 0);
waitKey(0);
imwrite("linear_transform_correction.png", imgCorrected);
imwrite("gamma_correction.png", imgGammaCorrected);
return EXIT_SUCCESS;
}
三. 图形渲染中的伽马校正(附加资源)
一旦我们遇到要计算最终的像素颜色的情况,我们将不得不显示它们在显示器上.在过去的数字成像时代,大多数显示器是阴极射线管(CRT)显示器.这些显示器的物体特性是:两倍的输入电压不会导致两倍的亮度.将输入的电压增加一倍,其亮度域显示器的伽马值大致为2.2的指数关系.这恰好与人类测量亮度的方式非常的吻合,因为朗读也显示出类似的(逆)幂关系.为了更好的理解这一切意味着什么,请看下面的图片:
上面的线看起来像人眼的正确的亮度刻度,亮度翻了一番(例如从0.1到0.2)确实看起来像两倍的亮度,因为零度具有良好的一致性差异.大事,当我们谈论光的物理量度时,例如离开光源的光子数量,底部刻度实际上显示了正确的亮度.在底部的刻度中,双倍的亮度返回正确的物理亮度,但由于我们的眼睛对亮度的感知不同(更容易受深色变化的影响),所以它看起来很奇怪.
由于人眼喜欢根据高比例看到亮度颜色,显示器(至今仍是)使用幂关系来显示输出颜色,从而将原始物理强度颜色映射到最高比例中的非线性亮度颜色
这种非现象映射的监视器能够输出对于我们的眼睛来说更令人愉快的结果,但是,当谈到渲染图形的时候,有一个问题:
我们在我们的应用程序中配置的所有的颜色和亮度都是基于我们我们从显示器那里看到的,因此多有的选项都是非线性亮度/颜色.看看下面的图表:
虚线表示线性空间中的颜色/光值,实线表示显示器显示的颜色空间.如果我们在线性空间中对一种颜色加倍,其结果确实是值加倍.例如,取一个光的颜色向量(0.5,0.0,0.0),它代表一个半暗红色的光.如果我们将这个光在线性空间中加倍,它将变成(1.0,0.0,0.0),如图所示.但是从图中可以看到,原始颜色在显示器上显示为(0.218,0.0,0.0).这就是问题出现的地方:一旦我们将线性空间中的暗红色增加一倍,它在显示器上亮度就会增加4.5倍以上.
在本章之前,我们一直假设我们是在线性空间中工作,但我们实际上是在显示器的输出空间中工作,所以我们配置所有颜色和照明变量在物理上都是不正确的,而只是在显示器上看起来(有点)正确.出于这个原因,我们(以及美工)通常会将照明值设置得比实际更亮(因为显示器会将其变暗),这导致大多数线性空间计算不正确.注意显示器(CRT)和线性图开始和结束的位置相同;中间值会被显示屏变暗
因为颜色是根据显示器的输出配置的,所以线性空间中的所有中间(照明)计算在物理上都是不正确的.随着更高级的找平算法的使用,这点将变得更加明显,如下图所示:
你可以看到伽马校正,(更新过)的颜色值看起来结合的更漂亮一些并且深色区域显示更多的细节.总的来说,经过一些小的修改,图像质量更高.
没有正确的纠正这个显示器伽马,照明看起来错误,艺术家将会有很难得到显示和好看的结果的情况.解决办是应用伽马校正.
伽马校正
伽马校正的思想是在显示器显示之前应用显示器伽马的倒数得到最终的输出颜色.回头看本章前面的伽马曲线图,我们看到另一条虚线,它是显示器伽马曲线的倒数.我们将每一个线性输出颜色乘以这个逆伽马曲线(使得它们更亮),一旦这些颜色在显示器上显示出来,显示器的伽马曲线就被应用,得到的颜色就变成线性的.我们有效地使中间颜色变量,这样一旦显示器变暗它们,就会平衡所有颜色.
我们再举一个例子.假设我们还是深红色(0.5,0,0).在显示器显示该颜色之前,我们首先对颜色应用伽马校正曲线.显示器显示线性颜色大致是2.2的倍数,所以反比需要将颜色缩放为1/2.2的倍数.因此伽马校正后的深红色颜色变为
(0.5,0.0.0.0)^(1/2.2) = (0.73,0.0,0.0).然后,将校正后的颜色输入显示器,结果颜色为(0.73,0,0)^2.2 = (0.5,0,0).你可以看到,通过使用伽马校正,现在显示器最终显示的颜色为我们再应用程序中线性设置的那样.
2.2的伽马值是默认的伽马值,它粗略地估计了大多数显示的平均值.在颜色空间中,这个作为2.2倍伽马的结果被称为
sRGB
颜色空间(不是100%准确,但接近).每个显示器都有自己的伽马曲线,但是在大多数显示器上,伽马值为2.2就可以得到很好的结果.出于这个原因,游戏通常允许玩家改变游戏的伽马设置,因为每个显示器的伽马设置略有不同.
有两种方法可以对场景进行gamma
校正:
通过使用OpenGL内置的sRGB帧缓冲区支持
通过在片段着色器中自己做伽马校正
第一个选项可能是最简单的,但是也给你较少的控制权.通过启用GL_FRAMEBUFFER_SRGB
,你告诉OpenGL
每个后续绘制命令应该首先gamma
正确的颜色(从sRGB
颜色空间),然后将它们存储在颜色缓冲区(s).sRGB
是一个颜色空间,大致对应伽马2.2和大多数设备的标准.在启用GL_FRAMEBUFFER_SRGB
后,
OpenGL
在每个片段着色器运行到所有后续帧缓冲区(包括默认帧缓冲区)后自动执行伽马校正.
启用GL_FRAMEBUFFER_SRGB
就像调用glEnable
一样简单:
glEnable(GL_FRAMEBUFFER_SRGB);
从现在开始,你渲染图像将进行伽马校正,因为这是由硬件完成的,它是完全没有花费的.关于这种方法(以及其他方法),你应该记住的是伽马校正(也)将颜色从线性恐案换到非线性空间,所以只在最后一步进行伽马校正是非常重要的.如果在最终输出之前对颜色进行伽马校正,那么对这些颜色的所有后续操作都将对不正确的值进行操作.例如,如果你使用多个framebuffer
,你可能希望在framebuffer
之间传递的中间结果保持在线性空间中,只有最后一个framebuffer
在发送给显示器之前应用伽马校正
第二种方法需要更多的工作,但是我们可以完全控制伽马操作.我们在每个相关的随便着色器运行结束时应用伽马校正,所以最终的颜色在发送到显示器之前结束伽马校正:
void main()
{
// do super fancy lighting in linear space
[...]
// apply gamma correction
float gamma = 2.2;
FragColor.rgb = pow(fragColor.rgb.vec3(1.0/gamma));
}
最后一行代码有效地将fragColor
的每个单独的颜色提到到1.0/gamma
,就正这个片段着色器运行的输出颜色.
这种方法的一个问题是,为了保持一致,你必须对每个碎片着色器应用伽马校正,这有助于最终输出.如果你有12个碎片着色器用于多个对象,你必须添加gamma
校正代码到每个这些着色器.一个更简单的解决方案是在你的渲染循环中引入一个后期处理截断,并在后期处理的四边形上应用伽马校正作为最后一步,你只需要做一次.
这一行代表了伽马校正的技术实现.不是所有的都太令人印象深刻,但是又一些额外的事情,你必须考虑再做伽马校正.
sRGB 纹理
因为显示器使用伽马显示颜色,所以每当你在计算机上画画,编辑,绘制一幅图像时,你都是根据在显示器看到的内容选择颜色.这充分说明你创建或编辑的所有图芯片不是在线性空间,而是在sRGB
空间,例如根据感知到的亮度再屏幕上加倍暗红色,不等于加倍红色分量.
因此,当纹理美术师通过眼睛创造图像时,所有纹理的值都在sRGB
空间中,所以如果我们在渲染应用中使用了这些纹理,我们必须考虑到这一点.在我们知道伽马校正之前,这并不是一个真正的问题,因为纹理在sRGB
空间中看起来很好,这也是我们工作的空间:纹理显示完全一样,这是好的,然而,现在我们再线性空间中显示所有内容,纹理颜色将关闭,如下图所示:
纹理图像太亮了,这是因为它实际上被伽马校正了两次! 想想看,当我们基于在显示器上看到的内容创阿金图像时,我们有效地对图像的颜色进行了伽马校正,以便它在监视器上看起来正确.因为我们渲染器中再次使用了伽马校正,图像最终变得太亮了.
为了解决这个问题,我们必须确保纹理艺术家在线性空间工作.然而,由于在sRGB
空间中工作更容易,而且大多数工具甚至不正确地支持线性纹理,这可能不是首选的解决方案.
另一个解决方案是在对它们的颜色进行任何计算之前,重新纠正或者转换这些sRGB
纹理到线性空间.我们可以这样做:
float gamma = 2.2;
vec3 diffuseColor = pow(texture(diffuse,texCoords).rgb,vec3(gamma));
对sRGB
空间中的每个纹理都这样做事相当麻烦的.幸运的是,OpenGL
给了我们另一个解决方案,通过给我们GL_SRGB
和GL_SRGB_ALPHA
内部纹理格式
如果我们用这两种sRGB
纹理格式中的任何一种在OpenGL
中创建纹理,OpenGL
将在我们使用它们的时候自动将颜色纠正到线性空间,允许我们正确地在线性空间中工作.我们可以如下方式指定一个纹理为sRGB
纹理:
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
如果你还想在纹理中包含alpha
组件,你必须指定纹理的内部格式为GL_SRGB_ALPHA
.
当你再sRGB
空间中指定纹理时,你应该小心,因为不是所有的纹理实际上都会在sRGB
中.用于着色对象的纹理(如漫反射纹理)几乎总是在sRGB
空间中.用于检索照明参数的纹理(如高光贴图和法线贴图)几乎总是在线性空间中,所以如果你将这些配置为sRGB
纹理,照明将看起来很奇怪.在指定为sRGB
的纹理时要小心.
我们的漫反射纹理指定为sRGB
纹理,你会再次得到你期望的视觉输出,但是这一次所有的伽马校正就只有一次.
弱化
伽马校正的另一个不同之处是光照的弱化.在真实的物理世界中,光纤的衰减与光源距离的平方成反比.在普通的英语中,它的意思是光的强度随着光源距离的平方而减小,如下图所示:
float attenuation = 1.0 / (distance * distance);
然而,在使用这个方程时,衰减效应通常太强烈了,使得光线的半径很小,看起来不太对.出于这个原因,我们使用了其他的衰减函数(就像我们再基础照明章节中讨论的)来提供更多的控制,或者使用线性变换:
float attenuation = 1.0 / distance;
与没有伽马校正的二次变换相比,线性等效式给出了更可信的结果,但当我们进行伽马校正的时候,线性衰减看起来太弱,而无力上正确的二次衰减会突然给出更好的结果.下图显示了不同之处:
造成这种差异的原因是光衰减函数会改变亮度,因为我们不能在线性空间中可视化我们的场景,所以我们选择了再显示器上看起来最好的衰减函数,但物理上并不正确.考虑衰减函数的平方:如果我们使用这个函数而不进行伽马校正,衰减函数在显示器上显示时有效地变成:(1.0/distance^2)^2.2
.这与我们最初的预期相比造成了更大的弱化.这也解释了为什么线性等价在没有伽马校正的情况下更有意义,因为它有效地变成了(1.0/distance)^2.2 = 1.0 / distanec^2.2
,这与它的物理等价更近似.
总的来说,gamma
校正允许我们在线性空间中进行所有的着色器/照明计算.因为线性空间在物理世界中是有意义的,大多数物理方程在实际上给出了很好的结果(比如真实的光衰减).你的照明越先进,就越容器得到好看(和现实)的伽马校正结果.这也是为什么它建议只调整你的照明阐述一旦你用伽马校正到位.