ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Day13 unitree_G1人形机器人传输问题

2026/8/14 17:13:49 拓冰建站 浏览量
Day13 unitree_G1人形机器人传输问题

今天看的是

为什么Axis显示动捕设备是96 Hz,但我们的程序通过BVH/MocapApi只能得到约60~64帧/秒?

重点讲我们如何从发现现象开始,一步步修改程序、增加证据、排除可能原因,最后把问题定位到Axis普通BVH/MocapApi输出边界。

一、最开始我们看到了什么

第一次动捕服成功连接后,链路已经能跑通:

动捕服 ↓ PNLink ↓ Axis Studio ↓ MocapApi ↓ GMR ↓ ZMQ ↓ MuJoCo

Axis里的人物会跟随真人,MuJoCo里的G1也会动。

但终端不断出现:

[NoitomJump] prev_posture_index=100 posture_index=102 delta=2 missing_posture_indices=[101]

大白话就是:

上一帧编号是100 下一次收到的编号是102 编号101没有出现

同时,程序统计的接收频率只有:

约60~64 Hz

但Axis设备页面显示:

帧率(动捕设备):96

于是产生了最初的疑问:

是Axis本来就只发约64帧,还是我们的程序、GMR、ZMQ或MuJoCo弄丢了约三分之一?

这时不能直接下结论,因为从Axis到MuJoCo中间有很多环节,任何一层都可能造成少帧。

二、第一步:不靠“看起来”,建立分层计数

一开始只看终端里的NoitomJump,无法知道帧究竟丢在哪里。

所以我们的第一个核心思路是:

给流水线中的每一层都安装“水表”。

最终形成以下计数:

Axis/MocapApi实际交给程序多少帧 ↓ source_received GMR处理了多少帧 ↓ gmr_processed 生成了多少机器人动作 ↓ motion_generated ZMQ成功发送多少帧 ↓ zmq_sent MuJoCo接收多少帧 ↓ received MuJoCo实际应用多少帧 ↓ applied

同时记录:

source_missing_indices input_pending robot_pending gaps invalid stale

这样我们就能问:

是输入时就少了? 还是GMR处理时少了? 还是ZMQ发送时少了? 还是MuJoCo接收时少了?

这一步是整个排查最关键的基础。

三、建立AuditCore:检查电脑发送端

我们增加了AuditCore

它记录:

source_received source_missing_indices first_posture_index last_posture_index gmr_processed motion_generated zmq_sent grpc_sent input_pending robot_pending max_input_queue max_robot_queue

为什么需要它?

因为如果最终看到:

source_received=1000 gmr_processed=650

就说明GMR或输入队列丢了350帧。

如果看到:

motion_generated=1000 zmq_sent=650

就说明ZMQ发送端丢了350帧。

如果它们都相等,就说明问题不在这些层。

四、建立AuditViewer:检查MuJoCo接收端

发送端说“我发送成功了”还不够,因为接收端可能没拿全。

所以MuJoCo端又增加:

received applied pending gaps invalid stale latest_frame_id delay_ms

最重要的是:

gaps

我们给每个ZMQ动作帧增加连续编号:

frame_id=1 frame_id=2 frame_id=3

如果MuJoCo收到:

1 2 4

那么:

gaps=1

这样可以直接判断ZMQ通信有没有漏掉动作帧。

五、先排查MuJoCo是不是处理不过来

第一次看到明显延迟时,最容易怀疑:

会不会MuJoCo渲染太慢,导致动作积压或丢失?

于是我们观察Viewer:

received applied pending gaps invalid

测试结果出现过:

received=1862 applied=1862 pending=0 gaps=0 invalid=0 delay≈0.3 ms

这意味着:

  • 收到1862帧;
  • 实际应用1862帧;
  • 没有尚未处理的积压;
  • frame_id没有断号;
  • Header、CRC、protobuf没有损坏;
  • 本机ZMQ延迟很低。

所以排除:

MuJoCo渲染导致丢帧 MuJoCo接收后没有应用 MuJoCo队列持续堆积 数据解析错误导致丢弃

