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

尺度、頻率與形狀:大師級工具箱

進階到大師級工具箱:跨越尺度觀看影像、將它們解碼成頻率,並重塑它們的形狀本身。

一張影像,多種尺度

把朋友的照片放在手臂遠處看,然後走到房間另一頭再看一次。抵達你眼睛的像素數量劇烈改變了——現在少了非常多——但你仍然立刻看出那是一張臉。電腦視覺需要同樣的穩健性。無論一張臉佔了 800 個像素還是 30 個像素,它都是一張臉。所以本軌道一路上我們悄悄迴避的一個關鍵想法是:一張影像並不只是單一固定解析度下的一格數字。它是一個「視角的家族」,每個視角揭露不同的東西。

尺度不是一個要被歸一化掉的麻煩——它承載著意義。湊近看一張衛星影像,你看到一棵棵樹;退後一步,這些樹融化成森林那片平滑的紋理。沒有哪個視角才是「正確」的。葉子住在精細的尺度;森林住在粗糙的尺度;而樹幹與枝條住在兩者之間的某處。如果你的演算法永遠只看一個尺度,它就對所有恰好住在其他尺度的結構視而不見。真實系統——人臉偵測器、特徵匹配器、醫療掃描器——都必須刻意地跨越尺寸去搜尋。

統攝這一切的概念是尺度空間(scale space):想像把你的影像逐漸地、越來越強地模糊,產生一疊連續的、越來越平滑的版本。微小的細節最先消失,接著是中等的,直到只剩下最寬廣的形狀。在這一疊裡上下滑動,就像在調整顯微鏡的焦距,在「精細顆粒」與「整體大局」之間移動。這一疊的實用、對電腦友善的版本就是 影像金字塔,我們接下來就來建造它。

影像金字塔:一圖多尺寸

想像一組俄羅斯套娃,或是 Google 地圖的縮放層級:同一個場景被存成一整串不同的尺寸,從一個又大又細緻的版本,一路縮到一張小小的縮圖。這正是 影像金字塔——我們把一張影像越縮越小的副本一層層疊起來,每一層大約是下面那層寬與高的一半。把最大的放最底下疊起來畫,它看起來就真的像一座金字塔。

最常見的一種是高斯金字塔(Gaussian pyramid),而建造它會重用第 2 篇指南裡的老朋友:高斯模糊。配方就只是三個動作不斷重複。

  1. 從全解析度的影像開始——稱它為第 0 層。
  2. 用一個小的高斯核模糊它,去掉最精細的細節。
  3. 降採樣:丟掉每隔一列、每隔一行的像素,讓兩個維度各減半(像素數變成四分之一)。
  4. 把結果當成下一層,從第 2 步開始重複,直到影像只剩幾個像素寬。
L_{k+1} = \operatorname{downsample}\big(\operatorname{blur}(L_k)\big), \qquad L_0 = \text{original image}

把這一行白話讀出來:要從當前這層得到下一層金字塔,先模糊,再縮小。這裡 L_k 是金字塔第 k 層的影像(所以 L_0 是你的原圖,L_1 是一半大小,L_2 是四分之一大小,依此類推);\operatorname{blur} 是一次高斯平滑;而 \operatorname{downsample} 表示在每個方向上每隔一個像素保留一個。因為寬高都減半會讓像素數除以四,一張 1024×1024 的影像會變成 512×512、再 256×256……寥寥幾層就涵蓋了極大範圍的尺度,而額外花費的記憶體只比原圖本身多約三分之一(1 + 1/4 + 1/16 + … ≈ 4/3)。

還有一個漂亮的搭檔,叫做拉普拉斯金字塔(Laplacian pyramid)。在每一層,我們不存模糊縮小後的影像,而是存縮小時被「丟失」的東西——精細的細節。做法是拿某一層,把下一(較小)層重新放大回相同尺寸,再相減。

\text{Laplacian}_k = L_k - \operatorname{upsample}(L_{k+1})

用文字說:第 k 層的細節,等於清晰影像 L_k 減去把下一層 L_{k+1} 模糊放大回來的副本。由於 L_{k+1} 只保留了平滑、大尺度的內容,把它減掉後剩下的,正好是住在這個尺度的邊緣與紋理——就像剝下一層細節的薄帶。把所有這些差值層加上最頂端的小圖一起疊好,你就能反向操作、完美地重建原圖。這讓拉普拉斯金字塔非常適合多尺度融合(一帶一帶地混合,把兩張照片無縫拼接)以及壓縮(細節層大多接近零,所以壓得很狠)。而由粗到細的搜尋——先在頂端的小圖上找出粗略答案,再隨著往下逐步精修——正是追蹤器、立體匹配與光流能保持快速的原因:先解掉容易的小問題,再來打磨。

import cv2

