像素值到底是什麼意思
在第一個學習軌中,你已經認識了影像的核心事實:一張灰階影像不過就是一個數字網格。網格裡的每一格就是一個像素,而它的數字代表亮度——對 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)的波形一樣,整體形狀一眼就告訴你故事:昏暗的夜景把長條擠在左邊,過曝的雪景把它們堆在右邊,而平淡朦朧的影像則把所有東西擠成中間一團窄窄的隆起。
把它讀作「在第 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)這把原始計數變成了比例: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)——它是色調上一個貨真價實的機率分布。還有一個關鍵觀念:直方圖丟掉了像素「在哪裡」,只保留每個色調「有多少」。兩張完全不同的畫面可以共用一模一樣的直方圖,這正是直方圖計算便宜卻又極具洞察力的原因。
亮度與對比:一條直線搞定
現在我們真的來改色調。最常用的點運算是線性亮度映射,它把你最常用的兩種調整綁在一起:亮度與對比。亮度是加或減一個常數——就像一個音量旋鈕,把整張影像調得更響(更亮)或更輕(更暗)。對比是乘上一個倍數——就像抓住一條橡皮筋拉伸,讓暗調與亮調離得更開(對比更高)或被擠在一起(對比更低)。兩者用同一條公式一次完成:
這不過就是一條直線方程式:「輸出=斜率 × 輸入+截距」。逐個符號看: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 不動的同時,提亮或壓暗中間調,所以它永遠不會夾限。
逐個符號看: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…255 範圍,以最大化對比。目標是一個更平坦的直方圖——每個色調都被大致均等地使用。想像一座音樂廳,整群觀眾全擠在少數幾排;均衡化就是那位帶位員,把每個人均勻地重新安排到所有排次上,這樣沒有一區擠爆、也沒有一區空著。
- 計算直方圖 h(i):數出每一階各有多少像素。
- 把它正規化為 p(i) = h(i)/N,得到每個色調的機率。
- 把 p(i) 從 0 累加到 r,形成累積分布函數 CDF(r)。
- 把 CDF 本身當作重映射曲線:新階 =(L−1)× CDF(舊階)。
逐個符號看: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!閾值化:從灰階到黑白
最後一個點運算回答的是一個是非題,而不是調整色調。閾值化是最簡單的分割(segmentation)形式——把影像切成有意義的區域。你挑一個單一的切分值 T;每個比 T 亮的像素變白(前景),每個比它暗的變黑(背景)。一張灰階影像就塌縮成一張乾淨的雙色遮罩:墨水對紙張、物件對背景、細胞對玻片。
逐個符號看: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.