六、排查ZMQ是否把帧弄丢了

项目早期的设计是:

PUB/SUB HWM=1 CONFLATE 只保留最新帧

这种设计追求低延迟,但会主动抛弃旧帧。

后来对方提出新要求:

可以慢一点,可以等待,但所有数据都必须拿到。

这和“最新帧优先”完全相反。

所以我们重新思考:

如果不能丢历史帧 就不能使用只保留最新帧的机制

于是改成:

阻塞式PUSH/PULL FIFO先进先出队列 不使用CONFLATE 不主动覆盖旧帧 退出前排空队列

然后比较:

motion_generated zmq_sent received applied

正式测试结果:

motion_generated=1862 zmq_sent=1862 received=1862 applied=1862 gaps=0 invalid=0 pending=0

因此确认:

只要动作已经进入ZMQ发送阶段,就完整到达并应用到了MuJoCo。

所以约96→63 Hz不是ZMQ造成的。

七、排查GMR是不是只能处理约64 Hz

下一种可能是:

Axis可能已经给了96 Hz,但GMR IK太慢,每秒只能算64帧。

原来如果采集和GMR都放在一个循环中:

读取一帧 执行一次IK 发送一帧 再读取下一帧

那么GMR一旦计算慢,前面的MocapApi就无法及时继续读取。

因此我们把它们拆开:

MocapApi采集线程 ↓ Input FIFO ↓ GMR线程 ↓ Robot FIFO ↓ ZMQ发送线程

这样采集线程只负责尽快拿数据,不负责执行IK。

判断依据是:

source_received gmr_processed input_pending max_input_queue

实测多次得到:

source_received=1864 gmr_processed=1864 input_pending=0

以及:

source_received=1267 gmr_processed=1267 input_pending=0

以及:

source_received=628 gmr_processed=628 input_pending=0

说明:

  • MocapApi实际收到多少帧,GMR就处理多少帧;
  • 没有帧长期留在输入队列;
  • GMR没有把96 Hz降成64 Hz;
  • 程序实际进入GMR之前就只有约64 Hz。

因此排除GMR性能问题。

八、排查ZMQ发送线程是不是跟不上GMR

即使GMR能够处理全部数据,ZMQ线程也可能发送太慢。

所以检查:

motion_generated zmq_sent robot_pending max_robot_queue

正常停止时得到:

motion_generated=zmq_sent robot_pending=0

这说明:

  • GMR生成的动作全部发出;
  • 没有动作遗留在发送队列;
  • 停止时执行了队列排空;
  • ZMQ没有把输入频率降成64 Hz。

九、排查插值器是不是吃掉了帧

我们曾看到:

source_received=628 motion_generated=626

表面看少了2帧。

所以我们检查插值器逻辑。

插值器必须先拿到前后参考帧,才能生成中间动作。程序启动时最初少量帧用于初始化,因此可能固定出现:

输入628 输出626

但如果96→64是插值器造成的,应该看到:

输入约960 输出约640

实际不是。

实际是:

进入插值器之前就只有628帧

因此:

  • 固定少2帧属于初始化;
  • 每秒少约32帧不是插值器造成的;
  • 插值器不是根源。

十、排查代码里是否写死了60 Hz

下一步检查采集程序是否存在:

sleep_for(16ms);

或者:

if(frame_id % 3 == 0) { skip(); }

或者:

只读取队列中最新的一帧

实际采集逻辑是:

  • 有MocapApi事件就立即读取;
  • 没有事件时才短暂休眠约1 ms;
  • 没有固定60 Hz定时器;
  • 没有每隔几帧跳过;
  • 没有LatestBuffer覆盖输入;
  • 没有CONFLATE用于MocapApi输入。

所以排除程序主动限频。

十一、排查-InputFps 96-OutputFps 96

运行命令中使用:

-InputFps 96 -OutputFps 96

一开始可能会误以为:

既然这里写了96,为什么实际不是96?

日志显示:

[Interpolator] input_fps=96 output_fps=96 ratio=1.000

