图片编辑之亮度调整

很早就说要写一个图像编辑方面的专题了,可惜懒得很,而且前段时间一直感冒,没心情。元旦了,室友也全回家了,独守空房,无以慰藉,只能写写博文打发打发时间了。

从原理上来说,进行亮度调整无非两种渠道:转换到 HSL 或 HSV(HSB)颜色空间,直接对 L 或者 V 进行调整。或者直接对 RGB 三个通道同时进行调整以达到调整亮度的效果。又可以细分为大约四种方法:

1. 转换到 HSL(HSV)颜色空间调整

这可以说是最直观但也是最低效的方法:因为 HSL(HSV 同理)颜色空间天然有一个 L 分量表示亮度,直接进行调整即可。但是这种方法有很大的缺陷就是低效:因为计算机屏幕本身的特性决定了绝大多数的图片文件解析完毕后是 RGB 颜色空间,于是就需要从 RGB 转换成 HSL,调整 L,转换回 RGB 显示。虽然 RGB 转 HSL 和 HSL 转 RGB 并不复杂(Wiki),一次转换基本等同于 10 次浮点乘的运算量,但是考虑到对于图片上每个像素都要做这样的计算,其效率就极为低下了:一张 1000*1000 的图,需要做 1 亿次浮点乘法运算,效率可想而知。虽然可以通过一些近似计算来减少一些计算量,但终归不是一种好的方法。

2. 同时对 RGB 通道进行线性调整

这么做基于以下两个理由: 1.RGB 颜色空间本身就是源于物体发光,每个通道上的值表示的是该通道上的光强,调整光强即是调整亮度。

  1. 亮度 (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 万次赋值操作而已。

3. 曲线调整

上面两种方法进行亮度调整的时候都会有一个通病:图片亮度的变化没有层次感,往往是一整片区域一起变亮或者变暗。而接下来的两种方法虽然同样是在 RGB 通道上进行调整,却可以避免这个问题。曲线调整其实就是第二种方法的变种:从算法上来说和第二种方法完全一样,唯一的不同是 Lookup Table 是用户构造,而不是我们通过亮度变化系数计算得到。这个方法的示例可以参考 PhotoShop 中的曲线工具。在作用通道是 RGB 通道时就是对亮度调整,这种方法的好处在于:他是通过直观的表现反馈给用户,用户可以继续进行曲线调整直到调整出满意的效果来,是最直观而且最准确的一种方法。而唯一的问题是:那个曲线控件的实现相对麻烦……

4. 色阶调整(Gamma 校验)

从概念上来说色阶调整并不难懂,也是在各个通道上进行光强值计算和替换。当它作用于 RGB 通道,而不是某个单独的通道时就可以认为是在对图片亮度进行调整。色阶调整一共有五个参数:黑场阈值,白场阈值,灰场(即 gamma 值),输出黑场色阶,输出白场色阶(简单处理,可以设定输出黑场色阶为 0,即纯黑,输出白场色阶为 255,即纯白)。如图(截图来自 PhotoShop): 此处输入图片的描述

调整黑场阈值,所有低于黑场阈值的点都被设置为输出黑场色阶,这样就合并了暗调区域。

调整白场阈值,所有高于白场阈值的点都被设置为输出白场阈值,这样就合并了高光区域。

调整 gamma 值,使得各个部分的亮度更加均匀。

像素点 x, 设某通道上的值为 Lx 则有:

  • 若 Lx 小于 BlackThreshold 则 Lx’ = 0(黑场输出色阶,简单处理,认为是 0)
  • 若 Lx 大于 WhiteThreshold 则 Lx’ = 255(同上)
  • 若 Lx 在 BlackThreshold 和 WhiteThreshold 之间 则做 Gamma 校正: Lx’ = ((Lx – BlackThreshold)/(WhiteThreshold – BlackThreshold))^(1/gamma) * 255

如上面那张直方图所显示的,即使不看原图也可以知道原图的大致情况:偏灰,层次感不够。

此处输入图片的描述 调整完毕后的直方图为: 此处输入图片的描述

相比于原直方图而言,调整完毕后的直方图柱形分布更加均匀,图片能呈现出一种层次感。对比调整前后的图片,更能直观地看出差别: 此处输入图片的描述 此处输入图片的描述

可以看出调整前图片灰蒙蒙一片,天空,树和房子基本感觉是同一个层次,而调整后各个景物的层次就比较鲜明,而且也不再是灰蒙蒙一片了。

说完算法本身,来说说算法背后的一些原理。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);
}