← 返回文章库

遥操作的画面和手势怎么传?televuer 与 teleimager 的分工

最后更新 2026-08-23
⏱ 约 29 分钟 🟡 涉接线/强电
你将学到
  • 看懂遥操作系统里两条相反数据流的技术要求为什么完全不同
  • 掌握实时视频回传的核心取舍:压缩与延迟、缓冲与卡顿、丢帧与排队
  • 理解 televuer 与 teleimager 的职责边界,以及这种拆分带来的替换自由度

假设你正在调一套 XR 遥操作:戴上头显,抬手,机器人的手臂跟着抬起来。看上去是一件事,但你只要真动手搭过就会发现,它其实是两件事,而且这两件事经常在不同的地方出问题。

一种坏情况是:你手抬起来了,机器人没动,或者动得别扭。这是手势数据没被正确解析或映射

另一种坏情况是:机器人明明动了,但你眼前的画面慢半拍,你伸手去抓一个杯子,看到手已经碰到杯子了才停,结果早就撞过头了。这是画面回传出了问题

这两类故障的排查方法、涉及的代码、甚至用到的技术栈,都不一样。宇树的处理方式很直接:拆成两个仓库。手势和显示接口归 televuer,图像采集和传输归 teleimager。两份 README 摆在一起读,能看到一次挺清爽的职责切分。

先说清楚这篇的边界:所有内容来自这两个仓库公开的 README 与文件树,没有真机验证,凡是涉及运行效果的地方,我都只转述文档里的说法。

两条方向相反的数据流

把遥操作抽象一下,链路上跑着两股东西:

人 ──【手部姿态 / 控制器姿态 / 头部姿态】──▶ 机器人      (动作下行)
人 ◀──【第一人称画面,最好是左右眼各一路】── 机器人      (画面上行)

它们的技术要求几乎处处相反。

动作那一路,数据量极小——几十个关节角、几个四元数,一帧撑死几百字节,但它对语义正确性要求极高:坐标系搞错、左右手写反、腕关节和腰关节的字段名混了,机器人就会做出完全错误的动作。这一路的难点在「翻译」。

画面那一路正好倒过来,语义简单到不能再简单(就是一张图),但数据量大好几个数量级,而且对时间正确性要求极高。这一路的难点在「运输」。

一个负责翻译,一个负责运输。这就是两个仓库的分界线。

televuer:只做接口抽象,不做运输

televuer 的仓库文件树只有十二条路径,核心代码就三个文件:

src/televuer/__init__.py
src/televuer/televuer.py
src/televuer/tv_wrapper.py
example/test_televuer.py
example/test_tv_wrapper.py

一个薄成这样的仓库,本身就是一种表态。README 说得也很明白:它是 Vuer 库的一个特化版本,作为 Vuer 的 wrapper,为宇树机器人补上专门的适配;它目前是 xr_teleoperate 项目的核心组件之一。

向下,它适配 XR 设备。README 列出支持的设备包括 Apple Vision Pro、Meta Quest3、Pico 4 Ultra Enterprise 等,采集的是手部追踪与控制器追踪数据。

向上,它给上层程序提供一个统一的取数接口。V2.0 的更新记录里有一条特别值得看:把取数函数从 get_motion_state_data 改名成了 get_tele_data,同时去掉了嵌套的 TeleStateData,所有数据统一放进一个 TeleData 里返回

这条改动透露的信息量不小。一开始作者按「状态」和「其他」分了层,用下来发现嵌套只是徒增调用方的负担——上层拿到的本来就是同一时刻的一整套姿态,没必要拆开。把一次采样的所有结果打包成一个扁平结构返回,这是姿态类接口很常见的收敛终点。

同一批更新里还有一条:修正命名错误,waist(腰)改成 wrist(腕)。这是个很朴素的 bug,但放在遥操作里就不朴素了——上层程序按字段名把数据映射到机器人关节,名字错了,映射就错了。这一路数据的难点在「翻译」,这就是活生生的例子。