这些参数只告诉插值器如何处理已经收到的数据,不能控制Axis的BVH端口发送多少事件。

大白话就是:

在自己程序里写“我要96帧”,不能命令Axis凭空多发32帧。

所以这两个参数不是原因。

十二、测量MocapApi读取一帧到底多慢

还存在一种可能:

MocapApi读取一帧花15毫秒,所以程序最多只能读取约64帧/秒。

于是我们增加:

avg_avatar_read_ms max_avatar_read_ms

实际结果:

avg_avatar_read_ms≈0.05 ms max_avatar_read_ms通常小于0.5 ms

96 Hz每帧的时间预算约为:

1000 / 96 ≈ 10.42 ms

而我们读取只需要约:

0.05 ms

说明读取函数本身非常快,远没有占满10.42 ms。

所以排除:

骨骼节点读取太慢 MocapApi函数耗时导致64 Hz NoitomFrame转换太慢

十三、排查大量日志是否拖慢程序

当时终端疯狂输出:

[NoitomJump]

一份日志中甚至统计到:

11820条 18737条

控制台打印确实可能很慢,所以我们提出假设:

可能不是MocapApi慢,而是打印几万行日志把采集线程拖慢了。

于是增加:

log_jump_details = false;

关闭每一次跳号的详细打印,只保留周期汇总。

重新测试后:

source_recv_rate仍约60~65 Hz avg_posture_delta仍约1.5 skipped_frames仍约30~38/秒 avg_avatar_read_ms仍约0.05 ms

说明关闭日志没有改变实际频率。

因此排除日志输出造成降频。

十四、排查多线程日志互相干扰

终端中曾出现:

[Metrics]和[NoitomJump]粘在同一行

一开始容易误以为数据处理发生了混乱。

实际上是多个线程同时写控制台:

采集线程正在打印NoitomJump 监控线程正在打印Metrics

文字交叉不等于数据帧交叉,也不等于ZMQ损坏。

关闭逐跳日志后,审计数据仍然一致,证明这只是日志显示问题,不是动作数据问题。

十五、尝试更激进地读取MocapApi事件

我们又提出一个合理怀疑:

MocapApi内部可能一次缓存了多个事件,但程序每次只取一个,导致其余事件被覆盖。

于是进行了两类尝试:

  • 调整PollApplicationNextEvent调用方式;
  • 缓存关节数组和事件读取结果。

但修改后出现:

程序收不到动作 MuJoCo机器人保持站立 程序原生崩溃 退出码-1073741819

回退到原来稳定实现后:

机器人重新跟随抬手 程序恢复正常

同时SDK报告:

cache_events=unsupported

这说明当前MocapApi r70没有提供我们设想的事件缓存能力,那种优化方式与当前SDK并不兼容。

所以我们没有为了“看起来更快”保留危险修改,而是回退稳定方案。

这一步的结论是:

不能通过简单增加poll次数或错误缓存SDK对象来找回不存在的Avatar事件。

十六、排查GenLock是否锁到了较低频率

Axis普通BVH设置中曾开启:

GenLock

我们怀疑它可能让输出跟随其他同步时钟,从96 Hz变成较低频率。

于是关闭GenLock,保持其他设置不变,重新运行同样测试。

结果仍然:

source_recv_rate约60~64 Hz avg_posture_delta约1.5

没有明显变化。

因此排除GenLock是主要原因。

十七、排查7003端口或TCP是否随机掉包

普通BVH使用:

TCP 127.0.0.1:7003

这里有两个重要事实:

1. 这是本机连接

Axis和我们的C++程序运行在同一台电脑上。

127.0.0.1

不经过外部网线,也不经过交换机。

所以:

Axis → MocapApi程序

这一段不是PNLink网线传输。

2. TCP具有有序可靠传输

如果网络来不及,典型表现更可能是:

延迟堆积 读取阻塞 队列增长

而不是长期稳定地只得到约三分之二的应用层事件,同时队列保持0。

再加上多次测试都得到:

约62~64 Hz

比例非常稳定。

