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

找出邊緣:梯度、Sobel 與銳化

把影像看成一片光的地景,用梯度找出每一道邊緣——再把同樣的數學反過來用,讓影像變銳利。

把影像看成一片光的地景

這裡有一個能打開整篇指南的思考小技巧。別再把灰階影像當成一片扁平的數字陣列,改把它想像成一片地景。把每個像素的亮度讀成「高度」:明亮的像素(數值大)是丘陵與高山,黑暗的像素(數值小)是山谷。一面被均勻打光的牆是平緩的高原;黑底上的白色物體,就是從平地拔起、頂部平坦的方山。

一小塊像素:每一格存放一個亮度值。把每個數字想像成那一點地面的高度。

一個像素網格,每一格顯示一個數值亮度。

現在問:這片地景裡的邊緣在哪裡?影像中的邊緣,是亮度突然改變的地方——白色物體與黑色背景之間的界線。用地景的話來說,這種突變就是懸崖:原本平坦了一陣子的地面,陡然落下。在物體內部,高度幾乎不變(平地);在邊界上,高度急速墜落。於是「找邊緣」就變成了「找地形中陡峭的部分」。

在進入二維之前,先把它講清楚:取影像中單獨一條水平的列(row),把它畫成一條剖面曲線——底部是像素位置,向上是亮度。一條乾淨的物體邊界,在這條剖面上會呈現為一個陡峭的階梯或斜坡:低平、急升、再高平。那段「急升」的陡度,正是我們想量測的東西。而「沿著移動時某個量的變化率」,正是斜率——也就是導數——所捕捉的。下一節我們會把這份陡度變成一個叫做 影像梯度 的數字。

影像梯度:斜率與方向

在我們的一維列上,某像素處的斜率不過就是「我往右踏一步,亮度變化多快?」這個單一數字就是一個導數。但真實影像是二維的:在任一像素,你可以往踏(x 方向),也可以往踏(y 方向),而地形可能在某個方向陡升、在另一個方向卻平坦。所以單一斜率不夠——我們需要兩個,每個方向各一個,再把它們綁成一支小箭頭。這支箭頭就是 影像梯度

\nabla f = \left( \dfrac{\partial f}{\partial x},\ \dfrac{\partial f}{\partial y} \right)

這樣讀:影像 f 的梯度,記為 ∇f(念作「grad f」),是一對數字。第一個 ∂f/∂x,是你由左往右移動(水平方向)時亮度的變化率。第二個 ∂f/∂y,是你由上往下移動(垂直方向)時的變化率。彎彎的 ∂(「偏」)只是表示「在固定另一方向的前提下,沿這個方向的變化」。所以 ∇f 是一支二維向量——一支箭頭——它存在於每一個像素上。如果兩個數字都接近零,你就站在平地上(沒有邊緣);只要其中一個很大,那裡的亮度就在快速變動。

一支箭頭有長度方向,兩者各自告訴我們關於邊緣的有用資訊:

|\nabla f| = \sqrt{\left(\dfrac{\partial f}{\partial x}\right)^2 + \left(\dfrac{\partial f}{\partial y}\right)^2}, \qquad \theta = \operatorname{atan2}\!\left(\dfrac{\partial f}{\partial y},\ \dfrac{\partial f}{\partial x}\right)

第一個量 |∇f| 是強度(magnitude)——箭頭的長度,由兩個斜率以畢氏定理算出。它代表該像素的整體陡度:強度大表示這是強邊緣,強度小表示地形平滑。這個單一數字,就是我們通常顯示成「邊緣圖」的東西。第二個量 θ(theta)是箭頭所指的方向,用 atan2 計算(一種會判斷你身處哪個象限的安全反正切)。健行式的直覺是這樣:想像你站在地形上——梯度箭頭指向最陡的上坡方向(朝更亮的地方),而它的長度就是那段攀升有多陡。因為箭頭橫越懸崖,所以邊緣本身的走向會與 θ 垂直

我們來代入數字。取一塊正好壓在垂直邊緣上的 3×3 區塊——左暗、右亮: 50 50 200 50 50 200 50 50 200 在中心像素用簡單的中央差分(右鄰減左鄰,再除以兩者間的 2 步):∂f/∂x =(200 − 50)/ 2 = 75。往下走時,中心上下的整欄完全相同,所以 ∂f/∂y =(50 − 50)/ 2 = 0。於是強度為 √(75² + 0²) = 75(一條強邊緣),方向為 atan2(0, 75) = 0°——筆直指向右方、朝著明亮側。完美:一條垂直邊緣產生一支水平的梯度箭頭,而這支箭頭指向「上坡」進入明亮區,正如類比所承諾的。

