5.1. Image 物件¶
影像處理演算法會逐一像素地掃過整張影像。在每個位置上,它都做一些簡單的事情:讀取一個值、將它與閾值比較、把它與第二張影像中對應的像素結合、再把結果寫回去。當這些簡單的逐像素決策在整張影格上重複進行時,便構成了邊緣偵測、色塊追蹤、QRCode 解碼,以及其他所有經典電腦視覺技術的基礎。為了有效率地完成這些工作,演算法必須知道每個像素位於記憶體中的何處、每個像素的值究竟代表什麼意義,以及它應該關注影像的哪個部分。image.Image 正是組織這些資訊的物件。
Vision Sensors 的內容在 csi.CSI.snapshot() 回傳的那一刻便結束了。相機端機制為了產生擷取的影格所做的一切都已完成;應用程式手上握有 Image,接下來需要知道該如何處理它。
5.1.1. 緩衝區及其屬性¶
Image 內部含有一個指向 RAM 中連續位元組區塊的指標,以及一個攜帶三項中介資料的小型標頭:影像以像素為單位的寬度、以像素為單位的高度,以及這些位元組所採用的像素格式。位元組就是像素本身,以列為主(row-major)的順序儲存:先放最上面那一列的所有像素,接著是第二列的所有像素,依此類推一路向下到最底列。屬性則描述了該如何讀取它們。
寬度與高度只是單純的整數計數。像素格式則是比較有趣的屬性,因為它決定了每個像素佔用多少位元組,以及這些位元組編碼的內容。灰階影像每個像素攜帶一個位元組,存放亮度值。RGB565 影像每個像素攜帶兩個位元組,將紅、綠、藍欄位封裝進一個 16 位元的字組中。Bayer 影像每個像素攜帶一個位元組,但每個像素都是透過三種色彩濾鏡之一取樣而來,採用哪一種則由它在馬賽克中的位置決定。Vision Sensors 列舉了整份型錄;這裡重要的是,每個 Image 上都恰好設定了其中一種格式,而這個選擇驅動了每像素位元組數的運算,以及緩衝區中任一單一位元組的意義。
有了指向緩衝區的指標、寬度、高度與格式,演算法可能想要的其他每一項屬性,都能透過簡短的計算推導出來。像素 (x, y) 起始的那個位元組,位於距緩衝區起點 (y * width + x) * bytes_per_pixel 的偏移處。總位元組數為 width * height * bytes_per_pixel。下一列起始的位址,恰好在目前這一列起點之後 width * bytes_per_pixel 個位元組處。Image 透過單純的方法呼叫公開這三項屬性 -- width()、height()、format() -- 再加上透過 size() 取得的衍生 size。模組中其他地方的方法會自行使用這些值來進行偏移運算;應用程式碼則很少需要這麼做。
Image 是一個小型的 Python wrapper,指向一塊連續的記憶體:一個攜帶寬度、高度與像素格式的標頭,後面接著像素緩衝區本身。¶
5.1.2. 緩衝區從何而來¶
本章自始至終的預設情境,就是 Vision Sensors 已經介紹過的那一種:擷取的影格從 snapshot 抵達,位元組就放在相機的影格緩衝區中,而回傳的 Image 便指向它們。另外還有三種取得它的方式會經常出現,而每一種對於緩衝區最終落腳何處都各有不同的意涵。
從檔案載入的做法看起來像是把路徑傳給建構函式:image.Image("/sdcard/saved.jpg")。模組會把檔案讀入 Python 堆積上一塊新配置的緩衝區中。BMP、PGM 與 PPM 檔案在讀入過程中會被解碼,產生的 Image 攜帶未壓縮的像素格式。JPEG 與 PNG 檔案則維持壓縮狀態 -- Image 攜帶 JPEG 或 PNG 格式,緩衝區存放的基本上是檔案原封不動的位元組串流。要對壓縮影像進行任何像素層級的工作,應用程式得先透過 to_rgb565() 或 to_grayscale() 來轉換它,而那次轉換正是解壓縮 -- 以及隨之而來的堆積暴增,其中一個 30 KB 的 JPEG 可能變成 600 KB 的 RGB565 -- 實際發生的地方。從檔案載入在開發期間最為有用,當演算法需要針對與指令碼一同儲存的已知參考影格進行測試時。
從頭打造一張影像則是畫布的情境:image.Image(320, 240, image.RGB565) 要求模組以該格式配置那麼多位元組、將內容歸零,並把 wrapper 交回來。這些像素還沒有任何意義 -- 它們全都是零 -- 但這張空白影像是好幾種反覆出現模式的主力:用來與目前影格相減的參考影格、用來組合圖形疊加層的畫布、被填入後用作遮罩的二值緩衝區。
從 ndarray 建構則銜接了另一個方向,將任何數值運算的結果帶回 image 模組。把 float32 的 ulab.numpy.ndarray 傳給建構函式,會產生一個維度與該 ndarray 相符的 Image -- 兩軸的 (h, w) 形狀變成灰階影像,三軸的 (h, w, 3) 形狀變成 RGB565 -- 並將浮點值從 0.0 -- 255.0 縮放至整數像素範圍。神經網路的熱圖、任何種類的數值陣列,凡是由 ml 或 ulab 產生的東西,都會變成 image 模組的繪製與檢視這一面所能使用的對象。
這四種來源交回的都是同一種 Image。使用回傳物件的程式碼,從不需要追蹤它的來歷。
5.1.3. 對位元組的兩種視角¶
多數時候,應用程式碼會把 Image 當作一個具型別的影像物件來看待 -- 一個帶有具名方法的東西。故事的另一半是,同一個物件對於任何接受 bytes 引數的 MicroPython API 而言,也會透明地呈現為一段扁平的位元組序列。這些位元組並不是緩衝區的副本;它們是對緩衝區的直接視角。
正是這樣的安排,讓把擷取的影格推送出相機這件事變成一行就能搞定。對它進行雜湊運算、透過序列埠傳送它、將它轉送到網路 socket -- 這些都不需要額外一道「把影像轉成位元組」的步驟:
import csi
import hashlib
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)
img = csi0.snapshot()
uart.write(img) # transmits the raw pixel bytes
hashlib.sha256(img) # hashes the same bytes
sock.send(img) # sends them over a socket
類位元組的視角刻意預設為唯讀。影像緩衝區很大,有時還在影像處理堆疊的各層之間共用,因此若讓某段藏在呼叫堆疊深處、漫不經心的 buf[0] = 0 擁有悄悄損毀緩衝區的能力,那會是過於鋒利、不該外露的稜角。當應用程式真正需要的是讀寫層級的位元組存取時 -- 例如把校正值寫入某個已知偏移處 -- bytearray() 會回傳一個獨立、明確可讀寫、針對同一塊記憶體的視角,在呼叫處清楚標示出這個意圖。
5.1.4. 緩衝區位於何處¶
像素緩衝區大到讓它們落在 RAM 中的何處變得很重要。一個 QQVGA 的 RGB565 影格是 160 × 120 × 2 = 38,400 個位元組;一個 VGA 的 RGB565 影格是 614,400 個位元組;神經網路分類器可能消耗的 224 × 224 RGB565 輸入則約為 100 KB。在最小型的相機上,當執行階段啟動完成後,Python 堆積可能僅有區區數十 KB。若在堆積上保留超過一兩個影格的影像資料,便會把其他一切都擠下堆積。
出路在於:影像緩衝區大多並不存放於 Python 堆積。它們存放在 Vision Sensors 介紹過、那塊專用的 RAM 區域,也就是影格緩衝區 -- 同一塊記憶體,相機的 DMA 會把擷取的影格寫入其中,IDE 預覽則從其中讀出完成的影格。對 Image 進行的多數操作會就地修改其來源:演算法讀取像素、做出決定、把新值寫回去,並不會另外配置一張結果影像。那些確實會產生獨立結果的操作 -- 格式轉換以及少數幾種 -- 則可以透過 copy_to_fb 關鍵字引數,要求把結果放進影格緩衝區。copy_to_fb=True 同時做兩件事:它把結果影像放進影格緩衝區而非堆積(避開堆積壓力),並讓該結果成為 IDE 預覽接下來要顯示的影格。在管線的最後一步加上 copy_to_fb=True、看著結果出現在螢幕上、再從那裡反覆迭代,是影像處理中最有用的除錯慣用手法之一。
有了一個握著具標籤緩衝區的 wrapper、四種讓它誕生的方式、對其位元組的兩種視角,以及一個決定新影像落腳何處的開關,Image 便不再是個謎。剩下的那些基礎問題 -- 像素位置如何命名、每個像素究竟存放什麼、如何把操作限定在影像的某個部分 -- 都建立在它之上。