5.1. Image 对象

图像处理算法会逐个像素地遍历整幅图像。在每个位置上,它做的事情都很简单——读取一个值、将其与阈值比较、将它与第二幅图像中对应的像素组合、把结果写回去。把这些简单的逐像素决策在整帧上重复执行,就构成了边缘检测、色块跟踪、QR 码解码以及其他所有经典计算机视觉技术的基础。为了高效完成这项工作,算法必须知道每个像素在内存中的位置、每个像素的值究竟代表什么,以及它应该关注图像的哪一部分。image.Image 就是组织这些信息的对象。

Vision Sensors 一章在 csi.CSI.snapshot() 返回的那一刻结束。摄像头一侧为生成所捕获的帧而做的一切都已经完成;应用程序手里握着 Image,需要知道接下来该如何处理它。

5.1.1. 缓冲区及其属性

Image 内部,是一个指向 RAM 中连续字节块的指针,以及一个携带三项元数据的小型头部:图像以像素为单位的宽度、以像素为单位的高度,以及这些字节所采用的像素格式。这些字节就是像素本身,按行优先顺序存储——先是最上面一行的所有像素,然后是第二行的所有像素,依此类推一直到最底部。这些属性描述了如何读取它们。

宽度和高度只是普通的整数计数。像素格式是更有意思的属性,因为它决定了每个像素占用多少字节,以及这些字节编码的是什么。灰度图像每个像素携带一个字节,保存一个亮度值。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.Image —— Python 包装器 的框,一个向下指的箭头 标注为 "references",指向两个堆叠的框—— 一个薄薄的头部框,保存着宽度、高度和 像素格式,以及一个较厚的像素缓冲区框, 其中有一排小单元格表示 各个像素。下方的说明指出, 该缓冲区默认位于堆上, 而当 copy_to_fb 为 true 时则位于帧缓冲区中。

Image 是一个小型 Python 包装器,它指向一块连续的内存:一个携带宽度、高度和像素格式的头部,后面紧跟着像素缓冲区本身。

5.1.2. 缓冲区从何而来

本章中默认的情形就是 Vision Sensors 已经介绍过的那种:捕获的帧从 snapshot 到来,字节位于摄像头的帧缓冲区中,返回的 Image 指向它们。还有另外三种获取它的方式会经常出现,每一种都意味着缓冲区最终所在位置有所不同。

从文件加载看起来就是向构造函数传入一个路径:image.Image("/sdcard/saved.jpg")。模块会将文件读入 Python 堆上一块新分配的缓冲区中。BMP、PGM 和 PPM 文件在读入时会被解码,得到的 Image 携带一种未压缩的像素格式。JPEG 和 PNG 文件保持压缩状态——Image 携带的格式是 JPEGPNG,缓冲区中保存的本质上是文件的字节流,基本未作改动。要对压缩图像做任何像素级的处理,应用程序需要先通过 to_rgb565()to_grayscale() 进行转换,而解压缩——以及相应的堆内存膨胀,一个 30 KB 的 JPEG 可能变成 600 KB 的 RGB565——正是在这次转换中实际发生的。从文件加载在开发期间最为有用,此时算法需要针对一个与脚本一同存放的已知参考帧进行测试。

从头构建则是画布的情形:image.Image(320, 240, image.RGB565) 要求模块以该格式分配那么多字节、将内容清零,然后把包装器交还回来。这些像素还没有任何含义——它们全都是零——但这个空图像是若干反复出现的模式中的主力:用来与当前帧相减的参考帧、用来合成图形叠加层的画布、被填充后用作掩码的二值缓冲区。

从 ndarray 构造则架起了另一个方向的桥梁,将任意数值计算重新引回到 image 模块。把一个 float32ulab.numpy.ndarray 传给构造函数,会产生一个尺寸与该 ndarray 相匹配的 Image——二维 (h, w) 形状变成灰度图像,三维 (h, w, 3) 形状变成 RGB565——其中浮点值会从 0.0——255.0 缩放到整数像素范围内。一张神经网络热力图、任意一种数值数组、由 mlulab 产生的任何东西,都会变成 image 模块的绘制和检查端可以使用的内容。

这四种来源交还回来的都是同一种 Image。使用所返回对象的代码完全不必追踪它从何而来。

5.1.3. 对字节的两种视图

大多数时候,应用代码把 Image 当作一个带类型的图像对象——一个带有命名方法的东西。故事的另一半是,对于任何接受 bytes 参数的 MicroPython API 而言,同一个对象也会透明地以扁平字节序列的形式出现。这些字节不是缓冲区的副本;它们是对缓冲区的直接视图。

正是这种安排使得把捕获的帧从摄像头推送出去成为一行代码的事。对它进行哈希、通过串口发送、转发到网络套接字——这些都不需要一个单独的"把图像转换为字节"的步骤:

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 堆可能只有区区几十千字节。在堆上保存超过一两帧的图像数据,就会把其他所有东西都挤出去。

出路在于,图像缓冲区大多并不位于 Python 堆上。它们位于 Vision Sensors 介绍过的那块称为帧缓冲区的专用 RAM 区域——也就是摄像头 DMA 写入所捕获帧、以及 IDE 预览读取已完成帧的同一块内存。对 Image 的大多数操作都会原地修改其源数据:算法读取像素、做出决策、把新值写回,并不会分配单独的结果图像。那些确实会产生单独结果的操作——格式转换以及其他少数几种——可以通过 copy_to_fb 关键字参数被要求把该结果放置到帧缓冲区中。copy_to_fb=True 一次做了两件事:它把结果图像放进帧缓冲区而不是堆上(避开了堆内存压力),并且让该结果成为 IDE 预览将要显示的下一帧。把 copy_to_fb=True 附加到流水线的最后一步,看着结果出现在屏幕上,并据此继续迭代,这是图像处理中最有用的调试惯用法之一。

有了一个保存着带标注缓冲区的包装器、四种让它得以存在的方式、对其字节的两种视图,以及一个决定新缓冲区落在何处的开关,Image 就不再是个谜了。剩下的那些基础性问题——像素位置是如何命名的、每个像素实际保存着什么、如何把操作的范围限定在图像的某一部分上——都建立在它之上。