5.33. ImageIO 流¶
save() 和 to_jpeg() 涵盖了单帧 I/O 场景:应用程序捕获一帧、对其编码,然后将其推送到某处。另一类应用程序需要的是序列场景:以自然捕获速率连续录制许多帧,将它们存储在以后可检索的地方,并以正确的速度回放。例如,一个训练数据采集脚本为机器学习流水线捕获几百个示例帧;一个检测工位日志记录每个被捕获的部件以供追溯;一个开发脚本回放已存储的序列,以便用先前实时捕获的数据测试新算法。
ImageIO 类是 image 模块的录制器/播放器。单个流保存一个 Image 帧序列——这些帧的尺寸和像素格式可能各不相同——并连同每一帧的帧间间隔一起保存,因此回放时可以重现原始帧率。可用的后备存储有两种:文件系统上的文件,或 RAM 中的固定大小缓冲区。
5.33.1. 两种后备存储¶
文件流会在断电重启后持久保留录制内容,其大小仅受其后备存储的限制。它以一个 16 字节的魔数头 OMV IMG STR Vx.y 开头,后面每一帧一个数据块;当前写入器生成 V2.0,而读取器为了向后兼容仍接受 V1.0 和 V1.1 文件。文件路径是构造函数参数;mode 是文件打开模式('r' 用于读取现有的流,'w' 用于截断并重新写入)。
# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
img = csi0.snapshot()
stream.write(img)
stream.close()
内存流存在于构造时分配的 RAM 缓冲区中。构造函数接受一个 (w, h, pixformat) 三元组而非路径,且 mode 参数变为预分配的帧槽数量。缓冲区会精确地按该数量的帧、按所提供的尺寸来分配大小,且一旦分配后不允许增长——写入超过最后一个槽会引发 EOFError,写入比每槽缓冲区更大的帧会引发 ValueError。当应用程序需要将一段录制交给下游阶段而不经过文件系统时(例如用于触发并回放模式的近期帧的短环形缓冲区),内存流就是合适的工具。
# Pre-allocate space for 32 QVGA RGB565 frames in RAM
stream = image.ImageIO((320, 240, image.RGB565), 32)
for _ in range(32):
stream.write(csi0.snapshot())
对于压缩的像素格式(image.JPEG、image.PNG),每槽大小按每像素 2 比特估算;编码后大于该估算值的帧会在写入时引发 ValueError,因此一个期望存储高质量 JPEG 的应用程序必须要么超额分配槽数量,要么先以较低质量编码。
type() 返回 image.ImageIO.FILE_STREAM 或 image.ImageIO.MEMORY_STREAM,以便下游代码可以适配它所获得的任一种后备存储。
5.33.2. 录制¶
write() 将捕获的 Image 追加到文件流(或将其存储在内存流的当前槽中),并将偏移量前进一位。同一调用还会记录自上次写入以来的帧间间隔,因此回放端可以在两帧之间暂停适当的时间,从而保留录制的自然帧率。
在单个文件流中允许使用异构帧:一段录制可以自由混合 RGB565 捕获、灰度裁剪图和 JPEG 编码的缩略图,读取器会以各自原始的尺寸和格式解码每一帧。内存流是同构的(所有槽共用构造函数提供的 (w, h, pixformat)),因此内存录制被限定为单一帧配置。
write() 返回流对象,因此调用可以链式进行。在文件流的非末尾偏移处写入会截断文件的其余部分——这对编辑已存储的序列很有用,但如果下一次写入位置被先前的 seek() 无意中移动了,则有风险。
sync() 会将待写入的数据刷新到磁盘(用于文件流;对内存流是空操作),在长时间运行的录制中应定期调用它,以避免在文件关闭之前摄像头重启时丢失录制的尾部内容。当 ImageIO 超出作用域时,析构函数会自动关闭流,但显式调用 close() 才是正确的做法。
5.33.3. 回放¶
read() 读取当前偏移处的帧,将偏移量前进,并返回新的 Image。当 copy_to_fb=True(默认值)时,接收到的帧会保留在帧缓冲区中,因此返回的图像可以通过 IDE 预览进行绘制;当 copy_to_fb=False 时,该帧会落到 MicroPython 堆上。
# Loop a recorded stream at its natural frame rate
stream = image.ImageIO("/sdcard/run.bin", "r")
while True:
img = stream.read()
# img is now in the frame buffer; the IDE shows it
# and the script can run any analysis it likes
有两个关键字控制回放行为。loop=True(文件流的默认值)在到达录制末尾时将读取指针回绕到开头,因此该调用永远不会返回 None;loop=False 在录制耗尽后返回 None,调用方的循环随之终止。pause=True(默认值)会阻塞调用,直到写入时记录的帧间间隔已过去,因此回放帧率与原始捕获帧率一致;pause=False 立即返回,这对那些希望尽可能快地处理完录制、而不遵循原始时序的分析流水线很有用。
同样的循环模式也适用于内存流,只是 loop 会被忽略——读取超过内存流末尾会引发 EOFError。内存环形缓冲区的预期模式是在需要回绕时显式调用 seek() 回到零位。
5.33.5. 可在主机上播放的录制¶
当录制将要在摄像头上回放时,ImageIO 流是合适的工具——它们以原生像素格式保留每一个捕获的帧,帧间间隔被精确记录,下游脚本可以逐帧步进、seek 并无损地重新分析它们。然而,当录制必须能在主机——工作站、手机、网页播放器——上播放时,它们就不是合适的工具了。主机期望的是标准视频容器,而不是 OpenMV 的磁盘魔数头格式。
有两个独立的模块涵盖可在主机上播放的场景。mjpeg 模块录制 Motion JPEG:一个 JPEG 压缩帧序列被打包进单个 AVI 风格的容器中,VLC、QuickTime、ffmpeg 以及标准的网页视频标签都能直接播放。gif 模块录制动画 GIF:一个未压缩(或调色板压缩)帧序列,带有显式的每帧延迟,可在任何支持动画 GIF 的网页浏览器或图像查看器中播放。
mjpeg 模块是长时间录制的自然选择。JPEG 压缩使文件大小保持可控——逐帧都与按配置质量调用 to_jpeg() 相当——因此长时间的捕获会话仍能控制在 SD 卡的容量预算之内。其用法与 ImageIO 录制非常相似:
import mjpeg
m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
m.add_frame(csi0.snapshot(), quality=85)
m.close()
mjpeg.Mjpeg 接受与其他图像方法相同的绘制风格位置参数和缩放关键字,因此录制时可以对每一帧进行缩放、裁剪或调色板映射。构造函数的 width 和 height 参数默认为主帧缓冲区的尺寸,并固定输出分辨率;每个追加的帧都会被缩放(保持宽高比)以适配。sync() 在长时间录制期间将文件刷新到磁盘,而 close() 完成容器的最终化——一个未被正确关闭的 Motion JPEG 文件是无法播放的,所以这个规范很重要。
gif 模块是短时间录制的自然选择,用于原样分享给非技术观看者——为演示捕获的几秒钟动作、用于文档的动画插图、嵌入聊天消息的事件片段。GIF 帧以未压缩(或以 7 位色深进行调色板压缩)方式存储,这使得文件每秒占用的空间比 Motion JPEG 大得多,并使该格式不适用于超过几秒钟的录制,但其结果可以直接放进任何浏览器:
import gif
g = gif.Gif("/sdcard/clip.gif")
while running:
g.add_frame(csi0.snapshot(), delay=10)
g.close()
add_frame() 上的 delay 参数是以厘秒为单位的每帧显示时间(10 表示每帧 100 毫秒,即 10 fps),这是标准的 GIF 回放控制方式。构造函数的 loop 关键字设置生成的片段是否在查看器中自动循环(默认值为 True,符合传统“动画 GIF”的预期)。
这三条录制路径合起来涵盖了常见的场景:ImageIO 用于摄像头上的再处理,Motion JPEG 用于可在主机上播放的长录制,动画 GIF 用于可在主机上播放的短片段。在它们之间做选择归结为由谁回放该录制。运行在摄像头本身上的下游阶段读取 ImageIO;主机工作站或网页查看器读取 MJPEG 或 GIF。
5.33.6. 触发并回放模式¶
一个有用的模式将内存流与触发条件结合起来。摄像头持续录制到一个 count 槽的内存环形缓冲区中,每绕一圈就覆盖最旧的槽。当触发条件触发时(一个色块进入帧、一个运动事件超过阈值、一个按钮被按下),应用程序对环的内容拍快照——即最近的 count 帧——并将它们写入 SD 卡上的文件流。其结果是一段预触发录制,它捕获的是摄像头实际注意到的事件之前的若干秒,而不仅仅是之后的若干秒,而后者正是简单的“触发时捕获”录制器的经典局限。
一旦掌握了流类,实现起来就很直接:一个固定大小的内存流充当环(当偏移量到达槽数量时显式调用 seek() 回到零位),主循环在每次迭代时捕获到其中,触发处理程序逐帧读出内存流,并将每一帧写入一个以触发时间戳命名的文件流。