另外一条:变量命名向 Vuer 的约定看齐。作为 wrapper 层主动向上游命名规范靠拢,能让熟悉 Vuer 的人零成本上手,也降低了跟随上游升级的成本。

三种显示模式,对应三种作业心态

V4.0 把显示模式重新整理成了三种,README 原文的描述是:

  • immersive:完全沉浸,头显里显示机器人的第一人称视角(需要启用 zmq 或 webrtc)
  • pass-through:头显显示透过设备摄像头看到的真实世界,即使启用了 zmq 或 webrtc 也不显示机器人画面
  • ego:中间开一个小窗显示机器人第一人称视角,四周仍然是真实世界

这三种模式对应的其实是操作员的三种处境。完全沉浸适合精细作业,你需要全部注意力都在机器人那一侧;透视模式适合调试和安全撤离,你得看清自己脚下有没有线、旁边有没有人;ego 模式是折中,一边盯着机器人一边保持对现场的感知。

V4.0 还提到一条:为 immersive 和 ego 模式调整了图像平面的高度,以获得更自然舒适的 VR 体验。这句话背后是遥操作绕不开的一个问题——人在头显里待久了会不舒服,画面平面的位置、延迟、双眼一致性,任何一项没做好,操作员十分钟就撑不住了。这不是体验优化,是作业时长的硬约束。

一份诚实的兼容性日志

televuer 的 README 里有个我很少在这类项目里见到的东西:一整节 Version History,按上游 Vuer 的版本逐段记录当时的功能状态

内容不是「支持/不支持」这么简单,而是很具体的现象记录:某些版本手部追踪正常但控制器追踪不支持;某些版本只能显示平面 RGB 图像、没有立体视图;某些版本干脆取不到手部追踪数据,但控制器数据能拿到;某些版本在特定设备上,用手势点某个按钮会黑屏卡住、改用控制器点就正常,而换成另一种模式后又反过来。README 里明确标出了一个推荐版本(具体是哪个以官方仓库当前文档为准),并附了上游 issue 链接。

作为读者,这一节比任何架构图都有用。它等于在说:这一层的坑主要来自上游依赖的版本漂移。你接手这个项目,第一件要锁死的事情不是你的代码,是 Vuer 的版本。

对我们做工程的启发是通用的——当你的项目建立在一个还在快速迭代的上游库之上,与其在 README 里写「请使用最新版」,不如老老实实维护一张版本-现象对照表。这份表是团队踩坑的沉淀,价值远高于一句版本约束。

证书:一个容易卡住新人的前置条件

televuer 需要 SSL 证书,因为 XR 设备要通过 HTTPS / WebRTC 安全连接。README 给了两套生成流程:Pico / Quest 用一条 openssl req -x509 就够;Apple Vision Pro 麻烦一些,要自建根证书、写 server_ext.cnfsubjectAltName,最后把根证书传到设备上手动安装为受信任证书。

sudo ufw allow 8012

证书路径的配置方式有三种:放进用户配置目录 ~/.config/xr_teleoperate/(README 推荐),或者用 XR_TELEOP_CERT / XR_TELEOP_KEY 两个环境变量,或者走模块内的默认路径兜底。

这里有个细节值得停一秒:这套证书配置是和 teleimager 共享的。两份 README 都写了同一个目录、同一对环境变量名。两个仓库互相独立,却共用一份凭证约定——这是拆分项目时很务实的做法,拆开归拆开,公共的运行时约定还是要统一,不然部署的人要配两遍。

teleimager:一个专职的图像服务

换到另一边。teleimager 的自我描述是:一个图像服务,从多路相机采集视频流,通过 ZeroMQ 或 WebRTC 发布到网络上。

文件树同样精简,核心是两个文件加一份配置:

src/teleimager/image_server.py     —— 服务端:采集 + 发布
src/teleimager/image_client.py     —— 客户端:接收 + 显示
cam_config_server.yaml             —— 相机配置
setup_uvc.sh                       —— 给非 root 用户配视频设备权限
setup_autostart.sh                 —— 配开机自启
README_zh-CN.md

