JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

從像素到色調:直方圖、亮度與閾值

從每一次影像編輯的起點開始:一次一個像素地重塑亮度、對比與色調,並學會從直方圖讀懂一張影像的故事。

像素值到底是什麼意思

在第一個學習軌中,你已經認識了影像的核心事實:一張灰階影像不過就是一個數字網格。網格裡的每一格就是一個像素,而它的數字代表亮度——對 8 位元影像來說,是一個從 0(純黑)到 255(純白)的整數,中間則是各種灰階。把它想像成一張巨大的試算表,每一格存放著場景中那個確切位置的測光讀數:明亮處得到大數字,陰影處得到小數字。整張照片就是這張讀數試算表而已。

放大到足夠程度,畫面就會分解成一格格帶數字的方格——每個像素都是一筆亮度讀數。

左邊是一張照片,右邊放大成一個方格網,每一格標著 0 到 255 之間的亮度數字。

一旦你接受影像就只是數字,「影像處理」這個詞就不再神祕了:它字面上的意思就是對這些數字做算術。想要照片更亮?把每個數字加大。想要黑白?把每個數字換成 0 或 255。整個學習軌底下的一切,本質上都是把輸入數字變成輸出數字的配方。

最簡單也最重要的配方是點運算(point operation):一條完全獨立地套用在每個像素上的規則,不理會它的鄰居。一個像素的新值只取決於它自己的舊值——絕不取決於它左邊或上方的像素。正是這種獨立性,讓點運算容易理解、計算飛快。下面是一個小小的 3×3 區塊,以及一個你可以在腦中完成的點運算——「每個像素加 40」:

# A tiny 3x3 grayscale patch (each number is one pixel's brightness, 0..255)
img = [[ 10,  50,  90],
       [100, 150, 200],
       [120, 130, 255]]

# A POINT OPERATION: brighten by adding 40 to EVERY pixel, independently.
b = 40
for y in range(3):
    for x in range(3):
        out[y][x] = min(255, img[y][x] + b)  # clamp at 255 so we never overflow

# img[0][0]  10 -> 50,   img[1][1] 150 -> 190,   img[2][2] 255 -> 255 (already maxed)
每個像素只用它自己來更新——這正是它之所以是點運算的原因。

影像直方圖:一張影像的指紋

在改動一張影像之前,你會想先讀懂它。最有用的單一摘要就是影像直方圖:一張長條圖,統計每個亮度各有多少個像素。橫軸是色調 0…255;每根長條的高度是該色調的像素數量。它就是影像的指紋——像心電圖(EKG)的波形一樣,整體形狀一眼就告訴你故事:昏暗的夜景把長條擠在左邊,過曝的雪景把它們堆在右邊,而平淡朦朧的影像則把所有東西擠成中間一團窄窄的隆起。

h(i) = \#\{\,(x,y) : f(x,y) = i\,\}

把它讀作「在第 i 階的直方圖值,就是所有亮度等於 i 的像素位置的數量」。逐個符號看:i 是一個亮度階,0 到 255 之間的任一整數;f(x,y) 是位於第 x 欄、第 y 列那個像素所存的亮度;前面帶 # 的大括號表示「這個集合裡有幾個元素」,也就是有多少個位置 (x,y) 滿足條件;而 h(i) 就是那個數量。所以 h(0) 是有多少個全黑像素,h(255) 是有多少個全白像素。長條高表示那個色調到處都是;長條為零表示那個確切的灰階從未出現。

# A 4x4 toy image, 2-bit so intensities are only 0,1,2,3 (easy to count by hand)
img = [[0, 0, 1, 3],
       [0, 1, 1, 2],
       [2, 2, 1, 0],
       [3, 3, 2, 1]]

h = [0, 0, 0, 0]          # one bin per intensity level: h[0], h[1], h[2], h[3]
for row in img:
    for v in row:
        h[v] += 1         # tally this pixel into its bin

# h == [4, 5, 4, 3]  ->  four 0s, five 1s, four 2s, three 3s   (sums to N = 16)
親手建一次:把網格掃過一遍,把每個像素丟進它的箱子。四個 0、五個 1、四個 2、三個 3。
p(i) = \frac{h(i)}{N}

這把原始計數變成了比例:p(i) 是正規化直方圖,其中 N 是像素總數(這裡 N = 16)。對我們的玩具影像,p(0) = 4/16 = 0.25、p(1) = 5/16 ≈ 0.3125、p(2) = 0.25、p(3) ≈ 0.1875,加總為 1。它的好處是:p(i) 的行為正好就像一個機率——如果你閉上眼睛隨手戳一個像素,它的色調是 i 的機率。這正是為什麼稍後均衡化的數學要用 p(i)——它是色調上一個貨真價實的機率分布。還有一個關鍵觀念:直方圖丟掉了像素「在哪裡」,只保留每個色調「有多少」。兩張完全不同的畫面可以共用一模一樣的直方圖,這正是直方圖計算便宜卻又極具洞察力的原因。

亮度與對比:一條直線搞定

