☰
microduck vision-demo 技术解析:让家用路由器后面的机器人把相机帧直接推给数据中心 Space
2026/9/25 7:17:33 网站建设 项目流程
  • 机器人
  • 嵌入式
  • 强化学习
  • 人工智能
  • 智能硬件
  • 计算机视觉
  • 音视频

【免费下载链接】microduck

A Tiny biped duck robot 🦆

项目地址:https://gitcode.com/gh_mirrors/mi/microduck
点击查看免费下载

microduck 是一个微型双足机器人项目,其spaces/vision-demo是一个运行在 Hugging Face Space 上的视觉演示应用:机器人在某人的家庭网络里,把相机画面以 H.264 流的形式推送到这个 Space,Space 端用 OpenCV 对每一帧解码后的画面做实时处理。本文以该 Space 的设计文档 spaces/vision-demo/README.md 为主体,结合 mediad/src/stream.rs 与spaces/vision-demo/下的全部源码,完整讲解这套"机器人主动拨号、外向 WebSocket 直连、无 relay、无 WebRTC"的帧流方案——读完你既能照文档本地跑起来,也能从源码层面理解它的协议、取舍与部署细节。

设计核心:机器人拨号,这就是全部设计

这个 Space 的第一句话就点明了它的本质:一台在某人家庭网络里的机器人,把相机以 H.264 流的形式推送到这个 Space,Space 上由 OpenCV 对每一帧解码后的画面运行处理。机器人的网络上没有任何入站暴露,也没有任何 relay 参与。

不要直接编辑这个 Space。它的源码位于本仓库spaces/vision-demo/,发布动作由 scripts/publish-space.sh 完成(scripts/publish-space.sh vision-demo),这样协议、方法名、相机几何等与仓库保持单一事实来源,避免拷贝漂移。

为什么"拉流"的方向在这里行不通

把相机画面通过 rendezvous(会合服务)"拉"过来意味着 WebRTC,而"家庭路由器后面的机器人 ↔ 数据中心的容器"之间的 WebRTC,必须有一个 relay 候选作为兜底。这个 Space 的历史上恰恰没有可用的 relay:所有机器人出厂时携带的 relay 默认值指向一个没有 DNS 解析的主机名,于是出现了文档记载的预期结果——"信令通了,媒体没通"。

该默认值已在 docs/design/remote-access-design.md §6 修复,但即便修复,"反转方向"仍然是这个 Space 想要的方案,原因很实际:relay 会以机器人所有者的计量配额为代价来转发每一帧(relay 流量按 Hugging Face 账号每月 10 GB 计量,见 mediad/src/stream.rs 模块文档),而这些帧只被一个程序解码观看,不值得走一条按流量计费的中继通道。

反转方向:rendezvous 只传指令,不传像素

于是方向被反转了,README 用一张图说明整个拓扑:

this Space ──media.stream {url: "wss://…/frames"}──► rendezvous ──► the duck the duck ═════════ H.264, outbound wss, direct ═════════════════► this Space

即:Space 通过 rendezvous 告诉机器人"把帧发到哪个地址",机器人从这个 Space向外发起 WebSocket 连接,把 H.264 帧直连推过来。外向 WebSocket 是唯一"永远可用"的东西——机器人每秒钟能保持可达,靠的正是它本来就一直握着一条到某个 Space 的外向连接(这正是它出现在你的机器人列表里的原因)。这样一来:

  • NAT 不再是参与者:不再需要任何 NAT 穿透协商;
  • 不需要凭证代理:握手直接用机器人自己的账号 token;
  • rendezvous 传递的是"指令"而不是"像素":每个会话只有一条小指令包经过共享服务,真正的字节流点对点直达——这正是它能在依赖同一服务的 mini 机队规模下扩展的原因。

media.stream:一个 key 的三种读法

media.stream是这个方案的唯一开关,由mediad自身应答(实现见 mediad/src/stream.rs)。同一个url键有三种读法:

请求含义
media.stream {"url": "wss://…/frames"}启动一条帧流
media.stream {"url": null}停止帧流
media.stream(不带url)查询当前在流什么、流得怎么样

在 Space 端(spaces/vision-demo/app.py 的start/stop函数),发出去的实际参数是{"url": url, "fps": fps, "longest": longest, "quality": QUALITY},其中quality是 JPEG 专用的(H.264 默认时位率由 pipeline 决定,页面上不提供该旋钮)。mediad的Streamer::start会校验 url 必须是ws://或wss://、quality 必须在 1..=100,并在发送任何东西之前把目标 url 以 info 级别记入日志——"把相机交给一个被告知的地方"必须可审计。

这种设计的代价与取舍

反转方向不是免费的,README 明确列了三笔代价,随后是逐条源码佐证。

没有返回媒体路径

不能驱动机器人,也不能实时看它。想看实时画面的观众需要 WebRTC,那是 docs/design/remote-access-design.md §6 覆盖的场景。这个方案的适用对象是"消费者是一个程序"——模型、检测器、离线分析器,而不是人。要控制机器人(启动/停止流、询问状态)走的是独立的 JSON-RPC 控制通道,而非这条媒体路径。

每秒几帧,而不是三十帧

每秒 5 帧、最长边 640px 对模型来说绰绰有余,而且只是视频轨道本身编码量的一小部分。关键在于:速率是由机器人一侧的videorate施加的,而不是靠接收方"礼貌地请求"——在 mediad/src/stream.rs 中,H.264 分支的Config::interval()直接返回Duration::ZERO,因为 H.264 单元是编码器发出的,速率已经由 pipeline 里的videorate限好了;只有按需编码的 JPEG 分支才由这个线程自行守节奏(fps.clamp(0.2, 15.0)决定间隔)。

Space 端默认值与之对齐(spaces/vision-demo/app.py):

FPS = float(os.environ.get("DUCK_FPS", "5")) LONGEST = int(os.environ.get("DUCK_LONGEST", "640")) QUALITY = int(os.environ.get("DUCK_QUALITY", "70"))

页面上这三项(frames a second / longest side (px))可调,且mediad侧的Config常量默认值完全一致:DEFAULT_FPS = 5.0、DEFAULT_LONGEST = 640、DEFAULT_QUALITY = 70。

H.264 为主,JPEG 仍可回退

板载硬件编码器意味着 H.264 消耗的是 VPU 而非 CPU 核心,帧间预测带来的字节节省也很可观:文档记录在合成帧上测得 H.264 单帧约 0.5 KB,而 JPEG 约 6.5 KB——真实相机画面不会有这么夸张,但方向一致。

H.264 的代价是状态性:接收方在关键帧到达之前什么都解不出来,而丢失一个单元会污染其后所有单元,直到下一个关键帧。mediad的处理方式不是祈祷,而是三项落实:

  1. 阀门一开就向编码器要一个关键帧(pipeline::force_keyframe);
  2. 每个关键帧前重复携带 SPS/PPS(对应h264parse config-interval=-1);
  3. 遇到缺口直接放弃到下一个关键帧,而不是把缺口之后的预测帧发过去。

最后这条在 mediad/src/stream.rs 里就是awaiting_key标志与dropped计数器的逻辑:一旦丢过东西,H.264 分支会丢弃一切直到关键帧(a_dropped_h264_unit_is_followed_by_a_wait_for_a_keyframe测试精确验证了"丢一个 P 帧就等关键帧"的丢弃策略)。因为每帧都要做颜色转换和 JPEG/单元编码、且 CPU 还要跑控制回路,帧流同时最多一条:第二个media.stream会替换第一个,这也是不经过 stop 改变速率的唯一方式。

