直方圖等化 (Histogram Equalization)
在學習了亮度直方圖和累積直方圖之後,我們對影像的亮度分佈有了更深入的了解。現在,我們將學習一種強大的影像增強技術——直方圖等化 (Histogram Equalization),它能自動調整影像的對比度,讓影像細節更加清晰。
1. 什麼是直方圖等化?
當一張影像的亮度直方圖顯示像素主要集中在某些特定範圍(例如,所有像素都偏暗,或所有像素都集中在中間亮度),這表示影像的對比度很差。在這種情況下,影像看起來會灰濛濛的,細節模糊不清。
直方圖等化的目標是自動地將影像的像素亮度分佈「攤平」或「拉伸」,使其盡可能均勻地分佈在整個 0 到 255 的亮度範圍內。這樣做可以有效地增加影像的整體對比度,使原本不明顯的細節變得清晰可見。
直方圖等化處理前後的對比:左側影像對比度低,其直方圖像素集中;右側影像經等化後對比度提升,其直方圖像素分佈更均勻。
1.1 為何需要直方圖等化?
- 對比度不足:例如在霧天拍攝的照片,或是曝光不當的影像,可能導致像素亮度範圍很窄。
- 細節隱藏:在低對比度的影像中,許多重要細節可能因為亮度差異太小而難以辨識。
- 自動化處理:直方圖等化是一種非線性、全域性的影像增強方法,它不需要人工干預,能自動尋找最佳的亮度映射關係。
1.2 直方圖等化基本原理
直方圖等化的核心是建立一個亮度映射函式 (Mapping Function)。這個函式會將原始影像中的每個舊亮度值 g_old,轉換為一個新的亮度值 g_new。這個轉換函式是直接從影像的正規化累積直方圖中推導出來的。
我們希望經過轉換後,新的亮度值 g_new 的分佈是均勻的。根據機率論,如果一個變量的累積分布是線性的,那麼該變量的分佈就是均勻的。因此,直方圖等化就是嘗試讓轉換後的亮度值的累積直方圖接近一條直線。
具體來說,對於 8-bit 影像 (亮度值從 0 到 255,總共有 L = 256 個亮度級別),映射公式如下:
g_new = round( (cumulativeHistogramData[g_old] / totalPixels) * (L - 1) )
讓我們來拆解這個公式:
cumulativeHistogramData[g_old]:- 這是原始亮度值
g_old在累積直方圖中的值。它代表了影像中所有亮度值小於或等於g_old的像素總數。
- 這是原始亮度值
/ totalPixels:- 將累積像素數除以影像的總像素數,這一步將累積值正規化到 0 到 1 的範圍。這個正規化後的累積值,其實可以看作是亮度值
g_old的「機率累積分布函式 (Cumulative Distribution Function, CDF)」在g_old點的值。
- 將累積像素數除以影像的總像素數,這一步將累積值正規化到 0 到 1 的範圍。這個正規化後的累積值,其實可以看作是亮度值
* (L - 1):- 將正規化後的值乘以最大亮度值
(255)。這一步將 0 到 1 範圍的機率值,重新映射回 0 到 255 的亮度範圍。
- 將正規化後的值乘以最大亮度值
round(...):- 四捨五入取整,因為像素亮度值必須是整數。
1.3 轉換函式 (Lookup Table, LUT)
在實際應用中,我們不會對每個像素都重複計算上述公式。而是會先計算出一個查找表 (Lookup Table, LUT)。這個 LUT 是一個包含 256 個元素的陣列,其中 LUT[i] 儲存了原始亮度值 i 應該被映射成的新亮度值。
計算出 LUT 後,我們只需遍歷影像的每個像素:
- 得到舊的亮度值
g_old。 - 直接查詢
LUT[g_old]得到新的亮度值g_new。
這樣可以大大提高處理效率。
1.4 對彩色影像的處理
直方圖等化主要針對影像的亮度進行操作。對於彩色影像,我們通常有兩種處理方式:
轉換為灰階並等化:
- 將彩色影像先轉換為灰階影像 (例如使用
0.299*R + 0.587*G + 0.114*B)。 - 對這個灰階影像執行直方圖等化,得到新的灰階亮度值。
- 將新的灰階亮度值應用回原始彩色影像的每個 R, G, B 通道,通常是透過計算一個亮度調整比例:
ratio = g_new / g_oldR_new = R_old * ratioG_new = G_old * ratioB_new = B_old * ratio - 這種方法可以保持影像的色彩平衡,避免等化後顏色失真。如果
g_old為 0,則g_new也應為 0,此時ratio應設為 0 (保持黑色)。
- 將彩色影像先轉換為灰階影像 (例如使用
在 HSV/HSL/YCbCr 色彩空間中處理:
- 將 RGB 影像轉換到 HSV、HSL 或 YCbCr 等色彩空間。這些色彩空間將亮度 (V/L/Y) 與色相 (Hue) 和飽和度 (Saturation/Chroma) 分開。
- 只對亮度通道 (V/L/Y) 進行直方圖等化。
- 等化完成後,再將影像從該色彩空間轉換回 RGB。
- 這種方法是處理彩色影像直方圖等化的「最佳實踐」,因為它能徹底將亮度調整與色彩資訊分離,最大限度地減少色彩失真。然而,這需要額外實作色彩空間轉換的函式,超出了本章節的範圍,但你可以思考這是一個進階的延伸練習。
在本章節的實作中,我們將採用第一種方法:計算灰階等化比例,並將其應用於原始 RGB 通道。
2. 演算法實作步驟
綜合以上原理,直方圖等化的演算法步驟如下:
- 計算原始影像的亮度分佈:
- 呼叫
createBrightnessHistogram函式,獲取histogramData。 - 計算影像的總像素數
totalPixels。
- 呼叫
- 計算原始影像的累積分佈:
- 根據
histogramData計算出cumulativeHistogramData。
- 根據
- 建立亮度映射表 (Lookup Table, LUT):
- 初始化一個 256 個元素的陣列
lut。 - 對於每個原始亮度值
i(從 0 到 255),根據公式lut[i] = round( (cumulativeHistogramData[i] / totalPixels) * 255 )計算其新的映射值。
- 初始化一個 256 個元素的陣列
- 遍歷影像並應用 LUT:
- 建立一個新的空白
BmpImage物件作為等化後的輸出影像。 - 遍歷來源影像的每一個像素
(x, y)。 - 獲取原始像素的
{ r, g, b }值。 - 使用與步驟 1 相同的權重,計算該像素的原始亮度值
oldGray。 - 查詢
lut表,得到新的亮度值newGray = lut[oldGray]。 - 根據
newGray和oldGray計算亮度調整比例ratio。 - 將原始的
r, g, b分別乘以ratio,得到新的newR, newG, newB。 - 將
newR, newG, newB設定到輸出影像的對應像素(x, y)上。
- 建立一個新的空白
3. 實作
3.1 程式碼
import BmpImage from "./BmpImage.js";
import { createCumulativeHistogram } from "./CumulativeHistogram.js";
/**
* 對影像執行亮度直方圖等化,以增強對比度。
* @param {BmpImage} sourceImage - 來源影像
* @param {object} [weights={ r: 0.299, g: 0.587, b: 0.114 }] - 計算亮度的 RGB 權重
* @returns {BmpImage} - 等化後的 BmpImage 物件
*/
function equalizeHistogram(sourceImage, weights = { r: 0.299, g: 0.587, b: 0.114 }) {
const width = sourceImage.width;
const height = sourceImage.height;
const totalPixels = sourceImage.width * sourceImage.height;
// --- 步驟 1: 計算累積分佈 ---
const cumulativeHistogramData = createCumulativeHistogram(sourceImage, weights);
// --- 步驟 2: 建立亮度映射表 (Lookup Table, LUT) ---
const lut = new Array(256);
for (let i = 0; i < 256; i++) {
// 公式:g_new = round( ((cumulativeHistogramData[i] - 1) / (totalPixels - 1)) * (L - 1) )
lut[i] = Math.round(((cumulativeHistogramData[i] - 1) / (totalPixels - 1)) * 255);
}
// --- 步驟 3: 應用映射表到影像 ---
const equalizedImage = BmpImage.create(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const { r, g, b } = sourceImage.getPixel(x, y);
// 計算當前像素的原始亮度
const oldGray = Math.round(
r * weights.r +
g * weights.g +
b * weights.b
);
const clampedOldGray = Math.max(0, Math.min(255, oldGray));
// 查詢 LUT 得到新的亮度值
const newGray = lut[clampedOldGray];
// 根據新舊亮度比例調整 RGB 分量
let newR, newG, newB;
if (oldGray === 0) {
// 如果原始亮度是0,則新亮度也應為0 (保持黑色),避免除以零
newR = 0;
newG = 0;
newB = 0;
}
else {
// 計算調整比例,並應用於 R, G, B
const ratio = newGray / oldGray;
newR = Math.max(0, Math.min(255, Math.round(r * ratio)));
newG = Math.max(0, Math.min(255, Math.round(g * ratio)));
newB = Math.max(0, Math.min(255, Math.round(b * ratio)));
}
equalizedImage.setPixel(x, y, newR, newG, newB);
}
}
return equalizedImage;
}
export { equalizeHistogram };3.2 使用方法
import { createBrightnessHistogramToBmp } from "./BrightnessHistogram.js";
import { createCumulativeHistogramToBmp } from "./CumulativeHistogram.js";
import { equalizeHistogram } from "./CumulativeHistogram.js";
import BmpImage from "./BmpImage.js";
const img = BmpImage.load('test.bmp');
const bh = createBrightnessHistogram(img);
bh.save('原圖直方圖.bmp');
const ch = createCumulativeHistogram(img);
ch.save('原圖直方累積圖.bmp');
const eq = equalizeHistogram(img);
eq.save('均值化後圖片.bmp');
const bh = createBrightnessHistogramToBmp(eq);
bh.save('均值化後直方圖.bmp');
const ch = createCumulativeHistogramToBmp(eq);
ch.save('均值化後直方累積圖.bmp');4. 把「亮度映射表 (LUT)」想像成一個「換算表」
想像一下,你有一堆東西(像素),它們的顏色深淺不一(亮度值),但是顏色分佈得很不平均:
- 大部分東西都擠在「淺灰色」那一區,所以很多東西看起來都差不多淺。
- 很少有純黑或純白的,導致整個畫面看起來「灰灰的」,不夠鮮明。
「亮度映射表 (LUT)」就是一個「教你怎麼把舊顏色換成新顏色,讓所有顏色看起來更平均、更鮮明」的換算表。
這個表長這樣:
| 舊顏色 (亮度值) | 換算後的新顏色 (亮度值) |
|---|---|
| 0 | 0 |
| 1 | 5 |
| ... | ... |
| 100 | 136 |
| ... | ... |
| 255 | 255 |
換算表的「公式」怎麼來的?
這個公式是怎麼算出「換算後的新顏色」要填什麼數字的呢?我們用「排隊」來比喻。
1. 先讓所有「舊顏色」的像素排隊
- 我們把所有像素(就是圖片裡的點點)都找出來。
- 然後讓它們根據自己的舊亮度值,從最黑 (0) 到最白 (255) 依序排好隊。
- 這個隊伍裡,可能會有很多像素都擠在某個顏色區域(例如 100
120 的灰色),有些顏色區域(例如 010 的超深色)則沒什麼人。
2. 算出每個舊顏色在隊伍中的「相對位置」
- 現在,我們關心的是:某個舊亮度值的像素,在整條「從最黑到最白」的隊伍裡,它大概排在多前面的位置?
- 舉例來說:
- 亮度值 50 的像素,可能在整條隊伍中排在 10% 的位置(意思是比它更黑或和它一樣黑的像素,佔了全部像素的 10%)。
- 亮度值 150 的像素,可能排在 80% 的位置。
- 亮度值 200 的像素,可能排在 95% 的位置。
- 這個「排在百分之幾」的數據,就是我們前面說的**「累積機率」或「正規化累積直方圖」**。它告訴你,到這個顏色為止,總共有多少比例的像素。
3. 把「相對位置」直接變成「新顏色」
- 這是最神奇的一步!我們現在有一個 0%~100% 的「相對位置」資訊。
- 我們就直接把這個百分比,當作是它新的亮度值!
- 但亮度值是 0
255,不是 0%100%。所以我們需要做一個簡單的轉換:- 新顏色 = 「相對位置」的百分比 * 255
- 例如:
- 舊顏色 50 排在 10% 的位置,那麼它的新顏色就是
10% * 255 = 25.5(四捨五入後是 26)。 - 舊顏色 150 排在 80% 的位置,那麼它的新顏色就是
80% * 255 = 204。
- 舊顏色 50 排在 10% 的位置,那麼它的新顏色就是
- 你會發現,原本擠在淺灰色(例如 100~120)的像素,它們的「相對位置」可能會從 40% 跳到 60%,差距有 20%。那麼
20% * 255就是 51 個亮度單位。這 51 個亮度單位,就是讓它們原本擠在一起的顏色「拉開」的空間。
4. 填滿換算表,然後去換顏色
- 我們把所有 0~255 的舊顏色,都照這個方法算一遍,然後填滿那個「換算表 (LUT)」。
- 一旦這個表做好了,接下來就簡單了:
- 把圖片裡的每個點點都檢查一遍。
- 這個點點原本是什麼顏色?假設是舊顏色 100。
- 去換算表裡查:喔,舊顏色 100 要換成新顏色 85!
- 那就把這個點點的顏色改成 85。
- 對圖片裡所有的點點都這麼做一次,圖片就等化完成了!
為什麼這樣做會讓顏色更平均、更鮮明?
因為這個換算表,它巧妙地把原本「擠在一起」的顏色(在隊伍中佔據很多人的小區間),在新的顏色範圍裡「拉開」了;而原本「很稀疏」的顏色(在隊伍中沒什麼人),則可能會稍微「壓縮」。
結果就是:新的顏色分佈會更平均地分散在從 0 到 255 的所有顏色空間裡,讓圖片的對比度更好,該黑的地方更黑,該白的地方更白,中間的灰色也分得更清楚,細節也就跑出來了!
所以,亮度映射表 (LUT) 就是這個「聰明的換算表」,它的公式來源,就是利用了「像素在所有顏色中排在第幾名」這個資訊,來決定它應該換成什麼新的顏色,以達到整體顏色分佈更均勻的目的。