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

清理影像:雜訊與保邊去雜訊

真實照片總是帶著雜訊;學會雜訊是什麼,以及聰明的濾波器如何在不抹糊重要邊緣的情況下把它清除。

雜訊從何而來

把任何一張真實照片放大來看——尤其是室內或夜間拍的——你會發現原本平滑的表面其實一點也不平滑,而是浮動著一層細微、躁動的顆粒。這層顆粒就是 影像雜訊:在每個像素的真實亮度上疊加的微小隨機誤差。它不是相機犯的錯,而是光與電子運作方式下的物理必然。在能夠乾淨地去除雜訊之前,我們得先了解它從何誕生。

一般攝影中最大的雜訊來源是光子散粒雜訊(photon shot noise)。光並不是像平滑水流一樣注入感光元件的;它以離散的粒子——光子——的形式,在隨機的時刻落下。想像你站在鐵皮屋頂下,在小雨中數一秒鐘的雨滴。即使平均速率剛好是每秒 100 滴,這一秒你可能數到 92,下一秒卻是 108。速率穩定,但落下的時刻是隨機成團的。每個微小的感光井計數光子的方式也是如此,所以即使是一面完全均勻的灰牆,相鄰像素算出來的光子數也會略有不同。這種起伏就是散粒雜訊,而且是根本性的——再好的相機也無法消除它,因為它就在光本身裡。

在散粒雜訊之上,還疊著三種電子層面的貢獻。讀出雜訊(read noise)是感光元件把微小累積電荷放大、量測時所加入的不確定性——就像想從一把磨損的尺上讀出模糊的刻度。熱雜訊(thermal noise,又稱暗電流)來自熱:溫熱的矽會自發地釋放電子,假扮成光,這正是為什麼長曝光與發燙的感光元件看起來更顆粒。最後,量化(quantization)是把連續電壓硬塞進有限的整數階(例如 0–255)所產生的捨入誤差。每一階都丟掉一絲資訊、加入一點誤差。這些加起來,設下了一條地板:單張照片再怎麼處理也乾淨不到那以下。

影像是如何形成的:光(光子)抵達感光元件,被計數並放大成電荷,再量化成像素值——每一個階段都注入一點隨機性,也就是我們所說的雜訊。

成像流程圖,從場景光線經過鏡頭與感光元件,到數位像素網格。

設計去雜訊器時,我們不會把每個物理細節都建模。取而代之,我們用簡化的雜訊模型來抓住損壞的「形狀」。你會反覆遇到的兩種是:加性高斯雜訊(additive Gaussian noise),那種柔和模糊的顆粒,每個像素被一個小小的隨機量上下推動(就是日常低光下的閃爍感);以及椒鹽(脈衝)雜訊(salt-and-pepper / impulse noise),散落的像素被翻成純白(255,「鹽」)或純黑(0,「胡椒」),就像影像上的灰塵斑點。它們看起來不同,而且更關鍵的是——它們需要不同的解法,後面就會看到。

g(x,y) = f(x,y) + n(x,y), \qquad n(x,y) \sim \mathcal{N}(0,\sigma^2)

加性雜訊模型:觀測值 = 真實值 + 隨機推動量。

把這條式子讀成「一個像素的配方」。這裡 f(x,y) 是位置 (x,y)真實、乾淨的強度——完美相機會記錄的值。n(x,y) 是為那個像素新抽出的隨機雜訊值;我們常把它建模成平均值為 0、變異數為 \sigma^2 的高斯分布(寫作 \mathcal{N}(0,\sigma^2)),其中 \sigma 衡量顆粒有多兇。而 g(x,y) 是你實際拿到的觀測到的含雜訊像素。訊息很簡單:每個量測到的像素都是真實值加上一個隨機推動量。「零均值」是關鍵的禮物——推動量正負的機率相等,所以如果你能對完全相同的場景拍下並平均許多張獨立照片,這些推動量會互相抵消,你就能還原出 f。具體來說,假設真實灰階是 f=120。某張 n=+7 得到 g=127;另一張 n=-5 得到 g=115;第三張 n=+2 得到 g=122。平均:(127+115+122)/3 = 121.3 \approx 120,雜訊被洗掉了。從單一影像去雜訊,正是在沒有一疊照片可平均的情況下,設法取得那種平均效果的藝術。

