三個數字組成一種顏色
在上一篇我們達成了一個簡單的共識:一個灰階像素就只是一個數字,一個從黑(0)到白(255)的亮度量度。這一個數字是誠實的,但它是「色盲」的。它能告訴你某一點有多亮,卻永遠無法告訴你,你看到的是紅蘋果還是一片亮度完全相同的綠葉。要讓一個像素裝得下顏色,我們做的事情簡單到幾乎令人不好意思:不是存一個數字,而是存三個。這三個數字是那一點上紅光、綠光、藍光各自的份量,三者合起來就構成了RGB 色彩模型。
請在腦中牢牢記住這個最重要的畫面:我們混的是光,不是顏料。想像一間暗房裡有三支手電筒——一支紅、一支綠、一支藍——同時照向同一面白牆。每個數字(R、G、B)就是其中一支手電筒的調光旋鈕,從 0(關閉)一路轉到 255(全亮)。當光束彼此重疊時,它們會相加而變得更亮。所以三支手電筒全開時,並不會疊出一團混濁的暗色,而是在三者重疊處給你一塊明亮的白光。把每個旋鈕都轉到零,牆上就一片漆黑。光越多就越亮——這叫做加色(additive)混色。
一張彩色影像,旁邊是它分離出的紅、綠、藍三個通道,每個通道都以灰階圖層呈現。
我們用具體的三元組把它釘死,寫成 (R, G, B)。純紅是 (255, 0, 0):紅手電筒全開,另外兩支關閉。純綠是 (0, 255, 0),牆上發出綠光。全部關掉就得到 (0, 0, 0) =黑。全部全開,三道光疊起來就成了 (255, 255, 255) =白。最有趣的是黃色:根本沒有黃色手電筒,可是 (255, 255, 0)——紅全開、綠全開、藍為零——在你眼裡看起來就是黃色。你的眼睛把「大量紅光和大量綠光同時抵達」讀成「黃色」這一個單一感覺。整個把戲就是這樣:三個旋鈕,而你能看到的每一種顏色,都是這三個旋鈕的某種設定。
# A colour pixel is just a triple of three 8-bit numbers (0..255). # Same idea as the single grayscale number from guide 1 -- now there are three. red = (255, 0, 0) # full red light, nothing else green = ( 0, 255, 0) # full green light white = (255, 255, 255) # all three lights full on -> ADD up to white black = ( 0, 0, 0) # all lights off yellow = (255, 255, 0) # red + green light, no blue -> looks yellow # Brightness ADDS: more light per channel = brighter, never darker. # (This is the opposite of mixing paint, which gets darker.)
通道:把三張灰階影像疊在一起
到目前為止我們只看了一個像素。現在把鏡頭拉遠,看整張圖。在第一篇裡,一張灰階數位影像是一個形狀為 H×W 的數字方格(高 H 列、寬 W 行),每一格存一個亮度值。彩色影像是同一個方格,只是現在每一格存的是三個數字而不是一個。我們可以把它想成三整張方格疊在一起:一張存所有紅色值、一張存所有綠色值、一張存所有藍色值。每一張這樣的方格就叫一個通道(channel)。整個東西的形狀是 H×W×3——高、寬,再加上代表三種顏色的深度 3。
這裡有一個把一切接回第一篇的關鍵:每一個單獨的通道,本身就只是一張灰階影像。單看紅色通道,它就是一張「每像素一個數字」的二維方格,正是你已經懂的那種灰階陣列——只不過現在每個數字的意思是「這裡有多少紅光」,而不是「這裡有多亮」。綠色和藍色通道也一樣。所以彩色影像並不是一個神祕的新東西;它就是三張舊的、你熟悉的灰階影像,疊起來再各自上色。你對單一方格學到的一切——列、行、0 到 255 的值——原封不動地套用在每一個通道上。
用一張人臉肖像照把它具象化。單獨把紅色通道拿出來用灰階顯示:皮膚反射很多紅光,所以臉頰和嘴唇看起來很亮(數值高、接近白),而一件藍色襯衫看起來很暗。現在把藍色通道拿出來:皮膚反射的藍光相對很少,所以同一張臉看起來偏暗、有點褪色,而那件藍襯衫此時卻亮起來。人臉的綠色通道通常看起來最銳利、細節最豐富,因為臉會反射不少綠光。同一個場景的三張不同灰階「X 光片」——只有當你把它們疊起來、讓眼睛把這些光重新加回去,你才看到一張正常的彩色人臉。
import numpy as np
# A tiny 2x2 colour image: shape (H=2, W=2, 3).
# Each pixel is [R, G, B].
img = np.array([
[[255, 0, 0], [0, 255, 0]], # row 0: red pixel, green pixel
[[0, 0, 255], [255, 255, 0]], # row 1: blue pixel, yellow pixel
], dtype=np.uint8)
print(img.shape) # (2, 2, 3) -> height, width, 3 channels
# Slice out one channel -> it is a plain HxW grayscale grid.
red_channel = img[:, :, 0] # just the R numbers
blue_channel = img[:, :, 2] # just the B numbers
print(red_channel.shape) # (2, 2) -> back to a guide-1 grayscale image一張 H×W×3 的彩色影像被拆解成三張 H×W 灰階方格的堆疊,分別標示為 R、G、B。
色彩中的位元深度:上百萬種顏色
第一篇介紹過位元深度:每個數值用幾個位元(bit),透過 L = 2^b 決定這個數值能取多少個不同的階。當 b = 8 個位元時,單一灰階像素有 L = 256 個可能的亮度階(0 到 255)。色彩就是把這個一模一樣的想法用了三次。在標準色彩裡,我們給每一個通道 8 個位元,所以 R、G、B 各自是一個有 256 個檔位、彼此獨立的旋鈕。三個通道 × 8 個位元 = 每像素 24 個位元,這就是為什麼一般彩色叫做 24 位元色彩(或「每通道 8 位元」)。沒有發明任何新東西——我們只是拿第一篇的那個旋鈕,再加裝兩個。
這樣能給我們多少種顏色?因為三個旋鈕各自獨立轉動——任何一個紅的檔位都能配上任何綠、任何藍——所以我們把選擇數相乘。
每通道的階數取三次方,因為三個通道彼此獨立變化。
我們逐個符號讀。b 是每通道的位元數(就是第一篇 L = 2^b 裡的那個 b)。所以 2^b 是一個通道能取的階數——當 b = 8 時就是 2^8 = 256,也就是我們熟悉的 0 到 255 範圍。接著我們對它取三次方(那個小小的 3),因為有三個獨立的通道:256 個紅階裡的每一個,都能自由配上 256 個綠階中的任何一個,而其中每一種又能配上 256 個藍階中的任何一個。把獨立的選擇相乘,就是 256 × 256 × 256,也就等於 (2^8)^3 = 2^24。按下去算:256 × 256 × 256 = 16,777,216——一個小小的像素就能有大約 1670 萬種不同顏色。這就是為什麼螢幕會標榜「上百萬種顏色」;這個數字直接從三個 256 階的旋鈕掉出來。
色彩空間:RGB 只是描述顏色的一張地圖
我們一直把 (R, G, B) 當成顏色的「定義」。其實不是——它只是一個方便的座標系統。把人能感知的所有顏色想成一片疆域,像一個國家。RGB 是這片疆域的一張地圖,用三個軸(紅、綠、藍)畫出來。但同一片疆域也能用換了軸向的其他地圖來描述,而每一張這樣的地圖就是一個色彩空間。就像一座城市同時有「街道地址」和「經緯度」——兩套座標系指向同一個地方——一個顏色既有 RGB 的描述,也有其他描述。沒有哪一個比較「真實」;你挑那張讓你眼前工作最輕鬆的地圖。
對初學者最有用的替代地圖是 HSV,它用三個更貼近人類直覺的軸來重新描述顏色。色相(Hue) 是「哪一種顏色」——你在彩虹環上的位置(紅、橙、黃、綠……再繞回紅),通常用 0° 到 360° 的角度表示。飽和度(Saturation) 是「多鮮豔」——0 是褪色的灰,高則是純粹濃烈的顏色。明度(Value) 是「多亮」——從暗到全亮。注意這正是一個人在五金行描述油漆的方式(「深而鮮豔的藍,偏暗一點」),這就是 HSV 為什麼這麼直覺。同一個實體像素既能寫成 (R, G, B),也能寫成 (H, S, V);在這兩張地圖之間轉換不會丟失任何資訊。
電腦視覺為什麼要費事換地圖?因為 HSV 把 RGB 糾纏在一起的東西拆開了。在 RGB 裡,如果一朵雲飄過太陽、場景變暗,R、G、B 三者會同時下降——顏色的「身分」被抹散在三個數字裡,於是「這是什麼顏色」和「它有多亮」混在一起。在 HSV 裡,「什麼顏色」幾乎完全住在色相(Hue),「多亮」則住在明度(Value)。所以如果你想找出所有紅色物體、不管光線如何,你可以只測「色相是不是接近紅?」而大致忽略亮度的變化。把顏色與光線分開,正是電腦視覺改用另一個色彩空間最常見的理由之一。
有一個特別重要、方向相反的地圖轉換——從三個數字降到一個:把彩色轉成灰階。這是一個投影,就像把一個三維的點壓到一條直線上:我們把 (R, G, B) 塌縮成一個亮度數字 Y。最直覺的猜法是把它們平均,(R+G+B)/3,但對我們真實的視覺而言這是錯的。我們的眼睛對三種光的敏感度並不相同,所以誠實的轉換是一個加權總和:
感知亮度:三個通道的加權平均,而不是普通平均。
逐個符號看:Y 是這個像素輸出的那一個灰階亮度。R、G、B 是這個像素的三個通道值(就是之前那些 0 到 255 的數字)。0.299、0.587、0.114 這三個數是權重——代表人眼對每種顏色在亮度上的貢獻感受有多強。綠色的權重(0.587)最大,因為我們的眼睛對綠光最敏感,所以綠對「看起來多亮」貢獻最多;紅居中(0.299),而藍只占很小(0.114),因為我們的眼睛對藍最不敏感。關鍵在於這些權重加起來等於 1(0.299 + 0.587 + 0.114 = 1.000):這讓它成為加權平均,而不是普通平均,並保證白色仍是白色——丟進 (255, 255, 255),你得到 0.299·255 + 0.587·255 + 0.114·255 = 255,依然是全白。試一個純綠像素 (0, 255, 0):Y = 0.587·255 ≈ 150,一個相當亮的灰;而純藍 (0, 0, 255) 給出 Y = 0.114·255 ≈ 29,幾乎全黑——這正好對應一個事實:在相同強度下,綠看起來比藍亮得多。
為什麼電腦視覺在意色彩的表示方式
退一步,注意這裡更大的教訓:你如何表示顏色是一個工程決策,而不是大自然交下來的事實。同一個場景可以是一張三通道的數位影像、一張單通道的灰階影像,或一個 HSV 陣列,而一位好的電腦視覺工程師會挑選那個能讓任務更簡單、更快、或更可靠的表示法。這整篇的收穫,就在於學會問:「這項工作真正需要哪一種表示?」
理由一:大小。一張彩色影像每像素帶三個數字;一張灰階影像每像素只帶一個。所以若顏色與任務無關,轉成灰階就立刻把資料量——以及計算量——砍成三分之一。經典例子是邊緣偵測,我們只在乎亮度在哪裡急遽變化(一扇門的輪廓、一條路的邊緣)。色相在那裡幫不上忙,所以多數邊緣偵測器只在單一灰階通道上運作,因而輕了三倍。
理由二:對光線的穩健度。挑對色彩空間能讓演算法不受光線變化影響。假設你想在影片裡偵測膚色,做一個臉部濾鏡。在 RGB 裡,當人從陽光走進陰影時,膚色像素會四散亂跑,因為三個通道一起下降。轉成 HSV(或一個叫 Lab 的空間),膚色往往會聚集在色相的一個窄帶裡,而當只有亮度改變時這個帶幾乎不動——所以一個以色相為基礎的判斷,能穿過那些會騙倒 RGB 判斷的陰影,持續找到皮膚。同樣的像素,更聰明的地圖。
理由三:通道可以分開處理。因為每個通道都是它自己的灰階方格,你可以自由地獨立對待它們——只提高紅色通道讓照片變暖、單獨對雜訊較多的藍色通道做去雜訊、或把三個通道當成分開的輸入餵給後續演算法。前面那個「灰階圖層的堆疊」的畫面不只是個好比喻;資料在記憶體裡實際上就是這樣擺放的,正是這種佈局讓逐通道的技巧成為可能。
往下走時請抓住這個大局。不管是彩色還是灰階、HSV 還是 RGB,一張影像歸根究柢就是一個數字方格——而那正是通往電腦視覺其餘部分的大門。下一篇會問:這個乾淨的方格究竟從何而來,透過取樣(sampling)與量化(quantization)把真實世界平滑的光變成離散的像素。在那之後,我們會沿著光線倒推穿過相機,先用針孔模型,再用把三維世界映射到我們二維數字方格的矩陣。這些步驟,每一個都會拿起你現在理解的像素,把它們推過幾何,再往後推進學習。