從微積分到濾波核:Sobel 運算子

導數是為平滑、連續的函數定義的,但影像是離散的網格——你沒有無限小的步可踏,只有一整個一整個的像素。解法是有限差分(finite difference):用相減鄰近像素來近似導數。從你左邊像素到右邊像素的亮度變化,就是 ∂f/∂x 一個非常稱職的替身。這就是從微積分通往程式碼的整座橋:導數不過是鄰近像素的一個加權差。

但第 3 篇指南教了我們慘痛的一課:真實影像帶有雜訊,而雜訊充滿了細小、快速、隨機的亮度抖動。原始的差分會把每一顆雜訊斑點都誤認成陡坡,讓邊緣圖淹沒在假邊緣裡。Sobel 運算子就是那個經典又漂亮實用的答案:一個 3×3 的核,它在一個方向上微分、同時在另一個方向上平滑,於是能讀出真正的邊緣、並對雜訊聳聳肩不理。回想第 2 篇指南,我們是用卷積(convolution)來套用一個核——把它滑過影像,在每個位置取加權總和。Sobel 正是這樣一個 空間濾波 核,它直接估計出 影像梯度

G_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix}, \qquad G_y = G_x^{\top} = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix}

看看 Gx,也就是水平邊緣偵測器。每一列都是(−1, 0, +1)的樣式:它用右像素減左像素——一個左對右的差,也就是導數 ∂f/∂x。現在順著各欄往下看:權重是 1、2、1。那是一個微小、類高斯的模糊,套用在垂直方向上,與我們微分的方向垂直。所以 Sobel 的配方是:橫越邊緣去微分、沿著邊緣去平均。中間那一列拿到加倍的權重(−2, 0, +2),因為它離我們正在計算的像素最近,理應算得最重。Gy 不過是把 Gx 轉 90°(它的轉置,記為 Gxᵀ):它做上對下的差、同時在水平方向平滑,用來偵測水平邊緣。

卷積運作中:3×3 的 Sobel 核滑過影像;在每個位置,重疊的像素與核權重相乘後加總,產生一個輸出值。

一個小核滑過影像網格,以加權總和計算產生單一輸出像素。

M = \sqrt{G_x^2 + G_y^2}

把影像分別與 Gx 和 Gy 做卷積後,你會得到兩張回應圖:一張在垂直邊緣處強,一張在水平邊緣處強。要得到一張對任何方向邊緣都有反應的單一邊緣強度圖 M,就用我們合併梯度分量的同一招——逐像素的畢氏定理。這裡的 Gx、Gy 指「此像素的 Sobel 回應」,而 M 就是 Sobel 邊緣強度。我們用先前同一塊區塊算算看: 50 50 200 50 50 200 50 50 200 Gx 回應 =(−1·50 + 0·50 + 1·200)+(−2·50 + 0·50 + 2·200)+(−1·50 + 0·50 + 1·200)= 150 + 300 + 150 = 600。Gy 回應:上列貢獻 −(50+2·50+200) = −350,下列貢獻 +(50+2·50+200) = +350,相加為 0。於是 M = √(600² + 0²) = 600——一個大數字,正確地大喊「這裡有條強垂直邊緣」;而一塊平坦區塊(全是 50)會給出 Gx = Gy = 0、M = 0。

import numpy as np
from scipy.ndimage import convolve

# img: a 2D grayscale image as float (so differences can go negative)
img = img.astype(np.float64)

# Sobel kernels: differentiate in one axis, smooth (1,2,1) in the other
Gx = np.array([[-1, 0, 1],
               [-2, 0, 2],
               [-1, 0, 1]], dtype=np.float64)
Gy = Gx.T  # transpose -> detects horizontal edges

gx = convolve(img, Gx)   # horizontal gradient (strong on vertical edges)
gy = convolve(img, Gy)   # vertical gradient   (strong on horizontal edges)

magnitude = np.sqrt(gx**2 + gy**2)   # edge strength, any orientation
direction = np.arctan2(gy, gx)       # edge orientation in radians