# Build a Gaussian pyramid: blur-then-halve, repeatedly
def gaussian_pyramid(img, levels=5):
    pyr = [img]                      # level 0 = original
    for _ in range(levels - 1):
        # pyrDown blurs with a Gaussian THEN downsamples by 2 in one call
        img = cv2.pyrDown(img)
        pyr.append(img)
    return pyr

# Build a Laplacian pyramid: each level = detail lost when shrinking
def laplacian_pyramid(gpyr):
    lap = []
    for k in range(len(gpyr) - 1):
        up = cv2.pyrUp(gpyr[k + 1], dstsize=gpyr[k].shape[1::-1])
        lap.append(cv2.subtract(gpyr[k], up))   # L_k - upsample(L_{k+1})
    lap.append(gpyr[-1])             # keep the tiny top image to allow rebuild
    return lap
OpenCV 的 pyrDown 一步就完成模糊與縮小;拉普拉斯層是清晰影像減去重新放大的較小層。

用頻率思考:影像的傅立葉轉換

這是整個軌道概念上的最高峰,所以我們慢慢爬。先從聲音開始,那裡這個想法廣為人知。一個和弦聽起來像一個豐厚的整體,但調音器會告訴你它其實是好幾個純音加在一起——一個低沉的嗡鳴、一個中音、一個明亮的高音,每個各有自己的響度。傅立葉轉換(Fourier transform)就是那台機器:它接收任何訊號,回報出配方——它含有多少各個純頻率的成分。

現在把這個從一維的聲音提升到二維的圖片。影像的傅立葉轉換說:任何影像都能被重建成一堆簡單的 2D 波形圖案之和——那些在圖上明—暗—明—暗起伏的正弦波(sinusoid)。每個波都有一個頻率(條紋擠得多緊)和一個方向(條紋朝哪走——水平、垂直、斜向)。緩慢而寬的起伏是低頻;緊密而急促的起伏是高頻。把成千上萬個這樣的波各以恰當的強度混合起來,你就能精確地重現任何一張照片。

鎖住兩個直覺。低頻=平滑的大尺度色調——天空那柔和的漸層、一面牆的整體亮度。高頻=精細的細節與邊緣——睫毛、碎石的紋理、邊界處那道銳利的跳變。這和金字塔裡的低/高之分(粗層 vs 細層)是同一回事,也和第 4 篇「邊緣就是快速變化」是同一個想法,只是換成用波的語言來說。

當人們把傅立葉轉換畫成一張圖時,他們展示的是幅度譜(magnitude spectrum):一張新影像,其中每個點代表一個波,依它的頻率與方向擺放,而它的亮度顯示那個波出現得多強。依慣例,中心是零頻率(整體的平均色調),往外走則是越來越高的頻率。所以中心有一團亮塊是正常的(多數照片大多平滑)。某個方向上的一道亮條紋表示有強烈的條紋垂直於它而行;遠離中心的亮點則表示有清脆的重複紋理。讀懂這些圖是一項真本事——而它在下一節立刻就會有回報。

F(u,v) = \sum_{x=0}^{M-1} \sum_{y=0}^{N-1} f(x,y)\; e^{-j 2\pi\left(\frac{u x}{M} + \frac{v y}{N}\right)}