对频繁重连、更在意"立即起播"而非带宽的接收方,media.stream {"encoding": "jpeg"}是另一条路。JPEG 每条消息独立可解(天然是 keyframe),spaces/vision-demo/receiver.py 的jpeg_decoder()因此是无状态的:一张图进、一张图出;而h264_decoder()用av.CodecContext.create("h264", "r")持有一个贯穿连接生命周期的有状态解码器,按到达顺序喂入,parse先剥 start code、把粘在关键帧前面的参数集拆开。

帧到达时已经竖直

相机安装角度差了四分之一圈,但帧到达时已经转正。转换发生在mediadpipeline 的 tee 之前,所以视频轨道和这条分支都携带竖直画面,hello 里写rotate: 0,同时把安装角mount_rotate作为参考信息一并给出(mediad/src/stream.rs 的hello()函数)。因此 spaces/vision-demo/filters.py 里的upright()在这条帧流路径上不会被用到——它保留下来是因为对 WebRTC 消费者仍然正确:那边拿到的是相机原图,需要按media.video告知的角度自行旋转。filters.upright()实现的是MOUNT_ROTATION_DEGREES = 90的顺时针旋转(np.rot90按逆时针计数,所以取负),并把np.ascontiguousarray收尾以保证内存布局连续。

这是谁的相机:账号隔离与认证

帧端点(GET /frames,WebSocket)是公开的——"Space 的 URL 就是 Space 的 URL"。所以机器人在握手时出示它自己的账号 token,Space 端通过whoami-v2解析出用户名,与 rendezvous 的做法完全一致(spaces/vision-demo/receiver.py 里whoami()与 rendezvous 的validate_hf_token是同一个调用)。

为什么必须做这一步,README 说得很直白:没有它,任何人都能往演示里推帧;更糟的是,访客可能看到陌生人的相机。所以:

  • 帧按机器人应答出的用户名归档(Frames以(username, robot)为键,一人多机不会串);
  • 访客只被展示自己账号名下的机器人(页面逻辑见 spaces/vision-demo/app.py 的render():receiver.FRAMES.newest(who)只读当前账号);
  • token每次连接只解析一次,从不存储——进程里留下的是用户名。

从源码看,握手鉴权的顺序是先验后收:receiver.py 的/frames处理器在accept()之前检查Authorization: Bearer头并解析whoami-v2,拒绝时以 HTTP 403 / close code 1008 直接关闭——文档特意强调"拒绝要大声说出来",因为从机器人侧看,关闭看起来就像"url 写错了"。

自己运行:本地复现与环境变量

README 给出了完整的本地运行方式。一次性准备依赖:

uv venv && uv pip install -r requirements.txt

然后启动:

DUCK_RECEIVER=ws://192.168.1.50:7860/frames uv run app.py

关键环境变量与语义如下表(均来自 spaces/vision-demo/app.py 的读取逻辑):