# Pitfall: keep the image as float. On uint8, the -1/-2 taps would wrap
# negative values around to ~255 and corrupt every edge.
Sobel 邊緣偵測:先與 Gx、Gy 做卷積,再合併成強度與方向。

拉普拉斯運算子:曲率與過零點

Sobel 量測的是一階導數——也就是斜率。還有第二種、互補的找邊緣方式,它問的問題更細緻:不是「它有多陡?」而是「這份陡度變化得多快?」這就是二階導數。在我們的一維剖面上,二階導數量測曲率——地形是向上彎(山谷)還是向下彎(山脊)。它在兩個方向上的二維總和,就是拉普拉斯運算子(Laplacian),它用一個聰明的方式定位邊緣:不是當作尖峰,而是當作過零點(zero-crossing)——回應由正翻到負的那個精確位置。

\nabla^2 f = \dfrac{\partial^2 f}{\partial x^2} + \dfrac{\partial^2 f}{\partial y^2}

把 ∇²f(念「del 平方 f」,或「f 的拉普拉斯」)讀成:把 x 方向的二階導數與 y 方向的二階導數加起來。∂²f/∂x² 這一項是水平斜率的變化率——左到右的陡度本身是在增加還是減少?∂²f/∂y² 對垂直斜率亦然。與梯度不同,結果是單一數字(純量),不是箭頭——它丟掉方向,只保留「這裡的斜率總共彎了多少」。平坦區域、甚至穩定的斜坡(固定斜率)都會給出大約零;回應只在斜率本身正在改變之處飆高——也就是邊緣周圍那些亮度的「轉角」。

\nabla^2 \approx \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}

這是離散的拉普拉斯核——有限差分版本,和 Sobel 一樣用卷積套用。把它讀成一個比較:中心像素的權重是 −4,它的四個直接鄰居(上、下、左、右)權重各為 +1。所以在每個像素上,它計算(四個鄰居之和)− 4×(中心)——字面上就是「這個像素比它鄰居的平均偏離了多少?」。在平地上,鄰居等於中心,權重相消,得到 0。在被暗包圍的亮尖點上,中心遠大於鄰居,你會得到一個大負數;在暗凹處則得到大正數。一個負的中心去平衡四隻正的臂,正是它為何是「一個」合併的二階導數運算子、而非兩個分開運算子的原因。

看著過零點在一維階梯邊緣上現身。取一條由暗轉亮的列:10, 10, 10, 90, 90, 90(跳變發生在位置 3 與 4 之間)。位置 i 的一維二階導數是 f[i−1] − 2·f[i] + f[i+1]。位置 3(最後一個暗像素):10 − 2·10 + 90 = +80。位置 4(第一個亮像素):10 − 2·90 + 90 = −80。於是回應走勢是 … 0, +80, −80, 0 …——在邊緣之前強烈為正、在邊緣之後強烈為負,因此它必定在邊緣正中央穿過零。這個過零點把邊緣定位到極為精準,正是拉普拉斯的一大強項。

反銳化遮罩:用減去模糊來銳化

這裡是本篇指南的回報,也是它的大師級招式。到目前為止,我們都用邊緣數學去偵測邊緣。現在,我們把它倒著跑,去增強邊緣——也就是銳化。讓這成為可能的訣竅美妙地簡單:邊緣,正是影像中會被模糊摧毀的那部分。所以如果你能把「模糊抹掉了什麼」分離出來,這分離出來的那一層就是邊緣與細節紋理——而你可以把它更強地加回去,讓邊緣更銳利。這就叫做反銳化遮罩(unsharp masking)

g = f + k\,\bigl(f - \operatorname{blur}(f)\bigr)

把每個符號拆開。f 是原始影像。blur(f) 是它的一張 高斯模糊——一份把細節抹糊的副本,只留下緩慢、寬闊的變化(「低頻」)。差 f − blur(f) 是細節層遮罩:用平滑版去減原版,會抵消所有撐過模糊的東西,只留下被模糊抹掉的部分——邊緣、紋理、細微結構(「高頻」)。k 是強度(amount)——一個正的旋鈕,決定推得多用力。g 是銳化後的結果:取原圖 f,再加回 k 份它自己的細節。直覺是這樣:f − blur(f) 在邊緣的亮側為大正值、在暗側為大負值,所以加上它會讓亮唇更亮、暗唇更暗——它恰恰在邊緣處誇大對比,而眼睛把這讀成「更銳利」。