去雜訊的目標與根本取捨

現在我們可以精確地陳述這份工作。我們拿到含雜訊的影像 g,想要還原出一個對乾淨影像 f 的估計,而 f 我們從未直接觀測到。這就是 影像去雜訊:它是一個估計問題,一種有根據的猜測。我們永遠無法確定自己完全還原對了 f,因為原始的真實值在雜訊被加上去的那一刻就已被摧毀。我們能做的最好程度,是產生一個 \hat{f}(讀作「f-hat」,意思是「我們對 f 的估計」),讓它有理由地接近真實。

這裡有一股纏繞著每個去雜訊器的張力。雜訊存在於快速的、像素到像素的變化中——而我們珍視的東西也是:邊緣、細緻紋理、小字。如果你為了殺死雜訊而猛烈平滑,你也會把那些讓影像值得保留的細節一起模糊掉。如果你為了保護細節而怯懦地平滑,顆粒就會存活下來。去除雜訊與保留細節,是往相反方向拉扯的。本指南中的每一個濾波器,其實都是對同一個問題的不同回答:我該如何把雜訊的隨機起伏,和真正的邊緣區分開來?

想像修復一幅蒙塵的古畫。灰塵是雜訊;筆觸是訊號。懶惰的修復者用濕布用力擦——沒錯,灰塵走了,但一層油彩也跟著走了,留下一幅平板、糊掉的傑作。高明的修復者把灰塵拂去,卻讓每一道筆觸保持銳利。那種選擇性——清理平坦區域,但不要橫越筆觸去刷——正是本指南稍後中值濾波器與雙邊濾波器要給我們的魔法。

我們怎麼知道一個去雜訊器做得好不好?最誠實的第一個工具是你自己的眼睛:顆粒消失了嗎?邊緣保持銳利嗎?有沒有出現糊糊的光暈?若要一個數字,最常見的是 PSNR(峰值訊噪比,Peak Signal-to-Noise Ratio),以分貝(dB)為單位。它拿去雜訊後的結果與一張已知的乾淨參考圖比較:dB 越高代表越接近真實(粗略地說,30 dB 還可以,40 dB 很優秀)。PSNR 需要一張真實基準影像,所以它用在我們刻意對乾淨圖片加雜訊、再評分自己去得多好的實驗裡。把這放進口袋——當我們遇到每個濾波器時,會用兩個準則來評判:它去掉了多少顆粒,又保留了多少細節。

為何平均型濾波器對付不了離群值

在第 2 篇指南中,我們認識了 均值濾波器高斯模糊。兩者都是線性濾波器:它們在影像上滑動一個小視窗,把每個中心像素替換成鄰居的加權和。均值濾波器用相等的權重(單純平均);高斯則用鐘形權重,偏重中心。對柔和的高斯顆粒,它們表現得很漂亮——平均好幾個含雜訊但相似的鄰居,會讓零均值的推動量互相抵消,這正是第 2 節的多張拍攝技巧,只不過現在「多張」變成了鄰近的像素,而不是分開的照片。

但若把椒鹽雜訊交給它們,它們就崩潰了。原因是:平均對單一極端值毫無防禦力。一個卡在 255 的像素不會被鄰居抵消——它會被塗抹到鄰居身上。均值並沒有移除這個離群值;它只是把它稀釋成一抹淡淡的污痕,感染整個視窗。讓我們用真實數字看看它是怎麼發生的。

# A 3x3 window over a dark, smooth region.
# One pixel has been hit by SALT noise (value 255);
# the other eight are the true dark surface (around 20).
window = [[ 20, 22, 19],
          [ 21, 255, 18],   # <-- 255 is the impulse (an outlier)
          [ 23, 20, 22]]

# MEAN filter: sum all nine values, divide by nine.
values = [20,22,19, 21,255,18, 23,20,22]
mean = sum(values) / 9         # = 420 / 9 = 46.7

# The true value here should be about 20.
# The mean filter reports ~47 -- more than DOUBLE the truth.
# Worse: the bright speck did not disappear. It got smeared
# into a gray blob covering the whole 3x3 neighbourhood.
一個鹽斑就把均值從約 20 拉高到約 47,並汙染它所觸及的每一個輸出像素。