現在我們真的來改色調。最常用的點運算是線性亮度映射,它把你最常用的兩種調整綁在一起:亮度與對比。亮度是加或減一個常數——就像一個音量旋鈕,把整張影像調得更響(更亮)或更輕(更暗)。對比是乘上一個倍數——就像抓住一條橡皮筋拉伸,讓暗調與亮調離得更開(對比更高)或被擠在一起(對比更低)。兩者用同一條公式一次完成:

g(x,y) = a\cdot f(x,y) + b

這不過就是一條直線方程式:「輸出=斜率 × 輸入+截距」。逐個符號看:f(x,y) 是像素 (x,y) 的輸入亮度;g(x,y) 是取代它的輸出亮度;a 是對比增益——直線的斜率;b 是亮度偏移——直線的截距。直覺上,a 會傾斜並加陡色調曲線(a > 1 把色調拉開,a < 1 把它們壓在一起),而 b 把整條曲線平移上下。當 a = 1、b = 0 時,你會原封不動地拿回原影像。有一條不可違背的規則:結果必須被夾限(clamp/clip)在有效範圍 [0, 255] 內,因為一個像素不可能比黑更暗、也不可能比白更亮。

來看看每個旋鈕對影像直方圖做了什麼。加上 b 會把每根長條整體往右移(更亮)或往左移(更暗)——形狀不變,整枚指紋只是滑動。乘上 a 會以 0 為基準把長條彼此拉開(若 a > 1),使直方圖向外攤開、填滿更大的範圍;或把它們拉近(若 a < 1)。在編輯前後比對直方圖,是確認你的調整有沒有達到目的的最可靠方法。

來看一個實例。取一個中灰像素 f = 100,套用 a = 1.5(對比增強 50%)與 b = 10(小幅提亮)。則 g = 1.5 × 100 + 10 = 160——舒舒服服落在 [0, 255] 內,所以原封不動地保留。再取一個明亮像素 f = 200,用相同的 a 與 b:g = 1.5 × 200 + 10 = 310,超過了 255,於是被夾限回 255。第二個情況正是要小心的陷阱。

伽瑪校正:對齊眼睛與螢幕

直線對待每個色調都一視同仁,但你的眼睛和螢幕都不是這樣。人類視覺極度非線性:我們對暗調裡的微小變化遠比對亮調敏感——從接近全黑到暗灰這一步,對我們來說很戲劇化,而從亮灰到全白那一步幾乎察覺不到。顯示硬體同樣是非線性的。為了優雅地處理這件事,我們使用伽瑪校正,用一條冪次曲線取代直線。

把伽瑪想成一個更聰明的調光開關——它在陰影處(你的眼睛最挑剔的地方)格外溫柔,在高光處(反正你也看不出差別)則較為粗略。它彎曲色調曲線而不是傾斜它,因此伽瑪可以在保持純黑停在 0、純白停在 255 不動的同時,提亮或壓暗中間調,所以它永遠不會夾限。

s = c \cdot r^{\gamma}

逐個符號看:r 是輸入亮度,但要先正規化到 [0, 1] 範圍(把 0…255 的值除以 255);s 是輸出亮度,同樣落在 [0, 1];γ(伽瑪)是彎曲曲線的指數;而 c 是縮放常數,幾乎總是 1。直覺就藏在指數裡。當 γ = 1 時,曲線是一條 45° 的直線——完全不變。當 γ < 1(例如 0.5)時,曲線向上拱起,抬升陰影、提亮中間調。當 γ > 1(例如 2.2)時,曲線向下拱,加深陰影、壓暗中間調。端點則固定不動:對任何 γ 都有 0^γ = 0 與 1^γ = 1,這正是純黑與純白絕不移動的原因。

兩個實際數字讓它變具體。取一個暗像素值 50。正規化:r = 50/255 ≈ 0.196。套用 γ = 0.5(也就是開平方根):s = 0.196^0.5 ≈ 0.443。再縮放回去:0.443 × 255 ≈ 113。這個陰影像素從 50 跳到 113——正好在眼睛最在意的地方有了巨大且看得見的提升。現在取一個中間調 128:r ≈ 0.502,用 γ = 2.2 得到 s = 0.502^2.2 ≈ 0.220,換算回 ≈ 56——明顯變暗。那個 γ ≈ 2.2 並非巧合:典型螢幕會對它收到的訊號套用約 2.2 的伽瑪,所以相機與影像檔會以大致為其反向值的 1/2.2 ≈ 0.45 預先編碼,這樣畫面在被螢幕壓暗回去之後看起來才正確。

# Gamma correction: the correct 3-step workflow
def gamma_correct(pixel, gamma, c=1.0):
    r = pixel / 255.0         # 1) normalize 0..255  ->  0..1
    s = c * (r ** gamma)      # 2) apply the power law
    return round(s * 255.0)   # 3) scale back to 0..255

gamma_correct(50,  0.5)   # -> 113   gamma < 1 lifts shadows (50 brightens)
gamma_correct(128, 2.2)   # -> 56    gamma > 1 deepens midtones (128 darkens)
永遠先把值正規化到 [0,1] 再做次方,最後再縮放回去——直接對原始的 0..255 值取次方是沒有意義的。