有两个安装选项:只用客户端就 pip install -e .,要跑服务端得 pip install -e ".[server]"。这个区分很实在——服务端跑在机器人身上那块计算板(README 的例子里是 Jetson Orin NX,ARM 架构),客户端跑在操作员的机器上,两边依赖不一样,没必要都装全。

README 里还有一句约定我很喜欢:所有用户可调用的 API 都放在代码里 # public api 这条注释下面。没有下划线私有约定,没有 __all__,就一条注释划一道线。土办法,但对读代码的人非常友好——你不用猜哪个函数是给你用的。

相机怎么认?三种标识方式的取舍

teleimager 的 README 有一整节叫「Design Principles」,专门解释设计取舍。这在开源项目里不多见,而且它讲的第一件事就特别接地气:Linux 上的相机到底该怎么标识。

它支持三种:

标识方式 特点 README 推荐的场景
物理路径 physical_path 不受重启和插拔顺序影响,即使多台相机序列号重复也能区分;缺点是换 USB 口就得改配置 多相机固定部署(README 举的例子是机器人头部 + 手腕)
序列号 serial_number 换 USB 口不变,识别准确,配置简单;缺点是低价相机可能序列号重复或异常 RealSense 相机
设备路径 video_id 直接写 video_id: X 就能用;缺点是随插拔顺序和重启变化 单相机、临时测试

一台人形机器人做遥操作,头上一个相机、两个手腕各一个,是很自然的配置。这种场景下设备一多,/dev/video0 这种编号就完全靠不住了——每次开机顺序可能都不一样,你以为在看头部相机,实际显示的是左手腕。物理路径把相机绑死在 USB 拓扑的某个位置上,插在哪个口就是哪个口,重启一百次也不变。代价是硬件走线定型之后就不能随便换口。

README 里还给了一条排查经验:如果 teleimager-server --cf 列出来的序列号等信息显示为 unknown,可以试试用 sudo 跑,有些相机需要提升权限才能读到完整的硬件元数据。这种细节,只有真的被卡过的人才会写进文档。

发现相机的命令是 teleimager-server --cf,有 RealSense 的话加 --rs。看清楚之后填 cam_config_server.yaml,再启动服务。这个「先发现、后配置」的两段式流程,比让你自己去猜设备号友好得多。

低延迟视频回传:这一路的真正难点

现在到了整篇最值得展开的部分。

为什么延迟是遥操作的命门

人做精细操作,靠的是视觉闭环:看到手离目标还有多远 → 调整动作 → 再看 → 再调整。这个环路人自己走一圈很快,快到你意识不到它的存在。

遥操作把这个环路拉长了——你的动作要经过采集、传输、映射、执行才到机器人身上,机器人的画面要经过采集、编码、传输、解码、渲染才回到你眼前。整个环路的延迟是这一长串环节的累加。

延迟带来的不只是「慢」,而是操作失准。你的大脑会按照「我看到的就是现在」来规划动作,画面延迟意味着你实际是在根据过去的状态做决策,于是你总会动过头,然后反向修正,又过头,形成来回振荡。做抓取这类需要停在精确位置的动作,这个问题会被放大。

延迟继续加大,还会引出生理上的麻烦:头动了但画面没跟上,前庭系统的感知和视觉信息对不上,人就开始不舒服。这也是 televuer 会去调整图像平面高度、去区分沉浸/透视模式的背景——操作员的舒适度直接决定了单次作业能持续多久。

所以做遥操作视频回传,第一优先级永远是延迟,不是画质。

压缩还是不压缩

这是视频传输的第一个岔路口,取舍非常直白:

  • 压缩编码:省带宽,但编码和解码本身都要花时间,一来一回就是额外的延迟。而且大多数编码器为了压缩率会做帧间预测,一帧要参考前后帧,这又引入了缓冲。
  • 不压缩(或只做轻量压缩):延迟低到几乎只剩传输时间,但带宽吃得厉害。