別慌——我們只會詮釋它,絕不推導。這是二維離散傅立葉轉換(2D DFT)。逐個符號來:f(x,y) 是第 x 列、第 y 行的像素強度——就是你平常的影像。MN 是影像的高與寬。(u,v) 這一對挑出某一個特定的波:u 是它的垂直頻率,v 是它的水平頻率。F(u,v) 是針對那個波的答案——一個複數,它的幅度(magnitude)說明那個波在影像中有多少(就是幅度譜圖所顯示的),它的相位(phase)說明那個波坐落在哪(它的位移)。j 是虛數單位,而 e^{-j2\pi(\dots)} 由尤拉公式(Euler's formula)來看,不過就是一個餘弦加上 i 乘一個正弦——也就是一個頻率為 (u,v) 的 2D 正弦波。雙重求和 \sum_x\sum_y 則掃過每一個像素。

那麼單獨一項到底在「做」什麼?想像把波形 e^{-j2\pi(\dots)} 疊在你的影像上,逐點地問:「這個波的亮部,有沒有落在影像的亮部上?」這個求和就是在統計那份吻合度。如果影像很像那個波,各項就會彼此增強,F(u,v) 算出來就大;如果不像,它們就互相抵消,算出來接近零。具體地說,F(0,0) = \sum f(x,y)——就是所有像素的總和,也就是總亮度——這正是為什麼幅度譜最中心是最亮、代表平均色調的那一點。其他每一個 F(u,v) 都只是在量度「我的影像中含有多少這一個波形圖案?」

濾波其實就是頻率選擇

這就是那個會重新框定整個軌道的回報。你遇過的每一個濾波器都能用頻率的語言重新命名,於是它們突然全都化為同一個想法:濾波器在選擇要保留哪些頻段、要壓抑哪些。與其說「這個核在平均鄰居」,我們可以說「這個核保留低頻、丟掉高頻」。同一個操作,更深的真相。

f * h \;\;\longleftrightarrow\;\; F \cdot H

這短短一行就是卷積定理(convolution theorem),它是整個訊號處理中最有用的事實之一。左邊 f * h 是卷積:把核 h 滑過影像 f——正是第 2 篇的滑動視窗操作。右邊 FH 是影像與核各自的傅立葉轉換,而 F \cdot H 是它們單純的逐點相乘。雙箭頭表示這兩者等價:在空間域做卷積,等同於在頻率域做相乘。在像素世界裡做那繁瑣的滑動視窗求和,和把兩者都帶到頻率世界、把頻譜相乘、再轉回來,是完全一樣的。

為什麼這如此重要?因為相乘就是一張遮罩(mask)。核的頻譜 H 就像一張頻率遮罩,蓋在影像的頻譜 F 上:H 接近 1 的地方,那個頻率原封不動通過;H 接近 0 的地方,那個頻率就被抹掉。所以濾波器,字面意義上,就是一份「哪些波得以存活」的配方。這一張圖就解釋了整個軌道裡的每一個濾波器。

高斯模糊是一個低通濾波器(low-pass filter):它的頻譜在中心接近 1、往外淡向 0,所以它放行平滑的低頻、悄悄衰減高頻。這就是它為什麼會模糊——精細細節是高頻,而它被調小了。銳化工具則反過來運作。第 4 篇的拉普拉斯與 Sobel 核是高通(high-pass):它們把低頻壓到近零、放行邊緣(高頻),這就是為什麼它們的輸出大多平坦、只有明亮的輪廓。反遮罩銳化(unsharp masking)是一個高增益(high-boost)濾波器:它不丟掉低頻,而是在上面額外加一劑高頻,於是邊緣躍然而出、整體色調卻得以保留。

這甚至帶來一個實務上的紅利。大型卷積一個像素一個像素地做很慢,但頻譜相乘很便宜,而且有一套快速演算法(FFT)能讓你迅速進出頻率世界。所以對大型核,工程師真的就是用「轉換、乘上一張手繪的頻率遮罩、再轉換回來」來濾波——例如把頻譜裡某個亮點消掉,就能抹除一個週期性的條紋圖案(我們在最後的流程裡會用上這一招)。

形態學:重塑二值形狀

現在我們從色調與頻率切換到純粹的形狀。回想第 1 篇,閾值化(thresholding)把一張灰階影像變成乾淨的黑白遮罩:每個像素不是前景(物體,「開」)就是背景(「關」)。實務上這些遮罩從不完美——一個閾值化後的字母會有鋸齒邊緣、背景裡散落的白色雜點,以及筆畫內部細小的黑色針孔。形態學(morphology)就是用來整理這些二值形狀的工具箱,把它們當成可以被擴張、收縮、重塑的區域。

每一個形態學操作都用到一個小小的探測形狀,叫做結構元素(structuring element)——把它想成一個小印章或餅乾模,常常是 3×3 的方塊或一個小圓盤。你把這個探針滑過整張遮罩,在每個位置問一個關於探針如何與前景重疊的是非題。不同的問題給出不同的操作。探針的大小與形狀,決定了清理有多激進、以及哪些特徵能存活。

\text{Erosion: } A \ominus B \qquad \text{Dilation: } A \oplus B \qquad \text{Opening} = (A \ominus B)\oplus B \qquad \text{Closing} = (A \oplus B)\ominus B

用滑動探針的圖像來讀這些,而不是看符號。A 是你的前景形狀,B 是結構元素。膨脹(dilation)A \oplus B 會把一個像素設為「開」,只要以它為中心的探針重疊到任何一個前景像素——於是前景區域向外生長,縫隙與針孔被填滿,就像漲潮淹沒低窪處。侵蝕(erosion)A \ominus B 只有在以它為中心的探針完整地容納在前景內時,才保留那個像素為「開」——於是區域向內收縮,凡是比探針還細的東西都被吃掉,就像退潮只露出高地。它們互為相反:膨脹生長,侵蝕收縮。

魔法在於把它們組合起來。開運算(opening)=先侵蝕再膨脹。侵蝕先刪掉細小的雜點(它們太細而無法存活),接著的膨脹把存活下來的真實物體長回大約原本的大小——淨效果:鹽粒雜點被移除,物體大小保持不變。閉運算(closing)=先膨脹再侵蝕。膨脹先填滿小洞與縫隙,接著的侵蝕把脹大的形狀縮回來——淨效果:針孔與裂縫被填上,物體大小保持不變。口訣:開運算清理外部(除去外面的胡椒粒),閉運算清理內部(填補內部的洞)。

一個具體的實作範例。你對一個掃描的字母「A」做閾值化,但結果一團亂:背景裡散落著一個個單獨的白色像素(鹽雜訊),而粗粗的斜筆畫上有幾個黑色針孔(墨水較淡之處)。用一個 3×3 方塊做開運算:每個孤零零的雜點只是單一像素,侵蝕把它整個抹掉,而既然沒剩下東西可供膨脹長回,它就永遠消失了——但粗壯的筆畫只在侵蝕時失去一圈一像素的邊緣,又在膨脹時補回來,所以「A」完好無損。接著做閉運算:膨脹把針孔架橋填上(鄰近的墨水湧入),隨後的侵蝕把筆畫修回原本大小——現在筆畫變得實心。兩個操作,鋸齒狀的遮罩就變成一個清脆乾淨的「A」。

import cv2
import numpy as np

# mask: a binary (0/255) image from thresholding in Guide 1
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))  # the probe B

opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN,  kernel)  # erode then dilate -> kill specks
cleaned = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel)  # dilate then erode -> fill pinholes

# The raw building blocks, if you want them directly:
# eroded  = cv2.erode(mask, kernel)   # A (-) B : shrink
# dilated = cv2.dilate(mask, kernel)  # A (+) B : grow
先開後閉是清理閾值化遮罩的經典組合拳:先除去外部雜點,再填補內部孔洞。

組裝工具箱:一條真實的處理流程

你現在擁有經典影像處理工具箱裡的每一件工具。使用它的藝術在於排序——每件工具只做一件事,而你把它們串接的順序,決定了你得到的是乾淨的結果、還是把錯誤放大。讓我們走一遍一個真實的端到端任務:把一張起皺、照明不均、略帶雜訊的文件掃描,數位化成一張清脆的二值影像,可以餵給文字辨識。

  1. 先處理色調(第 1 篇):套用伽瑪調整與直方圖等化,讓暗淡與明亮的區域共享一個合理的對比範圍。早點修好色調,會讓後面每一步都拿到更好的資料。
  2. 去雜訊(第 3 篇):用中值或雙邊濾波殺掉顆粒,而不模糊字母邊緣——保邊去雜訊在此很重要,因為接下來的步驟要去尋找邊緣。
  3. 分割(第 1/4 篇):閾值化成黑白遮罩(若想要輪廓則用 Sobel 邊緣)。這要在去雜訊之後做,否則每一顆雜訊雜點都會變成假的邊緣或假的前景像素。
  4. 清理形狀(本篇):開運算移除殘留的背景雜點,閉運算填補筆畫裡的針孔——把鋸齒遮罩變成實心、可計數的形狀。
  5. 特殊情況(本篇):如果掃描有重複的背景浮水印或感測器條紋,跳到頻率域,把頻譜裡那些惱人的亮點抹掉,再轉換回來。如果你得在未知尺寸下找文字,就跨越一座影像金字塔、由粗到細地搜尋。

順序並非隨意——它遵循因果。在閾值化之前先校正色調,否則一個陰暗的角落會被錯判為前景。在找邊緣之前先去雜訊,否則你會把雜訊銳化。在閾值化之後才做形態學,因為侵蝕與膨脹只在二值遮罩上才有意義。每件工具都假設前一件已經做完它的工作;用錯順序串接,它們就會互相打架。

import cv2

def clean_document(gray):
    # 1. TONE (Guide 1): even out contrast across the page
    eq = cv2.equalizeHist(gray)

    # 2. DENOISE (Guide 3): kill grain, keep edges
    den = cv2.bilateralFilter(eq, d=7, sigmaColor=50, sigmaSpace=50)

    # 3. SEGMENT (Guide 1): adaptive threshold handles uneven lighting
    mask = cv2.adaptiveThreshold(den, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                 cv2.THRESH_BINARY_INV, blockSize=31, C=10)

    # 4. CLEAN SHAPES (this guide): open removes specks, close fills holes
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN,  k)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
    return mask

# Order encodes cause-and-effect: tone -> denoise -> threshold -> morphology
一個簡短的函式,把四篇指南份量的工具串接起來,每件各就其位。

退一步,看看你走了多遠。你在本軌道一開始面對的是原始像素值與直方圖;現在你能校正色調、能在不破壞邊緣的前提下平滑與去雜訊、能尋找與銳化邊緣、能用金字塔跨尺度觀看、能用傅立葉轉換分解成頻率、能用形態學運算重塑二值遮罩。這就是完整的經典工具箱——在深度學習之前,驅動電腦視覺數十年的正是這些相同的操作。