一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图

2026-11-159

一句话介绍:把「选设备」这件事交给摄像头——镜头对准哪个家电,按下同一个按钮就控制哪个家电。而让这套图像分类真正跑起来的关键,是我给行空板 M10 写了一个 WebRTC 图传服务,让 Mind+ 2.0 在训练阶段就能直接采集部署端那颗摄像头的画面。


一、缘起:不想再做「一个按钮配一个设备」

做智能家居时最容易掉进的坑,是控制界面越来越复杂:一个设备一个按钮,三个设备三个按钮,十个设备就得做菜单、做翻页、做语音指令词表。可我想要的其实很朴素——桌面上只放一个按钮,我想开哪个设备,就把「控制器」转向哪个设备,按下去。

这样「选择设备」就不再是界面问题,而变成了一个识别问题:摄像头看到风扇,按钮就是风扇开关;看到台灯,按钮就是台灯开关;看到墙上的室内灯开关,按钮就变成那盏灯的开关。一个按钮 + 一个图像分类模型 = 任意多个被控设备,以后加设备只需要多训练一个类别,硬件一个字都不用改。

想法不复杂,真做起来才发现最难的一环不在识别,也不在继电器,而在训练时的画面从哪来。

Mind+ 2.0 的「模型训练 / 图像分类」默认是用电脑摄像头采集样本的。但模型最终要跑在行空板 M10 上、用 M10 接的那颗 USB 摄像头推理。两颗摄像头,不同的镜头、不同的视角、不同的白平衡和畸变,连摆放高度都不一样。用小样本训练时,这种「采集域」和「推理域」的偏差非常致命:电脑上训练出来 0.99 的模型,装到板子上对着同一台风扇可能只给 0.5。

最理想的做法是:采集端就是未来的部署端。在设备最终安装的位置、用最终那颗摄像头、按最终的光照条件把样本采下来。可 Mind+ 训练页面跑在电脑上,怎么让它拿到 M10 摄像头的画面?

这就是本项目真正花时间的地方。


二、整体方案

器材清单

角色 硬件 在项目里做什么
控制器 行空板 M10 + USB 摄像头 + 按钮(P21) 采集图像、跑图像分类推理、按按钮向 SIoT 发布指令
台灯 掌控板 + 扩展板 + 电磁继电器 订阅 siot/light,P8 驱动继电器
风扇 行空板 K10 + 电磁继电器 订阅 siot/fan,P0 驱动继电器
室内灯 行空板 K10 + 舵机 订阅 siot/light2,P1 舵机模拟拨动墙壁开关
通信 M10 上自带的 SIoT(MQTT) 四块板子之间唯一的通信总线
软件 Mind+ 2.0(2.0.7) 图形化编程、图像分类模型训练、模型导出

信号流

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_1.webp

四块板子连同一个 2.4G WiFi。M10 自己跑 SIoT 服务,所以控制器发布到 127.0.0.1 即可,另外三块板把 MQTT 服务器填成 M10 的局域网 IP。

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_2.webp

硬件风扇

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_3.webp
一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_4.webp

硬件台灯

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_5.webp
一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_6.webp

硬件开关

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_7.webp


三、核心创新:让 Mind+ 2.0 直接吃到 M10 的摄像头

3.1 从「WebRTC设置」里挖出协议

Mind+ 2.0.7 的更新说明里提到,模型训练和实时模式新增了 HUSKYLENS 2 的 WebRTC 图传支持;训练页面的摄像头下拉菜单里也确实多出了「WebRTC设置」和「连接 192.168.x.x」。

问题是:这个 IP 后面到底藏着什么服务?RTSP?MJPEG 流?还是某个私有协议?

我翻了本机 Mind+ 安装目录里的前端资源(apps/deps/ai-tools-classification 的压缩 JS,以及 realtime-gui 的 source map),把未压缩的 webrtcCamera.ts 和 janus.es.js 还原出来,答案很清楚——这不是普通视频流,而是 Janus Gateway 的 Streaming 插件:

const WEBRTC_CAMERA_DEFAULT_IP = '192.168.88.1';
const HUSKYLENS_WEBRTC_STREAM_ID = 99;

const getJanusServerUrl = (ip) => `http://${ip.trim()}:8088/janus`;
const getHuskyLensCommandUrl = (ip) => `http://${ip.trim()}:8080/cmd`;
this.janus.attach({
    plugin: 'janus.plugin.streaming',
    success: pluginHandle => {
        this.streaming = pluginHandle;
        this.streaming.send({ message: { request: 'watch', id: this.streamId } });
    },
    onmessage: (msg, jsep) => {
        this.streaming.createAnswer({
            jsep, media: { audioSend: false, videoSend: false },
            success: answer => this.streaming.send({ message: { request: 'start' }, jsep: answer })
        });
    }
});

也就是说,Mind+ 认死了三个东西:

  1. 服务地址必须是 http://<IP>:8088/janus;
  2. 插件名必须是 janus.plugin.streaming;
  3. 挂载点 id 必须是 99。

这意味着没必要在行空板上编译安装完整的 Janus Gateway——只要用 Python 复刻它那套 REST 信令,再用 WebRTC 把摄像头推过去,Mind+ 就会以为对面是一台 HUSKYLENS 2。整块板子只需要多跑一个几百行的 Python 服务。

3.2 板端服务的结构

我在 M10 上写了 m10_webrtc.py,三层:

层 用的东西 职责
采集 OpenCV / V4L2,/dev/video0,MJPEG,640×480 @ 15fps 全局只读一次摄像头,多个客户端共享;没人看时停止采集,省 CPU
信令 aiohttp 复刻 Janus REST API、长轮询事件队列、CORS
媒体 aiortc 生成 SDP offer、维护 RTCPeerConnection、VP8 编码、RTP 发送、trickle ICE

板子自带的 python3 是 3.7.3,装不上新版 wheel,改用 pyenv 里的 Python 3.12.7(自带 OpenCV 4.13)。依赖很轻:

aiortc==1.16.0
av==19.0.2
aiohttp==3.14.5
numpy>=1.26

3.3 一次完整的握手

Mind+(Electron / Chromium)和我这个服务之间的对话长这样:

Mind+ (Chromium)                     行空板 M10 (Janus 兼容服务)
     |--- POST /janus        {create} ----------------------->|
     |<-- {"data":{"id": sid}} -------------------------------|
     |--- POST /janus/sid     {attach, janus.plugin.streaming}>|
     |<-- {"data":{"id": hid}} -------------------------------|
     |--- POST /janus/sid/hid {message: watch, id=99} ------->|
     |--- GET  /janus/sid?rid=...  (长轮询) ----------------->|
     |<-- event + jsep{type:"offer", sdp:...} ----------------|
     |   setRemoteDescription → createAnswer                  |
     |--- POST /janus/sid/hid {start, jsep:answer} ---------->|
     |--- POST /janus/sid/hid {trickle, candidate} ---------->|
     |<============= RTP / VP8 视频流 ========================|

几个实现上的关键点:

  • M10 是发送方,所以 transceiver.direction = "sendonly",offer 由板子生成,Mind+ 只回 answer。
  • 只协商一种编码。用 setCodecPreferences() 把 codec 限定为 VP8,否则 Chromium 可能选中 RTX 或 aiortc 不支持的 payload,画面出不来。
  • CORS 必须全开。Mind+ 页面是 http://localhost:<port>,访问 http://192.168.x.x:8088 属于跨域,服务端要正确响应 OPTIONS 预检并返回 Access-Control-Allow-Origin: *。这一点卡了我很久,因为浏览器里只报一个含糊的 CORS 错误,服务端日志却什么都看不到。
  • trickle ICE 要真的处理。janus.js 默认逐条 POST {janus:"trickle", candidate:{...}},服务端得把远端候选加进 RTCPeerConnection,最后处理 {completed:true}。