teleimager 的 README 直接把这个取舍摊开写了——它列了三种传输方式,并且给出了各自的适用场景:

ZeroMQ PUB-SUB:适用于服务端和客户端在不同机器上、走局域网的场景。README 给的优点是局域网内高质量帧传输、开销低吞吐高、在不牺牲画质的前提下保持低延迟。

WebRTC:适用于实时预览、VR 遥操作、UI 调试。README 写的优点是低延迟加自适应码率,编码默认 H.264、也支持 VP8,浏览器和 VR 设备都能直接接。

共享内存:适用于服务端和客户端在同一台机器上、追求极限性能的场景。README 的说法是带宽只受内存限制、延迟到微秒级、可以做到零拷贝或单次拷贝、CPU 占用低。

这三条排下来,其实是一条清晰的「距离-代价」曲线:同机器用共享内存(不走网络,不用编码);同局域网用 ZeroMQ(走网络,但画质可以不妥协);要跨浏览器、跨 VR 设备、网络条件不确定,就上 WebRTC,用编码和自适应码率换通用性和抗抖动能力。

README 还解释了为什么非得支持这么多种:图像服务有两大用途——一是录制高质量数据用于模型训练,二是实时可视化用于调试和监控。前者要画质,后者要延迟,两者对传输的要求根本不同。这跟我们在具身智能那篇里聊的思路是一脉相承的:遥操作采到的数据本身就是训练素材,所以同一套相机既要伺候人眼,也要伺候数据集。

顺带一提,televuer 的 V2.0 更新里写着「图像传输改为按引用传递,不再使用外部共享内存」。两个仓库对共享内存的态度差异很有意思:teleimager 把共享内存留作同机高性能通道,而 televuer 这一侧把图像交接简化成了进程内的引用传递。这大概率是因为职责拆开之后,televuer 不再需要自己管跨进程的图像搬运——那是 teleimager 的活。

为什么是发布订阅,不是请求响应

注意上面三种方式里,网络传输的两种都不是请求-响应模型。

请求-响应(你要一帧、我给一帧)在实时视频里是个糟糕的选择。每一帧都要付一个完整往返的时间,而且客户端得自己决定什么时候要下一帧——要早了服务端还没准备好,要晚了帧就积压了。更麻烦的是,一旦某次请求卡住,整条流就停在那儿等。

发布-订阅反过来:服务端只管按自己的节奏往外推,推完就不管了;订阅方能收多少收多少。发送方不被接收方拖慢,这是实时流的基本要求。

teleimager 的做法更精细一点:视频帧走 ZeroMQ PUB-SUB,而图像配置命令走 ZeroMQ REQ-REP。这个划分很清楚——数据流用发布订阅,控制指令用请求响应。改分辨率、改帧率这类操作你必须知道有没有生效,所以要个回复;而视频帧不需要回复,也没时间等回复。

这个「数据流走订阅、控制指令走请求响应」的双通道模式,和我们在 unitree_sdk2 架构那篇里拆过的 Channel 与 Client/Server 并存,是同一个思路。看多了就会发现,凡是同时要传状态流和下指令的系统,最后基本都会长成这个样子。

三缓冲:宁可丢帧,不要排队

teleimager 的 Features 里有一条「使用三重环形缓冲区高效处理帧」,Design Principles 里专门有一节讲它的好处,README 列了三点:

  • 非阻塞读写:写入方不等读取方,一直往可用的槽位里写;读取方总能拿到最新的完整帧
  • 无撕裂:靠写入回避逻辑,读取方不会读到正在写一半的帧
  • 永远新鲜:和 FIFO 队列不同,旧帧可以被直接覆盖,读取方拿到的总是最新帧,README 明确写了这对实时应用至关重要

这一节我认为是整份 README 最有价值的部分,因为它把实时系统里一个反直觉的原则讲透了。

