12.8. 流式传输帧

自定义通道最常见的实际用途是以摄像头的帧率将图像帧从摄像头流式传输到主机程序。其机制比看上去更微妙:一个 JPEG 可能达到 25 KB 甚至更大,因此主机会将其分成若干片段读取,而必须防止摄像头的捕获循环在读取过程中覆盖缓冲区。正确的模式——此处展示并被 openmv-projects/tools/ 中的工具所采用——会 锁存 缓冲区,直到主机取走最后一个字节为止。

12.8.1. 摄像头端

下面这个帧通道将图像捕获到单个帧缓冲区中,在主机首次读取时将其锁存,并且只有在主机消费完整张图像后才进行下一次快照:

import csi
import protocol

csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QVGA)
csi0.framebuffers(1)

img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True


class FrameChannel:
    def poll(self):
        return frame_available

    def size(self):
        return img_size

    def readp(self, offset, size):
        global frame_available
        end = offset + size
        mv = img_mv[offset:end]
        if end == img_size:
            # Host has just read the last byte of this frame --
            # release the buffer so the capture loop can refresh.
            frame_available = False
        return mv


ch = protocol.register(name='frame', backend=FrameChannel())

while True:
    if not frame_available:
        img = csi0.snapshot()
        img.compress(quality=85)
        img_mv = memoryview(img.bytearray())
        img_size = len(img_mv)
        frame_available = True
        ch.send_event(0x01)   # notify host that a new frame is ready

这里有四个部分在真正发挥作用:

  • frame_available锁存器。只有当它为 False 时(即主机已取走上一帧的最后一个字节),捕获循环才会进行新的快照。主机的读取会在 readp 内部、在最后一个偏移量被提供之后将其重新设置回 False。如果没有这一保护,下一次 csi0.snapshot() 就会在读取过程中覆盖缓冲区,主机将收到由两次捕获拼接而成的一帧。

  • 后端实现的是 readp 而不是 read。协议库将返回的缓冲区视为权威数据,直接将其字节读入外发数据包——无需复制。对于帧大小的负载,readp 明显比 read 更快,后者会强制进行一次中间复制。

  • size 返回缓存的 JPEG 长度而无需重新计算任何内容;捕获循环每次刷新缓冲区时都会维护它。主机在 pollreadp 之间调用 size 以得知需要取多少字节。

  • 一旦有新帧到达,send_event() 会立即通知主机,使其无需轮询即可开始取数据。事件 ID 0x01 由应用程序自行定义(本例中表示“帧就绪”);请为每种通知使用一个不同的小整数。

12.8.2. 分片

QVGA RGB565 在 JPEG 质量为 85 时会压缩到大约 10-25 KB,具体取决于场景——这远大于任何摄像头上协商出的最大负载(参见 protocol.init() 中的逐板卡对照表)。一次 JPEG 读取无法装入单个数据包,但这没关系,因为协议库会透明地对其进行分片。

当主机请求 channel_read('frame', 12000) 时:

  1. 摄像头的 readp 会以 offset=0 和完整的 12000 字节请求被调用 一次。它返回一个覆盖整个范围的 memoryview。

  2. 协议库会在传输线路上将该 memoryview 拆分成最大负载大小的片段,每个片段对应一个 CHANNEL_READ 回复数据包,各自带有自己的报头和 CRC。这些字节直接从后端的缓冲区流出——无需复制。

  3. 主机按顺序接收这些片段,可靠性层会重传任何一个 CRC 校验失败的分块,主机 SDK 再将这些分块拼接成返回给调用方的 12000 字节结果。

备注

这正是 readpread 之间关键的实际区别。readp每次主机请求时被调用一次;协议层从单个返回的缓冲区中分片并传输。read每个片段时被调用一次,且库会将每个返回的分块复制到它自己的数据包缓冲区中。对于帧大小的负载,readp 同时省去了每个片段的 Python 层调用开销和复制。

小技巧

想亲眼看看二者的差距吗?将后端的 readp 方法重命名为 read——其他什么都不改;库会转而采用 read 能力——然后比较前后主机的帧率计数器。较慢的那个数字就是你通过使用 readp 所避免的每个片段的复制和 Python 调用开销。

FrameChannel.readp 中的锁存器会在 offset + size == img_size 时——也就是主机取走最后一个字节的那一刻——释放缓冲区。在此之前,缓冲区必须保持有效,这正是捕获循环只有在 frame_available 翻回 False 后才进行下一次快照的原因。

12.8.3. 主机端

主机在一个紧凑的循环中拉取帧:

import io
from PIL import Image
from openmv.camera import Camera

with Camera('/dev/ttyACM0', baudrate=921600) as cam:
    cam.update_channels()

    while True:
        size = cam.channel_size('frame')
        if not size:
            continue
        data = cam.channel_read('frame', size)
        img = Image.open(io.BytesIO(data))
        img.show()                  # or feed to a GUI

channel_size() 调用同时充当“是否有内容就绪”的检查——返回零意味着摄像头尚未捕获——因此循环会跳过对空缓冲区的读取尝试。对于已经在定时器上轮询的 GUI 应用程序,这是很自然的模式。

Pillow 的 Image.open 会解码该 JPEG;摄像头已经将其进行了 JPEG 压缩,所以主机无需在 RGB565 上重做开销很大的位打包。主机脚本同样可以轻松地将这些字节保存到磁盘、交给 OpenCV,或推送到 Web 视图中。

12.8.4. 关于吞吐量的思考

有三个因素限制可达到的帧率:

  • 摄像头的捕获速率。协议无法以快于传感器产生帧的速度交付帧;所选像素格式和帧大小对捕获施加的任何上限就是天花板。

  • 协商出的最大负载。更大的负载意味着每帧的片段更少、组帧开销更小,因此具有更大协议缓冲区的摄像头比较小的摄像头能更快地移动字节。

  • CRC 和 ACK 开销。每个数据包要花费 14 字节的组帧开销外加一次 ACK 往返。对于长片段,每个负载的开销很小;对于很小的负载,它则占主导地位。

对于大多数摄像头到笔记本电脑的 GUI 工作而言,限制因素是摄像头的捕获和 JPEG 压缩时间,而不是协议栈。在协议确实成为瓶颈的场景下——例如以高帧率流式传输未压缩的原始帧——可用的手段包括关闭 ACK(protocol.init(ack=False))、在摄像头支持的情况下增大协议缓冲区,或者以 GRAYSCALE 进行捕获,这样每个压缩后的 JPEG 只携带一个通道而不是三个,编码后的帧在传输线路上也会明显更小。

帧通道是从摄像头到主机的典型数据流。同一个后端接口,再加上一个 write 方法,就能让主机也朝相反方向推送数据——而这正是一个交互式摄像头工具在操作者想要 改变 某些东西(而不仅仅是观看)时所需要的。