一步步看。八個好像素都在 20 附近,所以誠實的答案是「約 20」。但那個孤零零的 255 為總和 420 加上了巨大的 255,除以九得到 46.7——超過真實值的兩倍。而且記得視窗會滑動:當它移動時,同一個 255 會落在下一個視窗、再下一個視窗裡,所以這單一斑點不會消失,反而複製成一團糊掉的灰斑,大小大約等於核。高斯模糊能減輕損害(斑點偏離中心時權重較小),卻無法逃脫——當脈衝正好坐在正中心時,它仍然拿到所有權重裡最大的那一份。平均之所以是錯的工具,是因為它同等地信任每一個值,連一個明顯在說謊的值也照信不誤。

解法是:別再加總,改成排名。如果我們不把這九個值相加,而是把它們排序、挑出正中間那一個,那個孤立的離群值就會被推到排序清單的最末端,直接被忽略。這個想法——靠順序,而非靠總和——正是下一節的核心。

中值濾波器:排序勝過平均

中值濾波器做的正是我們剛才暗示的事:對每個像素,蒐集它鄰域視窗內的所有值,排序它們,輸出正中間那個值——也就是中位數。中位數是這樣一個值:比它小的鄰居數量,恰好等於比它大的。因為它是由排序清單裡的位置定義,而不是由總和定義,幾個狂野的離群值根本拉不動它:它們只是無害地待在清單兩端,而中間原封不動。

g(x,y) = \operatorname{median}\bigl\{\, f(i,j) : (i,j) \in \mathcal{W}(x,y) \,\bigr\}

把每個像素的輸出設為其視窗內所有值的中位數。

拆解這個記號。\mathcal{W}(x,y) 是以像素 (x,y) 為中心的視窗(鄰域)——對 3×3 濾波器來說,就是它周圍連同自己的九個像素。集合 \{\, f(i,j) : (i,j) \in \mathcal{W}(x,y) \,\} 只是表示「那個視窗內所有的像素值 f」。\operatorname{median}\{\dots\} 說的是:把這堆值從小排到大,回傳落在正中間的那一個。g(x,y) 是清理後的輸出像素。沒有乘法、沒有權重——這正是重點所在。把它和第 2 篇的卷積 g = \sum_k w_k f_k(一個固定的加權和)相比:這裡根本沒有固定權重 w_k,所以中值濾波器無法寫成卷積。我們待會兒會回到這一點。

# Reuse the EXACT window from the previous section.
window = [[ 20, 22, 19],
          [ 21, 255, 18],   # the 255 salt speck is still here
          [ 23, 20, 22]]

values = [20,22,19, 21,255,18, 23,20,22]