為什麼叫這個怪名字「反銳化(unsharp)」?它來自底片暗房。攝影師會在另一張底片上做一張負片的模糊(字面上不銳利)副本,把它與原片疊在一起,再透過兩者一起印放。那張模糊副本充當遮罩,壓住寬闊的色調、卻讓邊緣對比更用力地印出來——用一張不銳利的版來銳化照片。我們現在在軟體裡做的是一模一樣的運算,但這個歷史名稱就留了下來。

這直接接回上一節。減去一張模糊,在數學上非常接近減去 拉普拉斯:細節層 f − blur(f) 近似於 −∇²f,所以反銳化遮罩本質上是 g ≈ f − k·∇²f。把邊緣曲率加回去,就是銳化的由來。它也預告了第 5 篇指南的頻率觀點:blur(f) 留住低頻,所以 f − blur(f) 是一個高通(high-pass)訊號,而用 k 去放大它就是高頻增益。「銳化」與「高通濾波」最後會證明是同一個運算的兩個名字。

import numpy as np
from scipy.ndimage import gaussian_filter

def unsharp_mask(f, sigma=1.5, k=1.0):
    f = f.astype(np.float64)
    low  = gaussian_filter(f, sigma=sigma)  # blur(f): the low-frequency copy
    detail = f - low                        # high-frequency edge/texture mask
    g = f + k * detail                      # add scaled detail back
    return np.clip(g, 0, 255).astype(np.uint8)  # clip to avoid wrap-around

# k = 0  -> unchanged   k = 1 -> moderate   k = 3+ -> likely halos/overshoot
反銳化遮罩:模糊、相減得到細節遮罩,再把 k 倍的細節加回去。

快速走一遍數字。看跨在邊緣兩側的兩個像素:一個亮的 f = 100、一個暗的 f = 60,所以邊緣兩側的原始對比是 100 − 60 = 40。模糊後,每個像素都被往鄰居拉:假設模糊讓兩者都變成 80。那麼細節在亮像素是 +20(100 − 80)、在暗像素是 −20(60 − 80)。取 k = 1:亮像素變成 100 + 20 = 120,暗像素變成 60 − 20 = 40。新的對比是 120 − 40 = 80——加倍了。邊緣明顯更銳利,而我們完全沒碰平坦區(那裡細節 ≈ 0)。

從邊緣走向視覺

退一步,看看你現在握有什麼。透過把影像讀成一片光的地景,你學會了用 影像梯度 量測陡度、用 Sobel 核穩健地計算它、用拉普拉斯的過零點精準定位邊緣,並把整套想法倒著跑來銳化。梯度與邊緣本身並非終點——它們是幾乎所有更高階電腦視覺的原料。幾乎每一個古典視覺演算法,都從問「亮度在哪裡改變?」開始。

完全用本篇指南的零件搭成的經典壓軸,是 Canny 邊緣偵測器。它沒有發明任何新東西——只是把你已經擁有的工具排成一條謹慎的流程,把一張帶雜訊的梯度圖,變成乾淨、細緻、相連的邊線:

  1. 平滑:先用高斯模糊,讓雜訊不會偽裝成邊緣(這是第 3 篇指南的教訓,也是 Sobel 與 LoG 也要平滑的原因)。
  2. 梯度:跑 Sobel,取得每個像素的邊緣強度與方向。
  3. 非極大值抑制:沿著每個梯度方向,只保留最強的那一個像素、其餘歸零,把粗胖的邊緣削成一像素寬的脊線。
  4. 遲滯門檻:用一個高門檻去啟動有把握的邊緣、用一個低門檻去延伸它們,連起雖弱卻相連的像素,同時丟掉孤立的弱像素。

從這些乾淨的邊緣,視覺向上層層建構。角點與特徵偵測器(Harris 角點、SIFT、ORB)尋找邊緣相交之處、或梯度在兩個方向上同時很強之處——這些是用來把一張照片配對到另一張、拼接全景、或追蹤運動的鮮明地標。線條與形狀可以由邊緣像素投票得出(霍夫變換)。你在第 2 節算出的那個梯度,正是這一切的種子。

還有一條線沒收尾。我們一直把模糊說成「低頻」、把細節說成「高頻」,也注意到銳化是一種高通增益。第 5 篇指南會把這套語言講精確:它不把影像看成地景,而是看成一堆波的疊加,並展示尺度、頻率與形狀如何給你一套單一的大師工具箱,把模糊、找邊緣、銳化一次全部收進去。