5.4. 讀取與寫入像素

大多數針對影像的操作會把逐像素的工作隱藏在單一方法呼叫之內,遍歷每個像素的迴圈以原生速度執行。不過,仍有一些情況需要應用程式碼直接存取某個特定像素:讀取某個位置上的內容、把新的數值寫入某個像素、為校正步驟取樣單一點,或是除錯已知位置上的數值。image 模組透過兩種定址形式提供這種層級的存取方式,各自對應一種思考像素所在位置的不同方式。

5.4.1. 依座標定址

最自然的形式就是「座標」一節已經建立起詞彙的那一種:以笛卡兒座標 (x, y) 來指名一個像素。get_pixel() 接受 (x, y) 並傳回該位置上的數值;set_pixel() 接受相同的 (x, y) 連同一個數值並將其寫入。

這些呼叫所傳回或接受的內容取決於影像的格式。灰階、二值與 Bayer 影像每個像素只帶有單一數值──灰階是亮度、二值是 01、Bayer 是單一色彩通道的取樣──因此 get_pixel() 會傳回單一整數。RGB565 將三個色彩通道封裝在 16 位元中,而 get_pixel 預設會將它們解封裝成 (r, g, b) 元組,每個通道對應到 0 -- 255 的範圍。

預設行為在兩端都可以反轉。在 RGB565 影像上對 get_pixel 傳入 rgbtuple=False,會退回到原始的 16 位元封裝字組──與線性索引所傳回的形式相同,當應用程式打算將同一個封裝值直接寫回時,這也是較有效率的形式。在單一通道影像上傳入 rgbtuple=True 則正好相反:儲存的數值會在傳回前轉換成 RGB888 元組,而 Bayer 影像會經過即時的去馬賽克(debayer)步驟。這個引數的存在,讓呼叫端程式碼能夠以統一的色彩空間索取像素,不論底層影像實際上是如何儲存它們的。

壓縮影像──JPEG 與 PNG──不受 get_pixelset_pixel 支援。它們的位元組並不代表已知位置上的像素,因此這兩個方法會引發錯誤,而不是傳回一個毫無意義的數值。

在實務上,這些模式看起來像是:

v = img.get_pixel(40, 30)            # grayscale: int 0..255
img.set_pixel(40, 30, 255)           # write white

r, g, b = img.get_pixel(40, 30)      # RGB565: defaults to (r, g, b) tuple
img.set_pixel(40, 30, (255, 0, 0))   # write red

如果所要求的 (x, y) 落在影像之外,get_pixel 會傳回 None,而 set_pixel 則不做任何事。這在設計上是寬容的:許多演算法會緊貼著影像邊緣行進,並短暫地索引到超出範圍的位置,而一個安靜的無動作(no-op)比起每次發生時都丟出例外要來得不那麼擾人。

5.4.2. 依線性索引定址

另一種形式是依像素在底層緩衝區中的位置來定址。回想一下緩衝區的配置:像素是一列接著一列儲存的,先放最上面一列的所有像素,再放下一列的所有像素,如此一路往下直到最底部。這種排列方式意味著每個像素都有一個單一整數索引,從左上角的 0 開始計數,並依序沿著每一列遞增。座標 (x, y) 上的像素,其線性索引為 y * width + x

一個 4 乘 3 的儲存格網格。每個儲存格帶有 一個較大的線性索引,從左上角的 0 一路到右下角的 11, 以及下方一個較小的 (x, y) 元組。 各欄在頂端標示為 x 等於 0、1、2、3; 各列在左緣標示為 y 等於 0、1、2。下方的圖說 給出兩者的關係:線性 索引等於 y 乘以寬度再加上 x。

像素同時以笛卡兒座標 (x, y) 以及一個沿著緩衝區一列接一列、由左至右行進的線性索引來定址。

image 模組透過一般的 Python 下標記法來提供這個索引:img[i] 讀取線性索引 i 上的像素,img[i] = value 寫入一個像素。索引形式所傳回的是該格式的 原始儲存值,而不是 get_pixel() 預設所傳回的解封裝元組。這項區別很重要,因為先前所選擇的格式決定了原始值看起來是什麼樣子:

  • 灰階與 Bayer 像素以 8 位元整數傳回。

  • RGB565 與 YUV422 像素以 16 位元整數傳回──即封裝字組。

  • 二值像素以 01 傳回。

  • JPEG 與 PNG 像素以 8 位元整數傳回,一次一個位元組地取自壓縮串流。這些數值是不透明的──它們是壓縮編碼的片段,而不是任何一般意義上的像素。

索引形式適合那些已經以緩衝區位移在思考的程式碼:一個將每個像素遍歷一次的迴圈、一個需要一次跳過一整列的演算法,或是一段在不同緩衝區配置之間轉換的程式碼。以 x 與 y 座標在思考的程式碼,則由 get_pixelset_pixel 服務得更好;這兩種形式透過不同的心智模型來定址同樣的像素。

Image 也是可迭代的。for v in img: 會以相同的列優先順序遍歷緩衝區,一次一個像素地產出原始值,而 len(img) 對未壓縮格式而言是像素數,對壓縮串流而言則是位元組數。

5.4.3. 為何逐像素的 Python 是慢路徑

一個值得誠實面對的實務提醒。從 Python 一次一個像素地遍歷影像是 很慢的。一張 320 × 240 的灰階影像含有 76,800 個像素;在一個 for 迴圈中對其中每一個都呼叫 get_pixel(),會執行數以百萬計的 MicroPython 位元組碼指令,去完成一個等效的原生方法只需幾百微秒就能做完的工作。這不是個小倍數。它是「一個能即時處理影格的指令碼」與「一個遠低於相機影格率而緩慢爬行的指令碼」之間的差別。

Image 介面上幾乎每個方法的存在,都是因為某個常見的逐像素模式有一個更快的原生版本。一個把兩張影像相加的迴圈,變成單一的原生呼叫。一個藉由與鄰居取平均來平滑每個像素的迴圈,變成另一個原生呼叫。一個依閾值對每個像素進行分類的迴圈,又變成第三個。應用程式的工作,大多數時候,就是辨識出哪一個整張影像的方法能匹配那個迴圈原本要做的工作,並改用它,而不是親手寫出迴圈。

當沒有其他方法合適時,像素層級的讀寫仍然是正確的工具──把某個特定的量測結果修補回緩衝區、為校正步驟取樣某個位置、除錯已知位置上的數值。重點在於,它們是慢路徑,是在整張影像的方法不具備應用程式所需形式時才使用的,而不是操作像素的預設方式。