5.4. 读取和写入像素¶
图像上的大多数操作都将其逐像素的工作隐藏在单个方法调用内部,遍历每个像素的循环以原生速度运行。不过,在某些情况下,应用代码需要直接访问某个特定像素:读取特定位置上的内容、向其写入新值、为标定步骤采样单个点,或者调试某个已知位置上的值。image 模块通过两种寻址方式提供这一级别的访问,每种方式对应一种关于像素所在位置的不同思考方式。
5.4.1. 按坐标寻址¶
最自然的方式就是“坐标”一节已经建立起术语的那种:用笛卡尔 (x, y) 来命名一个像素。get_pixel() 接受 (x, y) 并返回该位置上的值;set_pixel() 接受相同的 (x, y) 以及一个值,并将其写入。
这些调用返回或接受的内容取决于图像的格式。灰度、二值和 Bayer 图像每个像素只携带一个值——灰度是亮度,二值是 0 或 1,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_pixel 或 set_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 什么也不做。这种宽容是有意为之的:许多算法会在贴近图像边缘的地方移动,并短暂地索引到越界位置,而一个静默的空操作比每次发生时都抛出异常要少打扰得多。
5.4.2. 按线性索引寻址¶
另一种方式是按像素在底层缓冲区中的位置来寻址。回想一下缓冲区的布局:像素是逐行存储的,先是最上面一行的所有像素,然后是下一行的所有像素,依此类推一直到最下面一行。这种排列意味着每个像素都有一个单一的整数索引,从左上角的 0 开始,沿着每一行依次递增。坐标为 (x, y) 的像素的线性索引是 y * width + x。
像素既可以通过笛卡尔 (x, y) 寻址,也可以通过逐行从左到右遍历缓冲区的线性索引来寻址。¶
image 模块通过普通的 Python 下标表示法暴露该索引:img[i] 读取线性索引 i 处的像素,img[i] = value 写入一个。索引形式返回的是该格式的原始存储值,而不是 get_pixel() 默认返回的解包后的元组。这一区别很重要,因为之前选择的格式决定了原始值的样子:
灰度和 Bayer 像素以 8 位整数返回。
RGB565 和 YUV422 像素以 16 位整数返回——即打包字。
二值像素以
0或1返回。JPEG 和 PNG 像素以 8 位整数返回,每次一个字节地返回压缩流。这些值是不透明的——它们是压缩编码的片段,而不是任何通常意义上的像素。
索引形式适合那些已经在用缓冲区偏移量思考的代码:一个把每个像素遍历一遍的循环、一个需要一次跳过一整行的算法,或者一段在不同缓冲区布局之间转换的代码。而用 x 和 y 坐标思考的代码则更适合用 get_pixel 和 set_pixel;这两种形式通过不同的思维模型寻址相同的像素。
Image 也是可迭代的。for v in img: 以相同的行主序遍历缓冲区,每次产出一个像素的原始值,而 len(img) 对于未压缩格式是像素数,对于压缩流则是字节数。
5.4.3. 为什么逐像素的 Python 是慢路径¶
有一点值得坦诚地说明一下。在 Python 中一次一个像素地遍历图像是很慢的。一幅 320 × 240 的灰度图像包含 76,800 个像素;在一个 for 循环中对每个像素调用 get_pixel() 会运行数百万条 MicroPython 字节码指令,去完成一个等效原生方法在几百微秒内就能完成的工作。这不是一个小的差距。它是一个能实时处理帧的脚本与一个远远低于摄像头帧率缓慢爬行的脚本之间的区别。
Image 接口上几乎每一个方法的存在,都是因为某种常见的逐像素模式有一个更快的原生版本。一个把两幅图像相加的循环变成了一次原生调用。一个通过与邻域取平均来平滑每个像素的循环变成了另一次。一个把每个像素对照阈值进行分类的循环变成了第三次。应用的工作,大多数时候,就是识别出哪个整图方法与循环本应完成的工作相匹配,然后使用它,而不是手动编写循环。
当没有其他方法适合时,像素级的读写仍然是正确的工具——把某个特定的测量结果修补回缓冲区、为标定步骤采样某个位置、调试某个已知位置上的值。关键在于它们是慢路径,是在整图方法没有应用所需的形式时使用的,而不是作为操作像素的默认方式。