從原理上來說,進行亮度調整無非兩種渠道:轉換到HSL或HSV(HSB)顏色空間,直接對L或者V進行調整。再者就是對R,G,B三個通道同時進行調整以達到調整亮度的效果。又可以細分為大約四種方法:
1.轉換到HSL(HSV)顏色空間調整
這可以說是最直觀也是最低效的方法:因為HSL(HSV同理)顏色空間天然有一個L分量表示亮度,直接進行調整即可。但是這種方法有很大的缺陷就是低效:因為計算機屏幕本身的特性決定了絕大多數的圖片文件解析完畢后是RGB顏色空間,於是就需要從RGB轉換成HSL,調整L,轉換回RGB顯示。雖然RGB轉HSL和HSL轉RGB並不復雜(Wiki),一次轉換基本等同於10次浮點乘的運算量,但是考慮到對於圖片上每個像素都要做這樣的計算,其效率就極為低下了:一張1000*1000的圖,需要做1億次浮點乘法運算,效率可想而知。雖然可以通過一些近似計算來減少一些計算量,但終歸不是一種好的方法。
2.同時對RGB通道進行線性調整
這么做基於以下兩個理由:1.RGB顏色空間本身就是源於物體發光,每個通道上的值表示的是該通道上的光強,調整光強即是調整亮度。2.亮度(lightness)的計算公式為: l = (max(rgb) + min(rgb)) / 2,所以同時對三個通道進行調整也就近似地直接調整l值。
相應的代碼如下:(抽取了正在寫的Demo里面的部分代碼)
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:曲線調整。
3.曲線調整
上面兩種方法進行亮度調整的時候都會有一個通病:圖片亮度的變化沒有層次感,往往是一整片區域一起變亮或者變暗。而接下來的兩種方法雖然同樣是在RGB通道上進行調整,卻可以避免這個問題。曲線調整其實就是第二種方法的變種:從算法上來說和第二種方法完全一樣,唯一的不同是Lookup Table是用戶構造,而不是我們通過亮度變化系數計算得到。這個方法的示例可以參考PhotoShop中的曲線工具。在作用通道是RGB通道時就是對亮度調整,這種方法的好處在於:他是通過直觀的表現反饋給用戶,用戶可以繼續進行曲線調整直到調整出滿意的效果來,是最直觀而且最准確的一種方法。而唯一的問題是:那個曲線控件的實現相對麻煩……
4.色階調整(Gamma校驗)
從概念上來說色階調整並不難懂,也是在各個通道上進行光強值計算和替換,當它作用於RGB通道,而不是某個單獨的通道時就可以認為是在對圖片亮度進行調整。色階調整一共有五個參數:黑場閾值,白場閾值,灰場(即gamma值),輸出黑場色階,輸出白場色階(簡單處理,可以設定輸出黑場色階為0,即純黑,輸出白場色階為255,即純白)。如圖(截圖來自PhotoShop):
調整黑場閾值,所有低於黑場閾值的點都被設置為輸出黑場色階,這樣就合並了暗調區域。
調整白場閾值,所有高於白場閾值的點都被設置為輸出白場閾值,這樣就合並了高光區域。
調整gamma值,使得各個部分的亮度更加均勻。
Namely……
像素點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); }