因此随机TCP丢包的可能性极低,也无法解释固定接近2/3的比例。

十八、测试高级BVH 7005

我们看到Axis还有:

高级BVH数据广播

因此提出新的假设:

普通BVH 7003可能只输出约64 Hz,高级BVH 7005可能输出完整96 Hz。

为了不破坏已经稳定的7003,我们采用并行验证:

普通BVH:保留7003 高级BVH:另开7005

检查端口:

127.0.0.1:7005处于监听

运行程序连接7005,日志显示:

connected via TCP 127.0.0.1:7005

但20秒内一直是:

posture_index=0 source_recv_rate=0 gmr_rate=0 zmq_send_rate=0 received=0

说明:

  • TCP连接确实建立了;
  • 但7005没有发送当前MocapApi r70能够解析的Avatar事件;
  • 高级BVH不是普通BVH接口的直接替代品;
  • 可能需要专用高级协议或另一套SDK。

因此排除:

把端口改成7005就能直接获得96 Hz

然后恢复稳定的7003,程序重新正常动作。

十九、排查Axis界面有没有输出频率选项

接着检查Axis设置。

1. 工作模式

页面只有:

自动检测 全身 全身带手套 上半身 下半身 手臂 手套

而且是灰色不可修改。

它是穿戴类型识别,不是输出帧率。

2. 设备页面

显示:

帧率(动捕设备):96

但同样是灰色只读。

这说明96由当前硬件自动报告,不是普通BVH广播的可调参数。

3. 普通BVH页面

可以设置:

二进制格式 骨骼模板 旋转顺序 位移 TCP/UDP IP 端口

但没有看到:

Output FPS Broadcast Rate Downsample Every Frame Skip Frame

因此在当前Axis Studio界面内,没有找到把普通BVH输出改成96 Hz的入口。

二十、最终用数学关系确认边界

最有说服力的是固定时长统计。

20秒测试

审计结果:

first_posture_index=615147 last_posture_index=617082 source_received=1267 source_missing_indices=669

内部索引跨度:

617082 - 615147 + 1 = 1936

折算频率:

1936 / 20 = 96.8 Hz

程序实际收到:

1267 / 20 = 63.35 Hz

索引没有出现的数量:

1936 - 1267 = 669

恰好等于:

source_missing_indices=669

10秒复测

结果:

first_posture_index=754158 last_posture_index=755124 source_received=628 source_missing_indices=339

内部索引跨度:

755124 - 754158 + 1 = 967

折算:

967 / 10 = 96.7 Hz 628 / 10 = 62.8 Hz

两次独立测试结果基本相同:

内部索引约96~97 Hz 实际Avatar事件约63 Hz

比例大约为:

63 / 96 ≈ 0.66

非常接近三分之二。

这比“偶尔卡顿”更像稳定的输出策略或两种不同的时钟定义。

二十一、最后是怎么锁定问题边界的

我们把所有计数排成一条线:

Axis内部posture_index跨度:约96 Hz ↓ MocapApi实际Avatar事件:约63 Hz ↓ MotionInput收到:全部记录 ↓ GMR处理:与收到数量相等 ↓ ZMQ发送:与生成数量相等 ↓ MuJoCo接收:与ZMQ发送数量相等 ↓ MuJoCo应用:与接收数量相等

真正发生数量变化的最早边界是:

Axis内部姿态索引 ↓ 普通BVH/MocapApi Avatar事件

进入我们程序之后,数量都能对上。

所以问题不在:

GMR protobuf ZMQ FIFO MuJoCo 日志 线程 本机TCP

而是在:

Axis普通BVH输出策略 或 MocapApi AvatarUpdated事件生成机制 或 posture_index本身的语义

二十二、为什么不能简单说“Axis丢帧”

虽然我们定位到了Axis/MocapApi边界,但仍然要严谨。

posture_index可能表示:

Axis内部每次姿态解算的编号

而BVH接口可能只保证:

按另一个频率发布Avatar事件

如果协议本来就规定内部96 Hz、对外64 Hz,那么索引跳号不是网络丢帧,而是主动降采样。

