12.9. 双向流

通道不是单向的。一个实现了 write 的后端可以让主机把字节推送摄像头,摄像头随即作出反应。这是每一个真实交互工具背后的模式:操作员在主机 GUI 上转动一个旋钮,主机将新值写入一个配置通道,摄像头在下一次捕获时读取它。

12.9.1. 一个配置通道

在流式传输的摄像头端脚本基础上,再暴露第二个通道用于 JPEG 质量:

class ConfigChannel:
    def __init__(self):
        self.quality = 85

    def size(self):
        return 0

    def read(self, offset, size):
        # Not used for "host writes to cam" -- but the library
        # still needs the method present.
        return b''

    def write(self, offset, data):
        # data is a bytearray view into the protocol buffer.
        # Copy out the contents before doing anything with it.
        new_q = int(bytes(data))
        if 1 <= new_q <= 100:
            self.quality = new_q
        return len(data)

config = ConfigChannel()
protocol.register(name='config', backend=config)

捕获循环在每次压缩一帧时都从 config.quality 读取:

while True:
    img = csi0.snapshot()
    latest_jpeg = bytes(
        img.compress(quality=config.quality).bytearray()
    )
    ch.send_event(0x01)

现在主机有了一个旋钮。把它设为 50,下一帧就会更小(也更难看);把它设为 95,下一帧就会更大(也更清晰)。摄像头持续捕获而无需重启;主机也不必推送新脚本。

12.9.2. 来自主机的写入调用

在主机端,channel_write() 将字节发送到一个命名通道:

cam.channel_write('config', b'50')

主机 SDK 将这些字节编码为单个(或分片的)CHANNEL_WRITE 数据包,协议层将其递交给摄像头,摄像头的 write(offset=0, data=...) 运行,然后摄像头一方进行确认。当该调用返回时,摄像头已经收到并接受了新值。

从摄像头的角度看,写入是原子的——协议库保证在该通道上的任何其他操作进行之前,后端的 write 已运行完毕。应用程序代码可以在捕获循环内部读取 config.quality,而不必担心主机在快照中途插手。

12.9.3. 桩 size 与只写通道上的 read

一个纯写入通道仍然需要定义 sizeread,即便它们只是返回 0 和 b'' 的桩。库使用方法的存在与否来推导通道的能力标志;一个缺少 read 的后端不会被设置 CHANNEL_FLAG_READ,主机也会拒绝读取尝试。

不过,只写通道上的 read 所返回的字节可用于另一个目的:回显当前值,这样一个刚刚连接的主机就可以询问摄像头“当前的设置是什么?”,而不是从默认值开始。要做到这一点,双方必须就一种序列化方式达成一致。先前示例中的原始字节 int(bytes(data)) 解析对单个整数字段有效,但一旦有了第二个要设置的旋钮就无法扩展了。把 write 切换为解析 JSON,并将其与一个返回相应 JSON 转储的 read 配对,就能把该通道变成一个真正的往返配置存储:

import json

class ConfigChannel:
    def __init__(self):
        self.quality = 85
        self._buf = b''
    def size(self):
        self._buf = json.dumps({'quality': self.quality}).encode()
        return len(self._buf)
    def read(self, offset, size):
        return self._buf[offset:offset + size]
    def write(self, offset, data):
        new = json.loads(bytes(data))
        if 'quality' in new:
            self.quality = int(new['quality'])
        return len(data)

现在主机写入 cam.channel_write('config', b'{"quality": 50}') 来设置一个值,并用 cam.channel_read('config') 读回当前状态。摄像头在每次读取时都序列化一份新的 JSON 转储,因此主机总能看到最新的值,而新增另一个旋钮(thresholdexposureorientation)在双方各自的 JSON 字典中都只是一行代码。

12.9.4. 一个完整的循环

有了用于摄像头 → 主机数据的帧通道、用于主机 → 摄像头控制的配置通道,再加上少量粘合代码,应用程序就成了一个交互工具:

  • 主机打开摄像头,开始拉取帧,并在一个窗口中显示它们。

  • 当操作员拖动滑块时,主机在 config 上写入新值。

  • 摄像头的捕获循环在下一帧时取到该值。

  • 新的帧通过同一个 frame 通道流动。

这就是整个模型。两个通道、各两个回调、摄像头上的一个捕获循环、主机上的一个读写循环。没有可见的成帧逻辑,没有可见的错误处理——协议库让可靠的字节传输消于无形。

此后的一切都是应用程序代码。新增第三个通道用于直方图、第四个用于遥测、或第五个用于传感器触发,都是同样的后端类加 protocol.register 配方的重复。一旦摄像头项目到达这一步,协议就不再是有趣的问题了;有趣的是应用程序自身的逻辑。