什麼是特徵?為何不能只用像素?
想像你要在繁忙的火車站和朋友碰面。你不會靠「他一定站在時鐘左邊正好 4.2 公尺處」來找人——人是會走動的。你會改去掃視穩定、好認的標記:他的紅帽子、圓眼鏡、背包揹的樣子。這些標記很有辨識度,從不同角度都看得出來,就算朋友走到大廳另一頭也還是認得出。電腦視覺也是同一套做法。特徵(feature)就是影像中一個有辨識度、可以在局部被指認出來的地方——它的外觀夠特別,特別到你能在同一場景的另一張照片裡,重新找到「同一個」點。
一個「值得記住」的特徵位置,就稱為 影像關鍵點(image keypoint)——白話講,就是「演算法決定把它記下來、之後要再回來找的那個點」。你可以把關鍵點想成你會在地圖上插的圖釘:不是每一段路都插,而是只插那些好記的路口與地標。視覺流程前半段的全部工作,就是挑出好的圖釘,並為每個圖釘寫下一段簡短而穩健的描述,好讓我們之後能在另一張影像裡認出同樣這些圖釘。
那為什麼不能直接拿原始像素值當作身分標籤呢?因為一個像素的數值其實非常不穩定。同一個實體點——比方說窗戶的一角——所產生的亮度數值,會隨著光照(晴天還是陰天)、視角(你往左移了一步)、尺度(你走近了)以及單純的感測雜訊而完全不同。同一棟建築的兩張照片,那個角落在一張裡像素值是 47,在另一張裡卻可能是 180。如果我們想靠比對原始像素值來配對影像,幾乎什麼都對不起來。我們需要的是能挺過這些變化的描述——而這一切的起點,不是看亮度本身,而是看亮度在影像上「如何變化」。
一張灰階影像,旁邊並排顯示組成它的整數亮度值方格。
這裡先給整個單元的心智地圖。幾乎每一條古典視覺流程都跑這三個階段:偵測 → 描述 → 配對(detect → describe → match)。「偵測」找出值得記住的關鍵點;「描述」把每個關鍵點周圍的鄰域,轉成一段精簡、且在光照與視角變化下仍穩定的數字向量;「配對」則跨兩張影像比對這些描述,好說出「這裡這根圖釘,和那裡那根圖釘是同一個」。這第一篇導讀,就是要為這三個階段打地基——介紹梯度與 邊緣偵測(edge detection),也就是「亮度在哪裡變化?」這個最原始的訊號,後面每一個偵測器與描述子都建在它上面。
把影像看成亮度的地形
我們先把影像在數學上「是什麼」釘清楚,因為這個單元後面都倚靠這幅圖像。一張灰階影像就是一個函數。給它一個位置——第 x 行(欄)、第 y 列——它就回傳一個數字:那個像素的亮度,通常是 0(黑)到 255(白)之間的整數。我們把它寫成 I(x, y)。整張影像,不過就是把這個函數在格點上的每一組整數 (x, y) 都算過一遍。這裡沒有任何神祕之處:「影像」和「一張依位置索引的亮度數值表」其實是同一個東西。
現在把這張表想像成一片實體地形。你走在影像的格點上,把亮度當成「高度」:亮的像素是高地,暗的像素是低地。一張白牆的照片,就是一片平坦的高原;一張亮牆上有暗門洞的照片,就是一片高高的高原上,被鑿出一個又深又方的坑。那個門洞的邊緣就是懸崖——高度在極短的水平距離內陡降。這個「影像=地形」的比喻很值得記住,因為它能讓最核心的洞見變得一目了然。
這個洞見是:所有能讓一個位置「可被指認」的資訊,都住在地形「會變化」的地方——斜坡、稜線、山峰——而不是平坦處。 一片平坦的高原毫無特徵;上面任一點都和鄰居長得一模一樣,所以你永遠分不出它們、也找不回其中某一點。但懸崖邊、尖銳的稜線、孤立的山峰,就很有辨識度。這正是為什麼「特徵」要從度量「亮度的變化」開始。平坦=沒有資訊;陡峭=潛在的特徵。
用兩個小小的 3×3 像素方塊把它具體化。左邊是一塊平坦區域,每個值都是 100——完全均勻,任何方向都沒有變化。右邊則是一道垂直亮度階梯:左邊兩欄是暗的(30),最右欄一下跳到亮(220)。沿著右方塊的任一列讀過去,亮度一步就躍升 190。這個躍升就是那道懸崖。平坦方塊哪裡都藏不住特徵;階梯方塊則大聲喊著「邊緣在這裡」。
# Two 3x3 brightness patches (rows top-to-bottom)
flat = [[100, 100, 100], # every value identical ->
[100, 100, 100], # no change in any direction ->
[100, 100, 100]] # featureless plateau
step = [[ 30, 30, 220], # dark, dark, BRIGHT -> a jump of 190
[ 30, 30, 220], # same jump on every row
[ 30, 30, 220]] # this vertical cliff IS an edge
# Reading left-to-right across a row:
# flat: 100 -> 100 -> 100 (change = 0, nothing here)
# step: 30 -> 30 -> 220 (change = +190 between cols 2 and 3)把像素方格視覺化成 3D 高度圖,以亮度作為海拔,呈現一片平坦高原與一道陡峭懸崖。
梯度:度量變化
我們已經認定「亮度的變化」是特徵的原料。現在要精準地度量它。在每個像素問兩個簡單問題:往右走一步,亮度變化得多快? 以及 往下走一步,亮度變化得多快? 每個問題的答案都是一個斜率——正是我們地形的陡峭程度。把這兩個斜率綁在一起,你就得到了影像梯度(image gradient):每個像素上的一支小箭頭,它指向亮度增加最快的方向,而它的長度告訴你那個增加有多陡。
我們要怎麼從一格格數字估出斜率?用有限差分(finite difference)——導數的離散版表親。最乾淨的版本是中央差分:要算某像素在 x 方向的變化率,就拿它右邊一步的亮度,減去它左邊一步的亮度。差很大表示斜坡很陡;差為零表示平坦。在 y 方向垂直地做同樣的事。把這兩個值疊成一個向量,你就有了梯度。
影像梯度:一對斜率,一個沿 x,一個沿 y。
我們把每個符號拆開來看。I 就是上一節的亮度函數——I(x, y) 是第 x 欄、第 y 列的亮度。∇I(讀作「grad I」)是梯度:每個像素上的一個雙分量向量。I_x 是水平方向的變化率——往右走一步亮度上升多少——我們用「右邊一欄的亮度 I(x+1, y)」減去「左邊一欄的亮度 I(x−1, y)」來估它。I_y 是垂直方向的變化率,用上下兩列以同樣方式估。來算個實際數字:拿我們的階梯方塊,看中央那個像素。它右邊是 220、左邊是 30,所以 I_x ≈ 220 − 30 = 190——一個很強的水平變化;它上下都是 30,所以 I_y ≈ 30 − 30 = 0——沒有垂直變化。那裡的梯度就是 (190, 0):一支指向右方的強箭頭,從暗指向亮。
從這兩個斜率,我們讀出邊緣強度(大小)與邊緣方向(角度)。
梯度向量帶有兩項我們會反覆使用的資訊。它的大小(magnitude) |∇I| = √(I_x² + I_y²) 就是箭頭的長度(兩個斜率的畢氏定理組合);它度量邊緣強度——此處的亮度懸崖有多陡。它的方向(orientation) θ = atan2(I_y, I_x) 是箭頭所指的角度;它度量邊緣方向,也就是亮度上升最陡的那個方向。(atan2 是安全的雙引數反正切,它在四個象限都回傳正確角度,不會被正負號搞混。)代入我們的中央像素:大小 = √(190² + 0²) = 190(強邊緣),θ = atan2(0, 190) = 0 弧度(箭頭正指向右)。關鍵在於:邊緣本身是與梯度「垂直」的——門洞的垂直邊緣是上下走向,而它的梯度卻指向側邊。把「大小」與「方向」記牢:再過兩篇要講的 SIFT 與 HOG 這類描述子,幾乎完全是用這兩個量的直方圖搭起來的。
純粹的中央差分能用,但有個缺點:它只看兩個像素,因此會放大雜訊——一粒雜點就能造出假斜率。實務上到處都用的修正是 Sobel 算子(Sobel operator),它估的是同樣的導數,但會在一個 3×3 視窗上做平均,讓單一個雜訊像素無法獨大。而它把這個視窗掃過整張影像的方式,正是卷積(convolution)——你在前面單元見過的「滑動加權視窗」運算:把一小格權重(卷積核 kernel)疊在每個像素上,把每對重疊的值相乘、加總,再把結果寫在中央,然後滑動、重複,掃完整張影像。Sobel 不過就是用兩個巧妙挑選的卷積核做卷積。這也是為什麼後面幾篇導讀,以及一般的 邊緣偵測(edge detection),都用卷積來框架陳述。
兩個 Sobel 卷積核:G_x 估水平導數 I_x,G_y 估垂直導數 I_y。
把 G_x 一欄一欄地讀,你會看見它同時在平滑與微分。由左到右是 (−1, 0, +1):一個中央差分(右減左)——這是微分的部分。由上到下權重是 (1, 2, 1):一個小小的加權平均,做垂直方向的模糊,中間那列算兩倍——這是平滑的部分。所以 G_x 回答「這裡有沒有垂直邊緣?」的同時,悄悄在三列上做平均以甩開雜訊。G_y 就是同一個核轉 90°:它由上到下微分、由左到右平滑,回答「這裡有沒有水平邊緣?」把影像和 G_x 卷積得到 I_x,和 G_y 卷積得到 I_y,再餵進上面的大小與方向公式。我們在階梯方塊上驗證:用 G_x 卷積那塊 3×3 得到 (−1·30 +1·220) + (−2·30 +2·220) + (−1·30 +1·220) = 190 + 380 + 190 = 760——很大,明確的垂直邊緣;用 G_y 卷積得到 (−1·30 −2·30 −1·220) + (+1·30 +2·30 +1·220) = 0——沒有水平邊緣。正是我們直覺所要求的答案。
動畫風格示意圖:一個 3×3 卷積核滑過影像方格,每個位置算出一個加權總和。
import numpy as np
def gradients(I):
"""Estimate gradient magnitude and orientation from a grayscale image I."""
I = I.astype(float)
Ix = np.zeros_like(I)
Iy = np.zeros_like(I)
# Central differences (ignore the 1-pixel border for simplicity).
Ix[1:-1, 1:-1] = I[1:-1, 2:] - I[1:-1, :-2] # right minus left -> I_x
Iy[1:-1, 1:-1] = I[2:, 1:-1] - I[:-2, 1:-1] # below minus above -> I_y
magnitude = np.sqrt(Ix**2 + Iy**2) # edge strength |grad I|
orientation = np.arctan2(Iy, Ix) # edge direction theta (radians)
return magnitude, orientation
# In practice you would convolve with the Sobel kernels instead of bare
# differences, because Sobel also smooths and is far less noise-sensitive:
# Gx = [[-1,0,1],[-2,0,2],[-1,0,1]]
# Gy = [[-1,-2,-1],[0,0,0],[1,2,1]]
# Ix = convolve2d(I, Gx); Iy = convolve2d(I, Gy)邊緣:第一種特徵
現在我們可以定義第一種真正的特徵了。邊緣(edge)是亮度急遽變化的位置——用我們的術語講,就是梯度大小 |∇I| 很大的那些像素。邊緣勾勒出物體的輪廓、明暗的交界、紙頁上字母的外形。它們是影像的骨架:就算只剩一張純邊緣圖、把所有平滑的內部都丟掉,人通常還是足以認出那個場景。偵測它們,正是 邊緣偵測(edge detection)這個經典任務。
最簡單的邊緣偵測器,直接從上一節推出來:在每個地方算出梯度大小,然後只保留那些超過某個閾值的像素。低於閾值就是「夠平了,不是邊緣」;高於閾值就是「夠陡了,算它是邊緣」。整個演算法就這樣——三行程式——而且它確實會讓影像裡的邊界亮起來。
def simple_edges(I, threshold):
"""Naive edge detector: threshold the gradient magnitude."""
magnitude, _ = gradients(I)
edge_map = magnitude > threshold # True where brightness changes steeply
return edge_map
# Pick threshold too LOW -> noise and texture flagged as edges (speckle).
# Pick threshold too HIGH -> faint but real edges vanish (broken outlines).
# The 'right' value depends on the image, the lighting, even the region.可是把它拿去跑一張真實照片,破綻立刻浮現。第一,邊緣會太粗:真實的亮度懸崖有好幾個像素寬,所以一整條帶狀的像素都過了閾值,你得到的是一坨糊掉的色塊,而不是乾淨的一像素線。第二,邊緣會斷裂:沿著同一條輪廓,梯度強度會起伏,這裡那裡掉到閾值以下,於是一條連續的邊緣碎成了虛線。第三,邊緣會佈滿雜訊斑點:隨機的感測波動到處製造小梯度,而如果你的閾值低到足以抓住微弱的真實邊緣,它也會一併抓進一場假邊緣的暴風雪。最糟的是,那個「對的」閾值並不是放諸四海皆準的常數——它取決於影像、光照,甚至你正在看哪個區域。一個能清乾淨明亮天空的值,會把昏暗的前景過度壓掉。
由簡單閾值化產生的邊緣圖,在照片上顯示又粗、又破碎、又雜亂的邊緣。
Canny 邊緣偵測器:一條嚴謹的流水線
John Canny 在 1986 年提出的邊緣偵測器,是針對我們剛剛列出的每一個弱點的經典解答,至今仍是主力工具。它的高明之處不在於某一個巧妙花招,而在於五個階段的縝密「排序」,每一個階段都在修補天真閾值化的某一項特定失敗。把它讀成一條流水線:影像從一端流入,另一端流出一張乾淨的、細的、相連的、一像素寬的邊緣圖。
- 高斯平滑——先對影像做溫和的模糊,讓隨機雜訊在我們動手取導數之前就被平均掉。這是針對「雜訊斑點」問題最重要的一招。
- 梯度計算——對平滑後的影像跑 Sobel,得到每個像素的大小 |∇I| 與方向 θ,和第 3 節完全一樣。
- 非極大值抑制——把又粗又寬的邊緣帶細化成一像素寬的稜線:只有當某像素是沿梯度方向上的局部最大值時,才保留它。
- 雙閾值化——用兩個閾值(而非一個),把每個倖存的像素分類成「強」(明顯是邊緣)、「弱」(可能是邊緣)或「無」。
- 遲滯(hysteresis)——保留每一個強像素,而弱像素只有在與某個強像素相連時才保留。這會把斷裂的邊緣重新縫合,同時排除孤立的雜訊。
第 1 階段最值得關注,因為「順序」正是重點所在:我們在微分之前先平滑。平滑是透過與一個二維高斯(下方那個鐘形權重核)做卷積完成的。這是把每個像素和它鄰居做平均的有原則做法——近的像素權重大、遠的像素權重小。
Canny 第 1 階段所用的二維高斯平滑核。
逐一拆解符號:x 與 y 是相對於卷積核中心的位移——(0, 0) 是我們正在計算的那個像素,(1, 0) 是它右邊的鄰居,依此類推。G(x, y) 是那個位移處的鄰居所獲得的權重。σ(sigma)是標準差,是控制模糊半徑的唯一旋鈕:σ 小只模糊最近的像素(保留細節,但壓不掉太多雜訊),σ 大則把平均攤到更寬的鄰域(非常平滑,存活下來的小邊緣更少)。exp(−(x² + y²)/(2σ²)) 這一項就是那條鐘形曲線:x² + y² 是離中心的距離平方,所以鄰居坐得越遠、權重就越平滑地下降——緊鄰的像素比三步之外的重要得多。前面的 1/(2πσ²) 是一個正規化常數,挑它是為了讓所有權重加起來等於 1;這很重要,因為這表示平滑不會讓整張影像變亮或變暗——它只是在局部重新分配亮度。舉例來說,用一個小小的 3×3 核,中心權重大約是 0.25、四個邊鄰各約 0.12、四個角更小——而這九個數字加起來剛好是 1。
為什麼要在微分「之前」先模糊?因為微分會放大雜訊,而順序的影響極大。導數對「變化」有反應,而雜訊就是變化——快速、隨機、一個像素接一個像素的抖動。直接對一張有雜訊的影像做微分,每一粒雜點都會變成尖銳的突波,把真實邊緣淹沒在假邊緣裡。先平滑會溫和地把那些隨機抖動平均掉(它們沒有一致方向,因此互相抵消),而真正的邊緣——一道連貫、綿延好幾像素的懸崖——幾乎能毫髮無傷地撐過模糊。接著對平滑後的影像微分,反應的就是真實的懸崖,而不是雜訊。先平滑再微分,能留住訊號、殺掉雜訊;先微分再平滑,雜訊早就已經爆掉了。
示意圖:一條高梯度像素帶與梯度方向箭頭,抑制後只保留局部最大值構成的稜線。
第 3 階段,非極大值抑制,是粗邊緣的解藥,值得具體想像。回想一下,真實的邊緣會產生一條好幾像素寬的高梯度像素「帶」,全都簇擁在真正的懸崖周圍。我們只想要這條帶的「脊」。所以在每個像素上,我們沿著它的梯度方向 θ 看過去——也就是橫越邊緣、變化最陡的方向——把它的大小與該方向上「正前方」和「正後方」的兩個鄰居比較。如果中央像素是局部最大值(嚴格大於兩者),它就在脊上,於是保留它;如果任一鄰居更大,中央就在帶的斜坡上、而非峰頂,於是把它抑制為零。想像橫越邊緣的大小是 30、90、50:只有 90 是局部最大值,所以 30 和 50 被歸零,一條 3 像素寬的帶就塌成乾淨的 1 像素脊。到處都這麼做,肥厚的色塊就變成俐落的線。第 4、5 階段接著處理「斷裂與斑點」問題:不用一個閾值,而用兩個來界定強、弱邊緣,並由遲滯來收尾——遲滯只在某個弱像素「連到」某個強像素時才信任它,於是把虛線輪廓重新接起來,同時丟掉孤零零的雜訊斑點。這條縝密的流水線,正是 Canny 邊緣偵測器(canny edge detector)所做的事,也是它如此決定性地勝過天真閾值化的原因。
從邊緣到關鍵點:為何邊緣還不夠
我們現在有了漂亮、乾淨的邊緣。那是不是大功告成了——能不能直接拿邊緣點當作跨影像配對用的關鍵點?還不行,而原因既微妙又重要到足以驅動整篇下一篇導讀。問題在於:邊緣點無法被精準定位。它告訴你邊界在哪裡,卻沒告訴你你正看著的,到底是那條邊界「上的哪一個點」。
來做個思想實驗——著名的孔徑問題(aperture problem)。在一張卡片上挖一個小洞(一個「孔徑」),透過它看一張影像,於是你只看見一個小小的圓形視窗。把這個視窗端正地擺在一條又長又直的邊緣上——比方說亮牆上一扇暗門的垂直邊界。現在把視窗「沿著」邊緣、平行於它稍微滑一點。你看見什麼?一模一樣的東西:一道垂直邊界橫切你的視窗,滑動前後完全相同。視野沒變,所以你根本無從得知自己滑了多遠。邊緣把「沿著它自身長度方向」的位置資訊全「抹掉」了。
把這件事接回梯度,一切就完全說得通了。沿著一條直邊緣,梯度在「一個」方向(垂直於邊緣)很強,在「另一個」方向(平行於邊緣)基本上是零。因為平行於邊緣的方向沒有變化,沿那個方向的移動就是看不見的。所以一個邊緣只釘住了「垂直」方向的位移——你橫越邊界橫到多遠——卻把「平行」方向的位移留得完全未知。邊緣點在一個方向可定位,在另一個方向卻是模稜兩可的。對於配對兩張影像,這種一維的模稜兩可是致命的:你永遠無法有把握地說「這裡這個確切的點,就是那裡那個確切的點」。
出路是要求更多。我們想要的,是「同時」在兩個方向都可定位的位置——也就是無論你把那個小孔徑視窗往哪個方向推,視野都會改變的點。那就是角點(corner):兩條邊緣相交的地方,因此在兩個不同方向都有強梯度,任何滑動都偵測得到。角點能在二維裡被釘到單一個像素上,而這正是一個穩定、可配對的 影像關鍵點(image keypoint)所需要的。(之後我們還會想要帶有「大小」或尺度概念的特徵——圓形的「斑點(blob)」區域——但角點要先來。)
這就是通往本單元其餘部分的橋樑。我們從原始像素開始,學到它們不穩定,便用梯度——也就是「變化」的語言——取代它們。從梯度,我們建起了邊緣,我們的第一種特徵,並用 Canny 把它精煉成乾淨的輪廓。但邊緣有孔徑問題,所以下一篇導讀會轉向角點與 Harris 角點偵測器(harris corner detector),它同時度量「兩個」方向上的梯度變化,以找出你真正能釘死的點。再之後是尺度與斑點,接著是描述子(SIFT、HOG)——它們正是用你在這裡學到的梯度大小與方向,來總結一個關鍵點的鄰域——最後是配對,屆時偵測 → 描述 → 配對的所有拼圖將會嚴絲合縫地扣在一起。