3.4 三个让我掉过头的坑

坑一:时间戳倒退会把整路视频打死。

最初我用墙钟时间算 VideoFrame.pts,编码跟不上时还「好心」地把时间戳重置回 0。结果 Chromium 一请求关键帧,libvpx 就抛:

av.error.InvalidDataError: avcodec_send_frame(): Invalid data found when processing input

更糟的是 aiortc 的 RTCRtpSender._run_rtp() 一旦在编码时捕获到异常,会永久停止这一路发送——现象是 Mind+ 里画面定格,服务端却显示连接正常。修法是改成帧计数生成严格递增的时间戳,编码落后时只跳过补偿、绝不回退:

vf = VideoFrame.from_ndarray(frame, format="bgr24")
vf.pts = int(self._timestamp * 90000 / self.fps)
vf.time_base = Fraction(1, 90000)
self._timestamp += 1        # 只增不减

另外给 VP8 / H264 编码器加了一层兜底:编码异常就重建编码器重试,仍失败只丢这一帧,不让发送循环退出。修完后做了 20 秒稳定性测试,收到 299 帧(约 14.9 fps),日志里再没有编码异常。

坑二:IP 会变,而且开机时探测不到。

服务启动时用 UDP 连一下默认路由来取本机 IP,写进 SDP 的候选里。但行空板开机时网络常常还没就绪,这一探就拿到 127.0.0.1——Mind+ 自然连不上。后来改成过滤 loopback / 169.254、按网卡优先级挑地址,并且每次新订阅和查状态时都重新探测,换 WiFi 网段后不用重启服务。

顺带发现板子其实有两个地址:WiFi 的 DHCP 地址会变,而 USB 直连的 10.1.2.3 是固定的。22 / 8088 / 1883 两个地址都通,实测经两个地址取流都稳定在 14.6~14.8 fps。所以 USB 线连着电脑时,直接填 10.1.2.3 最省心。

坑三:USB 摄像头会偷偷改名。

某次重新枚举后 /dev/video0 直接消失,变成 /dev/video1(真能出图的节点)和 /dev/video2(metadata 节点)。Mind+ 积木里的 ImageReader(source=0) 和服务启动参数 --source /dev/video0 同时失效。解决办法是一条 udev 规则 + 一个小脚本:每次 video4linux 设备 add 时,自动探测哪个节点能读帧,把 /dev/video0 指过去。

3.5 在 Mind+ 里用它

服务跑起来后,训练侧完全不用改任何东西:

  1. 模型训练 → 图像分类 → 点「摄像头」→「WebRTC设置」;
  2. 填入行空板的 IP(或 10.1.2.3);
  3. 下拉菜单里出现「连接 192.168.x.x」,选它;
  4. 预览区出现 M10 摄像头的画面,按住即可录制采集各类别样本。

【配图 3】摄像头下拉菜单里出现「WebRTC设置 / 连接192.168.x.x」
【配图 4】采集页面显示 M10 画面,按住录制后「N 个图片样本」

这一步的价值,前面说过:采集端和推理端是同一颗摄像头、同一个安装位置、同一套光照。三个类别时我采了 600 多张样本(每类 130~180 张,验证集约占 1/4),模型在板子上对着实物识别,置信度基本稳定在 0.9 以上。如果用电脑摄像头采,这个数字很难看。

还有一个很实际的互斥要注意:/dev/video0 同一时刻只能给一个进程。采图时 WebRTC 服务占着摄像头,推理项目的 ImageReader 就打不开;反过来也一样。所以我写了 switch.py / mode.sh 做一键切换:

python switch.py webrtc       :: 采图模式:启动图传服务
python switch.py inference    :: 推理模式:停服务,释放摄像头
python switch.py status       :: 看当前模式、订阅数、摄像头占用