缓冲区设计天然是个两难:

  • 缓冲:网络抖动、采集抖动都能被吸收,画面平滑不卡顿——但每一帧在队列里排队的时间都算进延迟,缓冲越大,你看到的画面越旧。
  • 缓冲:延迟低——但只要来一次抖动,缓冲就空了,画面卡一下。

播放电影、看直播这类场景,选的是前者。观众不在乎自己看到的是三秒前的画面,只在乎流畅。所以播放器会攒一大段缓冲再开始播。

**遥操作必须选后者,而且要选得更激进。**你看到的每一帧都是要用来做动作决策的,一帧过期的画面对你没有任何价值——它只会误导你。所以正确的策略不是「把旧帧排好队慢慢给」,而是「有新帧来了就把旧帧扔掉」。

一句话概括:丢掉一帧旧画面,永远比显示一帧过期画面好。

FIFO 队列做不到这件事。队列的语义就是「先进先出、一个都不能少」,一旦读取方慢了,队列就开始堆积,堆积就是延迟,而且这个延迟会一直累积下去不会自己恢复。三缓冲的语义完全不同:三个槽位轮转,写入方永远往当前没被读的槽位里写,写满了就覆盖最旧的那个。读取方来的时候,直接拿最新的那个完整帧。

至于为什么是三个而不是两个:双缓冲的问题在于,当读取方正占着一个槽位读的时候,写入方只剩另一个槽位可写,如果这时候又来了新帧,写入方要么等(阻塞),要么覆盖自己刚写的(可能撕裂)。加第三个槽位就给了写入方一个永远可用的落脚点,读写彻底解耦。这是图形和实时采集领域用了很多年的老办法,放在这里正合适。

立体视觉:两只眼睛不是奢侈品

televuer 的版本历史里反复出现一个描述:某些版本下「只能显示平面 RGB 图像,没有立体视图」,这被当作一个功能缺陷记录在案。另外还提到某些版本在启动时右眼会短暂发黑。

会专门记录「右眼」的状态,说明左右眼在这套系统里是两条要分别照顾的通道。

为什么遥操作特别在意这个?因为深度感知。人判断距离靠好几种线索,其中最直接的一种是双眼视差——同一个物体在左右眼视网膜上的位置有细微差别,大脑据此算出远近。给两只眼睛送同一张图,这条线索就没了。

只用单眼线索(物体大小、遮挡关系、透视)也能大致判断远近,但精度差很多,而且需要经验和场景先验。做抓取的时候,「手离杯子还有多远」这个判断直接决定成败——差几厘米,要么抓空,要么撞上去。

所以立体回传对遥操作抓取几乎是刚需。这也解释了 teleimager 为什么要在多相机管理上花这么大力气:不只是「机器人头上有相机、手腕上有相机」这么简单,头上那一路本身可能就是左右两个成像单元,都得被稳定地识别和配置。相机识别方式那一节的价值,在这里体现得最充分——你绝不希望左右眼的画面因为一次重启而对调。

关于相机本身的接入,宇树还有独立的 SDK,我们在相机 SDK 那篇里单独讲。

为什么值得拆成两个项目

回到最初那个问题。把这两个仓库并排放着看,拆分的理由就很清楚了:

关注点不同。 televuer 处理的是「设备接口抽象」——不同厂商的头显,手部追踪的数据格式、坐标系约定、交互方式都不一样,它的工作是把这些差异吃掉,向上吐出统一的 TeleData。teleimager 处理的是「数据搬运」——怎么从多路相机稳定取到帧,怎么用最低的延迟送到对端。一个是语义问题,一个是性能问题

变化的原因不同。 televuer 要跟着 XR 设备生态和上游 Vuer 库走,那份长长的版本历史就是证据。teleimager 要跟着相机硬件和网络传输技术走。两者的变化频率、变化触发点完全不重叠,捆在一起就是互相拖累。