所以现在最准确的说法不是:

Axis丢了三分之一的数据。

而是:

Axis设备侧姿态索引按约96 Hz增长,但普通BVH/MocapApi只产生约62~64个可读取的Avatar事件每秒。现有程序完整处理了所有实际交付的事件;尚需厂商协议或官方说明确认这是广播降采样,还是posture_index与BVH帧率本来就采用不同定义。

二十三、整个排查过程形成的方法论

我们最终形成了一套可以复用的排查体系。

第一层:先明确现象

不要只说“感觉掉帧” 必须明确: 哪个编号跳了 每秒收到多少 在哪一层看到的

第二层:每层设置计数器

输入多少 处理多少 生成多少 发送多少 接收多少 应用多少

第三层:通过等式定位边界

A=B,说明A到B没丢 B>C,说明问题在B到C

第四层:观察队列

pending持续增长 → 后面处理不过来 pending始终很小 → 上游本来就没给那么多

第五层:测量耗时

不能猜“读取可能慢” 要测出avg_avatar_read_ms

第六层:一次只改变一个变量

我们依次单独测试:

关闭逐帧日志 关闭GenLock 拆分线程 更改MocapApi轮询 切换7005 恢复7003

每次只改一项,才能知道结果由什么造成。

第七层:危险优化必须能回退

MocapApi优化导致崩溃和无数据后,立即回退到稳定实现。

判断标准不是“代码更漂亮”,而是:

机器人是否跟随 计数是否对齐 测试是否通过 是否出现崩溃

第八层:使用固定时长复测

通过10秒、20秒等固定测试,避免不同测试时长无法比较。

第九层:区分“没产生”和“传输丢失”

这是这次最重要的认识:

Axis没有通过接口交付 ≠ ZMQ传输时丢失

程序只能保证:

拿到的全部处理 生成的全部发送 发送的全部接收 接收的全部应用

不能保证获得上游未通过接口公开的数据。

二十四、最终形成的完整问题框架

```mermaid flowchart TD A["发现现象<br/>设备显示96 Hz,但程序约63 Hz"] --> B["给每层增加计数"] B --> C["AuditCore<br/>输入/GMR/生成/发送"] B --> D["AuditViewer<br/>接收/应用/gaps/invalid"] C --> E["检查GMR与FIFO"] D --> F["检查ZMQ与MuJoCo"] E --> G["source_received等于gmr_processed"] F --> H["sent等于received等于applied"] G --> I["排除GMR和队列"] H --> J["排除ZMQ和MuJoCo"] I --> K["测量MocapApi读取耗时"] K --> L["约0.05 ms,排除读取性能"] L --> M["关闭日志、关闭GenLock"] M --> N["频率仍约63 Hz"] N --> O["测试高级BVH 7005"] O --> P["TCP可连,但无Avatar事件"] P --> Q["恢复普通BVH 7003"] Q --> R["检查Axis设备和工作模式"] R --> S["96为只读设备频率,无广播频率选项"] S --> T["固定时长数学复核"] T --> U["问题边界定位到Axis普通BVH/MocapApi"] ```

二十五、一句话总结整个思路

我们不是看到NoitomJump就直接说Axis丢帧,而是:

先给Axis输入、GMR、动作生成、ZMQ发送、ZMQ接收和MuJoCo应用分别计数;再通过FIFO积压、frame_id、CRC、读取耗时和固定时长测试,逐层排除GMR性能、程序限频、日志开销、线程阻塞、ZMQ丢帧、MuJoCo丢帧、GenLock和端口配置;最终发现数量差异在数据进入程序之前就已存在,从而把问题边界定位到Axis普通BVH/MocapApi输出机制,并保留“主动降采样或索引语义不同”两种严谨解释。

最终方法可以浓缩为:

先分层 → 再计数 → 找第一个数量发生变化的位置 → 测性能 → 看队列 → 做单变量实验 → 用固定时长复测 → 区分上游未输出与下游传输丢失 → 最后才下结论