后来干脆把开机自启关掉了——板子一开机服务就抢摄像头,推理项目反而起不来。现在默认不自启,需要采图时手动开。

图像采集


四、训练与模型导出

在 Mind+ 模型训练里新建图像分类项目,类别这样规划:

类别 含义 对应设备
bg 背景 / 没对准任何目标 不动作
fan 风扇 风扇
light 台灯 台灯
light2 室内灯开关 室内灯

bg 这个类别非常重要:没有它,模型会在「什么都没对准」时硬猜一个设备,按钮一按就误触发。

训练完成后 Mind+ 会导出两个文件:

  • best.onnx:模型权重(本项目约 5.8 MB,基于 yolov8n-cls,输入 224×224)
  • data.yaml:类别索引到名称的映射

把这两个文件放进 M10 的 python-block 项目目录,程序里用 ImageClassificationInference("best.onnx", "data.yaml") 加载。

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_8.webp

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_9.webp

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_10.webp
一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_11.webp


五、控制器程序:识别 + 按钮 + 发布

M10 这边用 Mind+ 的 python-block 图形化积木搭的,逻辑其实是一条流水线:

初始化推理 → ImageReader(source=0, 224×224) → 循环 {
    读一帧 → inference → 取 class_name / score
    若 score > 0.9:
        按 class_name 分支(fan / light / light2)
        读 P21 按钮
        按下 且 标识==0 → publish "on"  置标识=1
        松开 且 标识==1 → publish "off" 置标识=0
}

三个「标识」变量是状态锁,保证一次按压只发一条消息,不会因为循环每秒跑十几帧而疯狂刷 MQTT。屏幕上常驻显示「万能遥控器 / AI」,调试时也能看到当前识别结果。

推理结果长这样,和 Mind+ 训练工具输出的是同一套格式:

{'model': 'image_classification', 'result': [
    {'class_id': 0, 'class_name': 'bg',    'score': 0.9968, 'result_id': 0},
    {'class_id': 1, 'class_name': 'fan',   'score': 0.0025, 'result_id': 1},
    {'class_id': 2, 'class_name': 'light', 'score': 0.0007, 'result_id': 2}]}

M10 连的是自己板上的 SIoT:Server=127.0.0.1、Port=1883、User=siot、Password=dfrobot。三个主题分别是 siot/light、siot/fan、siot/light2,消息体就是字符串 on / off。

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_12.webp

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_13.webp

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_14.webp


六、三个执行端

三块板子的程序都很短,结构完全一样:连 WiFi → 连 SIoT → 订阅一个主题 → 收到 on 做动作、其余一律做关闭动作。

设备 程序 主题 动作细节
台灯(掌控板) 掌控板.mpcode siot/light on → P8 HIGH 吸合继电器;否则 P8 LOW。RGB 灯用颜色提示联网状态,屏幕显示「开灯 / 关灯」
风扇(K10) 行空板K10.mpcode siot/fan on → P0 HIGH;否则 P0 LOW。板载 RGB 提示状态
室内灯(K10) 行空板K10舵机灯.mpcode siot/light2 舵机初始 93°(中位);on → 转到 42° 停 1 秒再回 93°;off → 转到 138° 停 1 秒再回 93°

室内灯用的是「拨一下墙壁开关」的思路:舵机只做一次短促拨动就回中位,不会一直顶着开关,这样手动也能正常按。这比用继电器去短接墙壁开关安全、也更容易装。

台灯程序

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_15.webp

风扇程序

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_16.webp

荧光灯开关程序

一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图_image_17.webp


七、踩坑实录:那个让我查了半天的字母

项目联调时,台灯和风扇都听话,唯独室内灯毫无反应。MQTT 主题对得上、K10 也确实连上了 SIoT、用手机手动往 siot/light2 发消息舵机会动——说明执行端没问题。

最后发现是训练时类别名手滑:本该叫 light2,我写成了 ling2。而控制器程序里判断的是:

if class_name == "light2":
    ...

模型输出的是 ling2,字符串永远不相等,这一整个分支是死代码。bg / fan / light 三个类别一直在正常工作,所以问题被掩盖得很好。

好消息是不用重训模型。 data.yaml 只是「第几个输出 → 叫什么名字」的映射表,改名字不会改变模型的输出顺序:

labels:
  0: bg
  1: fan
  2: light
  3: ling2     # ← 改成 light2 即可

两个修法二选一:把 data.yaml 里的 ling2 改成 light2(推荐,一行搞定),或者把积木里比较的 "light2" 改成 "ling2"。教训是:类别名是跨工具传递的接口,训练端、导出配置、推理程序三处必须逐字一致,导出后最好先打印一次 data.yaml 的 labels 核对。

其余几个坑一并列在这里,都是实打实踩过的:

现象 根因 解决
ModuleNotFoundError: No module named 'model_mp_io' 模型工具把运行时库装进 Python 3.12.7,Mind+ 的 python-block 却用系统 3.7.3 启动 把远程解释器路径指到 3.12.7;另加了一层 3.7→3.12.7 桥接兜底
服务刚起来时连不上 开机约需 8 秒才打开摄像头并监听 切换脚本改成轮询 /status 就绪,不再死等固定秒数
开机后推理项目读不到摄像头 WebRTC 服务开机自启抢了 /dev/video0 关掉自启,改为采图时手动启动
8080 端口被占用 SIoT 已经占了 8080 服务端捕获 OSError 跳过那个兼容端点,不影响 8088 图传
换路由器后三块板全掉线 执行板的 MQTT 服务器写的是 M10 的 DHCP 地址 路由器给 M10 做静态分配;或 USB 直连时用固定地址
首次运行 30 秒没输出 model_mp_core 冷启动 import 约 22 秒 等一下就好,别当成死机

八、复现步骤小结

  1. M10 接好 USB 摄像头、P21 接按钮,确认 /dev/video0 能出图;
  2. 部署板端图传服务(Python 3.12.7 + 上面的四个依赖),启动后用 http://<IP>:8088/status 确认 online;
  3. Mind+ 2.0 模型训练 → 图像分类 → 摄像头 → WebRTC设置 → 填 IP → 连接 → 按住录制,采集 bg / fan / light / light2 四类样本;
  4. 训练并导出 best.onnx + data.yaml,核对类别名逐字一致;
  5. 切到推理模式(switch.py inference)释放摄像头;
  6. M10 的 python-block 项目加载模型、跑识别、按 P21 发布 MQTT;
  7. 三块执行板分别烧录对应程序,填入 M10 的 IP,上电;
  8. 镜头对准某个设备,按按钮,看它动作。

演示视频


九、做完之后的想法

这个项目最有意思的地方,其实不是继电器,而是「采集端 = 部署端」这个闭环。用 WebRTC 把部署端摄像头的画面送进训练工具,看起来只是省了一根 USB 线,实际解决的是小样本视觉项目里最容易被忽视的数据分布问题。而且复刻 Janus 信令这件事本身就说明:只要搞清楚工具认什么协议,一块行空板完全可以「扮演」成别的设备,不必等官方适配。

后面还能往几个方向走:

  • 把按钮从「按住开、松开关」改成「按一下切换」,再加一个长按关全屋;
  • 加一个「松开兜底」:不管当前识别到什么,按钮松开时都给上一次打开的设备补一条 off,避免镜头移开后设备一直开着;
  • 类别继续加,设备就继续加,控制器程序只多一个分支;
  • 把这套 M10 图传服务单独拎出来做成通用组件,给其他需要远程采图的 Mind+ 项目直接用。

如果只带走一句话,我希望是:当你觉得模型「不准」的时候,先别急着调参,去看看训练时它看到的世界,和部署后它看到的世界,是不是同一个世界。


创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -