很早就说要写一个图像编辑方面的专题了,可惜懒得很,而且前段时间一直感冒,没心情。元旦了,室友也全回家了,独守空房,无以慰藉,只能写写博文打发打发时间了。
从原理上来说,进行亮度调整无非两种渠道:转换到HSL或HSV(HSB)颜色空间,直接对L或者V进行调整。或者直接对RGB三个通道同时进行调整以达到调整亮度的效果。又可以细分为大约四种方法:
这可以说是最直观但也是最低效的方法:因为HSL(HSV同理)颜色空间天然有一个L分量表示亮度,直接进行调整即可。但是这种方法有很大的缺陷就是低效:因为计算机屏幕本身的特性决定了绝大多数的图片文件解析完毕后是RGB颜色空间,于是就需要从RGB转换成HSL,调整L,转换回RGB显示。虽然RGB转HSL和HSL转RGB并不复杂(Wiki),一次转换基本等同于10次浮点乘的运算量,但是考虑到对于图片上每个像素都要做这样的计算,其效率就极为低下了:一张1000*1000的图,需要做1亿次浮点乘法运算,效率可想而知。虽然可以通过一些近似计算来减少一些计算量,但终归不是一种好的方法。
这么做基于以下两个理由: 1.RGB颜色空间本身就是源于物体发光,每个通道上的值表示的是该通道上的光强,调整光强即是调整亮度。 2.亮度(lightness)的计算公式为: l = (max(rgb) + min(rgb)) / 2,所以同时对三个通道进行调整也就近似地直接调整l值。
void AdjustBrightness(TiBitmapData& bitmap,double level)
{
TINYIMAGE_ASSERT_VOID(level >= -1.0 && level <= 1.0);
double delta = level + 1;
u8 lookup[256] = {0};
for (int i = 0; i < 256; i ++)
{
lookup[i] = (u8)CLAMP0255(i * delta);
}
AdjustCurve(bitmap,lookup,TINYIMAGE_CHANEL_RGB);
}
//指定通道曲线调整
void AdjustCurve(TiBitmapData& bitmap,u8 (&lookup)[256],TINYIMAGE_CHANNEL channel)
{
int width = bitmap.GetWidth();
int height = bitmap.GetHeight();
int stride = bitmap.GetStride();
int bpp = bitmap.GetBpp();
u8* bmpData = bitmap.GetBmpData();
int offset = stride - width * bpp;
switch (channel)
{
case TINYIMAGE_CHANEL_R:
{
for (int i = 0; i < height; i ++)
{
for (int j = 0; j < width; j++)
{
bmpData[rIndex] = lookup[bmpData[rIndex]];
bmpData += bpp;
}
bmpData += offset;
}
break;
}
case TINYIMAGE_CHANEL_G:
{
for (int i = 0; i < height; i ++)
{
for (int j = 0; j < width; j++)
{
bmpData[gIndex] = lookup[bmpData[gIndex]];
bmpData += bpp;
}
bmpData += offset;
}
break;
}
case TINYIMAGE_CHANEL_B:
{
for (int i = 0; i < height; i ++)
{
for (int j = 0; j < width; j++)
{
bmpData[bIndex] = lookup[bmpData[bIndex]];
bmpData += bpp;
}
bmpData += offset;
}
break;
}
case TINYIMAGE_CHANEL_RGB:
{
for (int i = 0; i < height; i ++)
{
for (int j = 0; j < width; j++)
{
bmpData[rIndex] = lookup[bmpData[rIndex]];
bmpData[gIndex] = lookup[bmpData[gIndex]];
bmpData[bIndex] = lookup[bmpData[bIndex]];
bmpData += bpp;
}
bmpData += offset;
}
break;
}
default:
{
assert(false);
break;
}
}
}
可以看出为了提高速度,一般会构造一个LookUp Table事先计算某个光强调整后的数值,在真正进行像素调整时只需要将这个值赋值给相应的像素即可,这样对1000*1000图基本就只是100万次赋值操作而已。
上面两种方法进行亮度调整的时候都会有一个通病:图片亮度的变化没有层次感,往往是一整片区域一起变亮或者变暗。而接下来的两种方法虽然同样是在RGB通道上进行调整,却可以避免这个问题。曲线调整其实就是第二种方法的变种:从算法上来说和第二种方法完全一样,唯一的不同是Lookup Table是用户构造,而不是我们通过亮度变化系数计算得到。这个方法的示例可以参考PhotoShop中的曲线工具。在作用通道是RGB通道时就是对亮度调整,这种方法的好处在于:他是通过直观的表现反馈给用户,用户可以继续进行曲线调整直到调整出满意的效果来,是最直观而且最准确的一种方法。而唯一的问题是:那个曲线控件的实现相对麻烦……
从概念上来说色阶调整并不难懂,也是在各个通道上进行光强值计算和替换。当它作用于RGB通道,而不是某个单独的通道时就可以认为是在对图片亮度进行调整。色阶调整一共有五个参数:黑场阈值,白场阈值,灰场(即gamma值),输出黑场色阶,输出白场色阶(简单处理,可以设定输出黑场色阶为0,即纯黑,输出白场色阶为255,即纯白)。如图(截图来自PhotoShop):
调整黑场阈值,所有低于黑场阈值的点都被设置为输出黑场色阶,这样就合并了暗调区域。
调整白场阈值,所有高于白场阈值的点都被设置为输出白场阈值,这样就合并了高光区域。
调整gamma值,使得各个部分的亮度更加均匀。
像素点x,设某通道上的值为Lx 则有:
如上面那张直方图所显示的,即使不看原图也可以知道原图的大致情况:偏灰,层次感不够。
调整完毕后的直方图为:
相比于原直方图而言,调整完毕后的直方图柱形分布更加均匀,图片能呈现出一种层次感。对比调整前后的图片,更能直观地看出差别:
可以看出调整前图片灰蒙蒙一片,天空,树和房子基本感觉是同一个层次,而调整后各个景物的层次就比较鲜明,而且也不再是灰蒙蒙一片了。
说完算法本身,来说说算法背后的一些原理。Gamma校验的方法之所有有效,很大原因是人眼对亮度的感受是呈一种类似等比数列的形式,基本可以认为在0-255之间,我们实际上只能感觉到只有8等亮度而已,而过多的亮度值集中在了一个区间内,对人眼而言实际上是无法分辨的,从而造成图片是灰蒙蒙一片或者一片亮的感觉。而上面的方法正是通过合并了暗部和亮部,同时将亮度比较均匀分到了各个区间内,从而使得图像呈现出层次感。(虽然直方图中有很多空洞,图片也可能损失了一些颜色,但并没有太大影响)
原理讲清楚了,代码就很简单了,如下:
void AdjustLevels(TiBitmapData& bitmap,int blackThreshold,int whiteThreshold,double gamma,TINYIMAGE_CHANNEL channel)
{
TINYIMAGE_ASSERT_VOID(blackThreshold>= 0 && whiteThreshold>blackThreshold && whiteThreshold<=255);
TINYIMAGE_ASSERT_VOID(gamma >= 0.0 && gamma <= 10.0);
u8 lookup[256] = {0};
//小于黑场阈值都设成0
for (int i = 0; i < blackThreshold; i ++)
{
lookup[i] = 0;
}
//中间部分做gamma校正
double ig = (gamma == 0.0) ? 0.0 : 1 / gamma;
double threshold = (double)(whiteThreshold - blackThreshold);
for (int i = blackThreshold; i < whiteThreshold; i++)
{
lookup[i] = (u8)CLAMP0255( pow((i-blackThreshold)/threshold,ig)*255);
}
//大于白场阈值都设为255
for (int i = whiteThreshold; i < 256; i++)
{
lookup[i] = 255;
}
AdjustCurve(bitmap,lookup,channel);
}