环境变量默认值含义
DUCK_RECEIVER见下告诉机器人把帧发到哪。必须是机器人能到达的地址——你机器在机器人网络里的地址,而不是localhost;在 Space 上由SPACE_HOST推导(wss://{SPACE_HOST}/frames),无需设置
SPACE_HOST无Space 自身的 hostname;在本地未设置时,接收地址回退为ws://127.0.0.1:{PORT}/frames(正好适合本机跑scripts/duck-sim的仿真鸭)
HF_TOKENhf auth login存储值本地代替登录按钮的凭证
DUCK_FPS5每秒帧数
DUCK_LONGEST640最长边像素
DUCK_QUALITY70JPEG 质量(H.264 时无效)
DUCK_LOGINFO日志级别,DEBUG可看到控制通道收发细节
PORT7860服务端口,须与 README frontmatter 的app_port: 7860一致
REACHY_CENTRAL_URLrendezvous 默认地址覆盖会合服务基址(spaces/vision-demo/rendezvous.py)

README 特别提醒:DUCK_RECEIVER写成localhost只对同一台机器上的仿真鸭成立——真实机器人的 loopback 是它自己的,写localhost等于让机器人拨号拨到它自己。页面上的地址输入框正是为这个场景准备的;而登录按钮在 Space 之外是被 mock 的(Gradio 会塞入字面量mock-oauth-token-for-local-dev,任何服务都会正确拒绝它),所以本地要用HF_TOKEN或hf auth login存储的凭证——这个坑在 spaces/vision-demo/app.py 的token_of()里有专门处理。

为什么是 Docker Space:FastAPI 持有服务器

Space 的 frontmatter 声明了sdk: docker。原因不是偏好,而是技术约束:帧到达在一条属于我们自己的 WebSocket 路由上,所以 FastAPI 拥有服务器,Gradio 被挂载进 FastAPI(app = gr.mount_gradio_app(app, demo, path="/", ssr_mode=False))。文档写明的方向是 FastAPI 在外、Gradio 在内——反向(往 Gradio 的 app 上加路由)有已知的 WebSocket 破坏问题,而且这种问题在 Space 内部发现会异常恼人。自己跑uvicorn还消除了"平台会不会承载自定义路由"的任何疑问。

microduck-console因另一条原因(docs/design/remote-access-design.md §5.0)也是 Docker Space,所以这是项目里第二个。依赖清单(spaces/vision-demo/requirements.txt)也反映了这一架构:gradio[oauth]>=5(注意是[oauth],缺了itsdangerous/authlib会在 import 时失败而不是第一次点击时)、fastapi、uvicorn[standard]、websockets(uvicorn 跑ws://路由需要它,缺了机器人握手会得到一个像"url 写错"的 404)、av(H.264 解码)、opencv-python-headless(容器无显示,headless 版免去整棵 GTK)、numpy、requests、huggingface_hub。

这里面已经没有任何 WebRTC 栈

这个 Space 曾经用reachy_mini[central-consumer]拉帧,那意味着aiortc、av和一个骑在对方私有方法上的 DTLS 密码套件垫片——而且它从数据中心永远连不上。现在机器人主动拨号,剩下的是gradio、fastapi、opencv、requests和av。最后那个av是为 H.264 回来的,但它是解码器而非传输层:没有 ICE,没有 DTLS,没有信令,没有任何 NAT 能插嘴的东西。

控制侧同样干净:控制通道走 spaces/vision-demo/wire.py 的WsConsumer,用两个 HTTP 动词加一条 SSE 流完成与机器人的 JSON-RPC 会话——POST /send发、GET /events收,Authorization: Bearer鉴权,startSession的应答在 POST 响应体里而不是流上(这是读该协议最容易错一次的地方)。从 spaces/vision-demo/control.py 看,Rpc维护独立 id 空间与 pending 表,是传输无关的:datachannel、LAN datachannel、纯 HTTP JSON-RPC 三种传输都喂同样的行进来,页面代码一次都没改过。

源码级细节:四个文件串起一条帧流

机器人列表:rendezvous.py

spaces/vision-demo/rendezvous.py 用一次GET /api/robot-status列出账号名下的机器人(对应meta.kind == "microduck"的条目),并解析出peer_id、名称、release、simulated(仿真鸭,来自configd --simulated)、busy(被谁占用,activeApp)。它特意不开任何会话——列表刷新不会挤掉正在持有的会话;401只意味 token 不被whoami-v2认识,而429则不是 rendezvous 写的(该路由从不做 rate-limit 检查),_who_answered()会把你带到应答里那些可引用的头(server、retry-after、cf-ray…)来定位是哪个边缘节点拦的。User-Agent必须诚实自报家门,否则会被边缘当作 python-requests 机器人拦成 429。

帧流接收:receiver.py

spaces/vision-demo/receiver.py 是机器人拨号落地的那个 socket:先收一条文本 hello(描述编码、尺寸、速率、机器人身份),之后每个二进制消息就是一个访问单元。解码按 hello 里的frames.encoding分支(不嗅探字节),JPEG 无状态、H.264 有状态。超过STALE_AFTER = 5.0秒没新帧的流标记为 stale 并在页面上如实展示——"流停了"和"从来就没有流"是两回事。

帧上跑什么:filters.py

spaces/vision-demo/filters.py 只依赖 OpenCV 和 numpy,所以可以脱离机器人、token、WebRTC 在合成帧上单独验证。FILTERS字典提供五个选项:raw、edges (Canny)(灰度→Canny(80,180)→黄色蒙版叠加)、motion(帧差+阈值 18+膨胀→红色蒙版)、optical flow(200 个角点的 Lucas–Kanade 稀疏光流,位移平方小于 4 的亚像素抖动被滤掉)、camera geometry(从传感器光学反推fx=fy、主点与视场角并绘制叠加,文档注明"推导而非标定,media.video才是真相")。

机器人端:mediad/src/stream.rs

机器人那一半的完整闭环在 mediad/src/stream.rs:Streamer::start校验配置→开 H.264 阀门→起编码线程与pump;pump拨号→发 hello→carry转发帧,同时读回端(模型若有回话会计数),并对断连做 2 秒起、30 秒封顶、20% 抖动的指数退避重拨——因为Space 每次 push 都会重启、无人看时还会休眠,"接收方消失"是常态而非异常。三个测试精确钉住了协议形态:frames_reach_a_receiver_the_robot_dialled(hello 先行、bearer 携带)、a_receiver_that_hangs_up_is_redialled(重拨后新连接会收到新的 hello)、the_hello_names_the_robot_where_the_receiver_looks(hello 的robot.name字段形状与 spaces/vision-demo/receiver.py 读取处互锁)。

容器启动与故障诊断:boot.py 与 Dockerfile

最后是部署侧的工程细节。spaces/vision-demo/boot.py 在try里 import 应用:失败时不退出,而是起一个只回显完整 traceback 与关键环境变量的 FastAPI——Space 保持存活、curl /直接回答"为什么起不来",省去"容器退出→平台只报首行截断→要写权限才能读日志"的来回。而 spaces/vision-demo/Dockerfile 的注释记录了两个血泪教训:GRADIO_SSR_MODE=false(SSR 会起一个 Node 服务器,这个镜像没有 Node;且登录页后面不需要 SEO);SYSTEM=spaces(Gradio 判断是否在 Space 上用的是os.getenv("SYSTEM") == "spaces"且还要SPACE_ID,Docker Space 默认不设SYSTEM,于是 Gradio 装上 mocked 登录路由,import 时因没有本地hf auth login直接ValueError——SPACE_ID和OAUTH_CLIENT_ID一直都在,缺的只有这一行)。

小结

vision-demo是一份把"机器人外呼"这一朴素事实用到极致的参考实现:外向 WebSocket 永远可用,所以媒体也走外向;rendezvous 只传递指令;账号 token 只做身份解析、从不落地;H.264 的状态性用关键帧策略正面处理而不是回避。无论你是想把它跑成本地的模型推理演示、还是想把同样的"机器人拨号"模式复用到自己的消费端程序,spaces/vision-demo/ 下的app.py、receiver.py、wire.py、rendezvous.py、filters.py与机器人侧的 mediad/src/stream.rs 合在一起,就是一份从协议到部署都可以对照着抄的完整答案。

  • 机器人
  • 嵌入式
  • 强化学习
  • 人工智能
  • 智能硬件
  • 计算机视觉
  • 音视频

【免费下载链接】microduck

A Tiny biped duck robot 🦆

项目地址:https://gitcode.com/gh_mirrors/mi/microduck
点击查看免费下载

相关推荐

上一篇:PerfKit Benchmarker配置完全手册:YAML配置与参数覆盖详解
下一篇:在Linux系统中轻松部署eGPU的智能切换方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询