拆开之后各自获得了替换自由度:

  • 换一款头显 → 只动 televuer,图像传输那一侧完全不知道发生了什么
  • 换传输方案(局域网换共享内存、或者改走 WebRTC)→ 只动 teleimager,手势解析逻辑一行不用改
  • 想不戴头显、直接在浏览器里看画面 → teleimager 的 WebRTC 通道单独就能用,README 里写的就是浏览器打开 https://<host_ip>:<webrtc_port>、点右上角 start
  • 想只采数据不做遥操作 → teleimager 装 server 那套依赖单独跑,用 setup_autostart.sh 配成开机自启,压根不需要 televuer

最后这一条最能说明问题:teleimager 完全可以脱离遥操作场景独立使用。一个能识别多路相机、能配开机自启、支持三种传输方式的图像服务,本身就是通用基础设施。绑死在遥操作项目里是浪费。

再看一个细节:teleimager 的 README 说 WebRTC 需要的证书「通常由 televuer 生成」,而 televuer 的 README 说这份证书配置「可以和 teleimager 共享」。两边互相引用,但都不强依赖——你完全可以自己生成证书,两个模块都认同一个路径约定。拆分不等于老死不相往来,而是把耦合收窄到一个明确的接口上,这里的接口就是那个证书目录和两个环境变量名。

还有个小八卦:televuer README 里的 clone 命令指向的是作者个人账号下的同名仓库,teleimager 的仓库描述里也出现了同样的个人账号。这类痕迹在实际项目里挺常见——一个模块由个人先做出来,成熟后收进组织仓库,文档里的链接一时没来得及全部更新。读开源项目时碰到这种不一致,以组织仓库里的当前内容为准就好。

它们在整条链路里的位置

把这一卷前面讲过的东西串起来,一次完整的 XR 遥操作大概是这样跑的:

        ┌──────────────── 操作员侧 ────────────────┐
        │  XR 头显(AVP / Quest3 / Pico 4U 等)    │
        │        │ 手部与控制器追踪    ▲ 双眼画面   │
        │        ▼                    │           │
        │   televuer:接口抽象层                   │
        │   · 吃掉设备差异,输出统一 TeleData      │
        │   · render_to_xr 把画面送进头显          │
        │   · immersive / pass-through / ego       │
        └────────┬────────────────────▲────────────┘
                 │ 姿态数据            │ 图像帧
                 ▼                    │
        ┌────────────────────────────┴────────────┐
        │  xr_teleoperate:重定向、映射、安全逻辑   │
        └────────┬────────────────────▲────────────┘
                 │ 关节指令            │
                 ▼                    │
        ┌────────────────────────────┴────────────┐
        │  机器人侧                                │
        │  unitree_sdk2 → 关节执行                 │
        │  teleimager:多相机采集 + 三缓冲 + 发布   │
        │  (ZeroMQ PUB-SUB / WebRTC / 共享内存)  │
        └─────────────────────────────────────────┘

televuer 站在人这一端的最外层,teleimager 站在机器人那一端的最外层,中间那段重定向和映射的逻辑属于 xr_teleoperate。三者各管一段,边界清楚。

读完这两个仓库,有三件事我觉得值得记住:

**第一,两个方向的数据流要分开设计。**下行的动作数据难在语义正确(waist 写成 wrist 就是血的教训),上行的画面数据难在时间正确。用同一套思路去做两件事,两边都做不好。

第二,实时视频的核心信条是宁可丢帧不要排队。三缓冲不是为了「更快」,是为了让读取方永远拿到最新帧。这个原则可以直接搬到任何实时数据回传的场景里——传感器读数、点云、状态遥测,逻辑完全一样。

**第三,拆项目的判据是「变化的原因是否相同」。**televuer 跟着 XR 设备生态变,teleimager 跟着相机和网络变,两者的变化毫不相关,所以该拆。反过来,如果两块代码总是一起改,那它们本来就该待在一个仓库里。

顺着这条链路往上,xr_teleoperate 那一层的重定向和安全设计是更大的一块内容,遥操作主项目那篇里展开讲;这一卷的其他文章都汇总在宇树专题页里。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明