# MEDIAN filter: sort, then take the middle element.
values.sort()   # -> [18, 19, 20, 20, 21, 22, 22, 23, 255]
#                                       ^
#   nine values, so the middle is index 4 (the 5th):
median = values[len(values)//2]   # = 21

# Output is 21 -- right in the true dark range (~20).
# The 255 got sorted to the very END of the list and
# was thrown away. The speck is GONE, not smeared.
同一個視窗、同一個斑點——中值回傳 21(真實值約 20),並徹底丟棄了 255。

看看發生了什麼。排序後,九個值是 [18,19,20,20,21,22,22,23,255]。那個有毒的 255 現在落在最右邊、第九格——而中值永遠只看第五格,也就是 21。真實值大約是 20,我們得到 21。斑點沒有被稀釋成一團污痕;它被徹底消滅了,因為中值真的不在乎極端值,只在乎排名。這就是為什麼中值濾波器是椒鹽雜訊的標準解法:它直接移除脈衝,而不是把它們塗抹開來。

它對邊緣的保留也好得驚人,而原因很優雅。想像一個視窗橫跨在一條銳利邊界上——比方說一邊是五個暗像素(約 20),另一邊是四個亮像素(約 200)。均值濾波器會把它們平均成一種誰都不屬於的混濁中間灰(約 100),把邊緣糊掉。中值排序後落在接近 20 的值上(較大的那群贏得中間位置),所以輸出乾淨俐落地貼向邊緣的一側,而不會憑空捏造一個本來不存在的混合值。邊緣保持銳利,只有脈衝死去。

雙邊濾波器:在邊緣前止步的模糊

現在來到核心。中值是對抗脈衝的冠軍,但面對柔和的高斯顆粒,我們仍然想要 高斯模糊的平滑能力——卻不要它橫越邊緣滲色的致命毛病。雙邊濾波器恰好做到這點。其想法是:拿一個普通的高斯模糊,給它裝上第二顆腦袋——同時用兩個因素來為每個鄰居加權:它在空間上有多,以及它在強度上有多相似。一個鄰居只有在既靠近、又看起來像中心像素時,才能投出有意義的一票。

這裡有個關於人的類比。想像你藉由平均周圍人們的看法來形成自己的意見。單純的高斯模糊只問「這個人實際上離我多近?」——所以如果你正站在兩群截然不同的人之間的邊界上,你會把兩邊都平均進來,最後得到一個誰都不代表的糊塗折衷。雙邊濾波器加上第二個問題:「這個人是不是明顯屬於跟我不同的群體?」如果是,無論他站得多近,你都忽略他。最後你只平均了那些既離你近、又跟你像的人——於是你自己群體的看法保持清晰,永遠不被界線另一邊的人群汙染。

g(p) = \frac{1}{W_p}\sum_{q \in \mathcal{S}} f(q)\; G_{\sigma_s}\!\bigl(\lVert p-q\rVert\bigr)\; G_{\sigma_r}\!\bigl(\lvert f(p)-f(q)\rvert\bigr), \qquad W_p = \sum_{q \in \mathcal{S}} G_{\sigma_s}\!\bigl(\lVert p-q\rVert\bigr)\; G_{\sigma_r}\!\bigl(\lvert f(p)-f(q)\rvert\bigr)

雙邊濾波器:一個加權平均,其中每個權重都是空間高斯與值域高斯的乘積。

讓我們仔細為每個符號命名。p 是我們正在計算新值的中心像素q 則跑遍視窗 \mathcal{S} 內的鄰居。f(q) 是鄰居的強度(我們可能借來的值),f(p) 是中心的強度。這裡有兩個高斯加權函數。G_{\sigma_s}(\lVert p-q\rVert)空間高斯:它取決於兩個像素間的幾何距離 \lVert p-q\rVert,而 \sigma_s——「觸及範圍」——決定濾波器看多遠(\sigma_s 越大=模糊越寬)。G_{\sigma_r}(\lvert f(p)-f(q)\rvert)值域高斯:它取決於強度\lvert f(p)-f(q)\rvert,而 \sigma_r——「相似容忍度」——決定一個鄰居在亮度上可以差多少、超過後它的票才會被壓抑(\sigma_r 越大=越寬容,越接近單純的模糊)。最後 W_p正規化因子:所有權重的總和,除以它能讓權重加起來等於 1、輸出維持在正確的亮度範圍內(是真正的平均,而非被灌大的總和)。

最重要的單一細節是:這兩個高斯是相乘的。乘積只有在兩個因子都大時才會大:一個鄰居必須既在空間上靠近、又在強度上相似,才能掙得真正的權重。如果任一條件不成立——太遠,或亮度差太多——乘積就會塌向零,那個鄰居實質上被忽略。那個「而且(AND)」正是保護邊緣的關鍵,下一個實作範例就會展示。

讓它走過一條階梯邊緣。假設像素 p 在暗側,f(p)=30,緊鄰著一面強度為 200 的亮牆。考慮兩個鄰居。鄰居 q_1 在左邊一格,也是暗的,f(q_1)=32:它在空間上靠近(空間權重高),強度也相似,\lvert 30-32\rvert = 2(值域權重高)——乘積高,所以它完整地計入,把它平均進來會抵消暗側的顆粒。鄰居 q_2 在右邊一格,落在亮牆上,f(q_2)=205:在空間上它一樣近(空間權重高),但強度差距是 \lvert 30-205\rvert = 175——遠大於一個合理的 \sigma_r(比方說 25),所以值域高斯把它的權重壓到趨近於零。q_2 基本上被忽略了。結果:p 只用它的暗側同伴來平滑,亮牆不會滲進來。邊緣保持刀刃般銳利,而兩側的平坦區域都得到清理。這就是邊緣感知平滑,一氣呵成。

import numpy as np

def bilateral_pixel(img, x, y, radius, sigma_s, sigma_r):
    """Compute one bilateral-filtered output pixel at (x, y)."""
    center = img[y, x]
    weighted_sum = 0.0   # numerator: sum of f(q)*weight
    norm = 0.0           # denominator W_p: sum of weights
    for dy in range(-radius, radius + 1):
        for dx in range(-radius, radius + 1):
            q = img[y + dy, x + dx]          # neighbour intensity f(q)
            # spatial Gaussian: depends on distance from center
            spatial = np.exp(-(dx*dx + dy*dy) / (2 * sigma_s**2))
            # range Gaussian: depends on intensity difference
            rng     = np.exp(-((center - q)**2) / (2 * sigma_r**2))
            w = spatial * rng                # the PRODUCT -- near AND similar
            weighted_sum += w * q
            norm += w
    return weighted_sum / norm               # divide by W_p -> true average
雙邊權重是 spatial * range 的乘積;把加權和除以權重總和(W_p)即得輸出。

有兩個值得記下的後果。第一,雙邊濾波器是依內容而變的——它的權重在每個像素都不同,因為權重取決於局部強度 f(p),f(q)。所以,和中值一樣,它不是固定的卷積;你無法預先算好一個核、到處滑動套用。第二,這是一整個邊緣感知方法家族的概念祖先。把「比較單一強度」換成「比較整片像素區塊」,你就得到非局部均值(non-local means);再把這想法推得更遠,便來到導引濾波器(guided filter),以及現代深度 去雜訊中那些經學習、會尊重邊緣的去雜訊器。理解了雙邊濾波器,你就握住了它們全部的種子。

如何挑選去雜訊器,以及接下來

去雜訊中最重要的單一習慣,是讓濾波器配合雜訊模型——在伸手拿工具之前,先診斷損壞的型態。先仔細看你的含雜訊影像:散落的純黑與純白斑點在大喊椒鹽雜訊;平坦區域上均勻的模糊閃爍則是高斯顆粒。正確的診斷會直接指向正確的解法。

  1. 椒鹽/脈衝斑點 → 拿出中值濾波器。它以排序為本,所以能直接刪除離群值,同時保持邊緣銳利。3×3 中值就能清掉輕度斑點;只有在損壞密集時才放大視窗。
  2. 輕度高斯顆粒、且有想保留的邊緣與紋理 → 用雙邊濾波器。若你需要純粹的速度、且能接受邊緣稍微變柔,普通的高斯模糊是便宜的退路。
  3. 簡單視窗已不夠用的嚴重雜訊 → 升級到現代前沿:非局部均值、基於小波的去雜訊、BM3D,或經學習的深度去雜訊器。威力更大,運算也更重。

關於那個前沿,這裡只當作路標稍稍點名。非局部均值把雙邊的想法一般化:它不比較單一強度,而是比較整片小區塊,並平均那些周遭環境看起來相似的像素,利用了自然影像會自我重複這個事實。小波方法(本軌道最後一篇指南會為它鋪路)的去雜訊做法,是把影像轉換成「頻率與尺度」的分量,再縮減那些多半是雜訊的小分量。而深度 CNN 去雜訊器則推得最遠。

這裡是通往後續深度學習軌道的橋。經學習的 CNN 去雜訊器,不像中值或雙邊濾波器那樣由人手寫下規則。取而代之,它被展示成千上萬組(乾淨、含雜訊)影像對,並從資料中學習哪些起伏是雜訊、哪些是真正的結構——自行發掘出一套遠更靈活、屬於它自己版本的雙邊「近且相似」規則。本指南中手工打造的濾波器,正是那些網路所立足的概念基礎;現在認識它們,日後遇到學習型去雜訊器時,就會覺得那是自然的下一步,而非魔法。

最後一條要帶往前的線索。注意到本指南中的一切,都在試圖把快速的像素到像素變化當成雜訊壓抑下去——然而影像中最銳利、最有用的變化,正是它的邊緣。我們在這裡對抗的那些起伏,從另一個角度看,正是我們最想找到的訊號。下一篇指南把目標翻轉過來:我們不再把變化平滑掉,而是刻意用梯度與 Sobel 算子去量測它們,藉此尋找邊緣——你會看到,去雜訊與找邊緣其實是同一枚硬幣的兩面。