直方圖均衡化:把色調攤開

線性與伽瑪映射都需要你來挑旋鈕。直方圖均衡化則是自動版本:它檢視影像直方圖,在沒有任何參數要調的情況下,把一組擠在一起的色調攤開到完整的 0…255 範圍,以最大化對比。目標是一個更平坦的直方圖——每個色調都被大致均等地使用。想像一座音樂廳,整群觀眾全擠在少數幾排;均衡化就是那位帶位員,把每個人均勻地重新安排到所有排次上,這樣沒有一區擠爆、也沒有一區空著。

  1. 計算直方圖 h(i):數出每一階各有多少像素。
  2. 把它正規化為 p(i) = h(i)/N,得到每個色調的機率。
  3. 把 p(i) 從 0 累加到 r,形成累積分布函數 CDF(r)。
  4. 把 CDF 本身當作重映射曲線:新階 =(L−1)× CDF(舊階)。
s = T(r) = (L-1)\cdot \mathrm{CDF}(r), \qquad \mathrm{CDF}(r) = \sum_{i=0}^{r} p(i)

逐個符號看:r 是輸入階、s 是它的新輸出階;T 是我們建立的變換(重映射曲線);L 是階數,8 位元影像為 256,所以 L−1 = 255 是最亮的輸出;p(i) 是先前的正規化直方圖;而 CDF(r) 是 p 從第 0 階累加到第 r 階的累積和,平滑地從 0 升到 1。神奇之處在此:CDF 在像素堆積之處陡升,在空白色調範圍則保持平坦。因為我們用這個陡度當作新的色調曲線,擁擠的色調會被大幅拉開(斜率陡 ⇒ 輸出間隔大),而空白範圍會被擠合起來。人群正好按它原本有多擁擠的比例被攤開。

# Histogram equalization on a 3-bit image (levels 0..7, so L = 8)
img = [[3,3,4,5],
       [3,4,4,5],
       [4,4,5,5],
       [3,4,5,4]]
L, N = 8, 16

h = [0]*L
for row in img:
    for v in row: h[v] += 1        # h   = [0,0,0,4,7,5,0,0]

p = [c / N for c in h]             # p   = [0,0,0,.25,.4375,.3125,0,0]
cdf, run = [], 0.0
for pi in p:
    run += pi
    cdf.append(run)                # cdf = [0,0,0,.25,.6875,1,1,1]

T = [round((L-1) * c) for c in cdf]   # T  = [0,0,0,2,5,7,7,7]
# old tones {3,4,5} (span 2)  ->  new tones {2,5,7} (span 5): contrast stretched!
原本用到的三個色調 3、4、5 擠在一起;均衡化把它們映射到 2、5、7,填滿了整個範圍。

閾值化:從灰階到黑白

最後一個點運算回答的是一個是非題,而不是調整色調。閾值化是最簡單的分割(segmentation)形式——把影像切成有意義的區域。你挑一個單一的切分值 T;每個比 T 亮的像素變白(前景),每個比它暗的變黑(背景)。一張灰階影像就塌縮成一張乾淨的雙色遮罩:墨水對紙張、物件對背景、細胞對玻片。

g(x,y) = \begin{cases} 255 & \text{if } f(x,y) > T \\ 0 & \text{otherwise} \end{cases}

逐個符號看:f(x,y) 是該像素的輸入亮度;T 是你選定的閾值切分點;而 g(x,y) 是二值輸出——不是 255 就是 0,中間什麼都沒有。這是套用在色調上的階梯函數(step function):當你把輸入從暗掃到亮,輸出會一直黏在 0,直到輸入越過 T,便瞬間跳到 255 並停在那裡。結果裡沒有任何中間灰階,而這正是重點——你已經把「有多亮?」的問題,轉成了「前景還是背景?」的判決。

T 該放在哪?讀直方圖。一份掃描文件有兩個自然的隆起——一個靠近白色(紙張)的高峰,與另一個靠近黑色(墨水)的峰——中間有一道谷。最佳的全域閾值就坐落在那道谷裡,乾淨地把兩群像素分開。但當影像各處光照不均時,單一全域 T 會失敗(頁面一角的陰影把下方紙張壓到 T 以下,變成黑色)。解方是自適應閾值化:為每個小區域各算一個不同的局部 T,讓頁面的每一部分都對照它自己的背景來判斷。而當你根本不想用眼睛去找那道谷時,大津法(Otsu's method)會自動挑出 T。

# Global thresholding of a scanned document (T chosen at the histogram valley)
T = 128
for y in range(H):
    for x in range(W):
        out[y][x] = 255 if img[y][x] > T else 0   # paper -> white, ink -> black

# Otsu: instead of guessing T = 128, try every candidate T in 0..255 and keep
# the one that splits the histogram into the two TIGHTEST humps
# (minimum within-class variance). No manual tuning, no eyeballing the valley.
大津法把「用眼睛挑那道谷」變成在所有可能切分點上的客觀搜尋。