一个按钮控制全屋:行空板 M10 用 WebRTC 给 Mind+ 2.0 采图
一句话介绍:把「选设备」这件事交给摄像头——镜头对准哪个家电,按下同一个按钮就控制哪个家电。而让这套图像分类真正跑起来的关键,是我给行空板 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) | 图形化编程、图像分类模型训练、模型导出 |
信号流

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

硬件风扇


硬件台灯


硬件开关

三、核心创新:让 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+ 认死了三个东西:
- 服务地址必须是
http://<IP>:8088/janus; - 插件名必须是
janus.plugin.streaming; - 挂载点 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+ 里用它
服务跑起来后,训练侧完全不用改任何东西:
- 模型训练 → 图像分类 → 点「摄像头」→「WebRTC设置」;
- 填入行空板的 IP(或
10.1.2.3); - 下拉菜单里出现「连接 192.168.x.x」,选它;
- 预览区出现 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 这边用 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。



六、三个执行端
三块板子的程序都很短,结构完全一样:连 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° |
室内灯用的是「拨一下墙壁开关」的思路:舵机只做一次短促拨动就回中位,不会一直顶着开关,这样手动也能正常按。这比用继电器去短接墙壁开关安全、也更容易装。
台灯程序

风扇程序

荧光灯开关程序

七、踩坑实录:那个让我查了半天的字母
项目联调时,台灯和风扇都听话,唯独室内灯毫无反应。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 秒 |
等一下就好,别当成死机 |
八、复现步骤小结
- M10 接好 USB 摄像头、P21 接按钮,确认
/dev/video0能出图; - 部署板端图传服务(Python 3.12.7 + 上面的四个依赖),启动后用
http://<IP>:8088/status确认 online; - Mind+ 2.0 模型训练 → 图像分类 → 摄像头 → WebRTC设置 → 填 IP → 连接 → 按住录制,采集
bg / fan / light / light2四类样本; - 训练并导出
best.onnx+data.yaml,核对类别名逐字一致; - 切到推理模式(
switch.py inference)释放摄像头; - M10 的 python-block 项目加载模型、跑识别、按 P21 发布 MQTT;
- 三块执行板分别烧录对应程序,填入 M10 的 IP,上电;
- 镜头对准某个设备,按按钮,看它动作。
九、做完之后的想法
这个项目最有意思的地方,其实不是继电器,而是「采集端 = 部署端」这个闭环。用 WebRTC 把部署端摄像头的画面送进训练工具,看起来只是省了一根 USB 线,实际解决的是小样本视觉项目里最容易被忽视的数据分布问题。而且复刻 Janus 信令这件事本身就说明:只要搞清楚工具认什么协议,一块行空板完全可以「扮演」成别的设备,不必等官方适配。
后面还能往几个方向走:
- 把按钮从「按住开、松开关」改成「按一下切换」,再加一个长按关全屋;
- 加一个「松开兜底」:不管当前识别到什么,按钮松开时都给上一次打开的设备补一条
off,避免镜头移开后设备一直开着; - 类别继续加,设备就继续加,控制器程序只多一个分支;
- 把这套 M10 图传服务单独拎出来做成通用组件,给其他需要远程采图的 Mind+ 项目直接用。
如果只带走一句话,我希望是:当你觉得模型「不准」的时候,先别急着调参,去看看训练时它看到的世界,和部署后它看到的世界,是不是同一个世界。



