Day10 unitree_G1人形机器人BVH/MocapApi实际输出少于Axis

一、明确Axis、BVH和MocapApi的关系

当前输入链路是:

复制代码
Axis Studio内部姿态解算
        ↓
普通BVH二进制广播
        ↓
TCP 127.0.0.1:7003
        ↓
Noitom MocapApi r70解析
        ↓
AvatarUpdated事件
        ↓
NoitomMocapApiSource

其中:

  • Axis Studio负责接收动捕设备数据并解算人体骨骼;
  • 普通BVH广播通过本机TCP端口7003输出二进制数据;
  • MocapApi是C++ SDK,负责连接7003并解析Axis数据;
  • MocapApi向程序提供AvatarUpdated事件、骨骼位置、旋转和posture_index
  • MocapApi与我们的C++程序之间不是新的网络,而是SDK函数调用。

因此96 Hz到约63 Hz的差异可能发生在:

复制代码
Axis内部姿态解算
    ↓ 可能降采样
Axis普通BVH广播
    ↓ TCP 7003
MocapApi解析和事件生成
    ↓ 可能覆盖
我们的采集线程

二、Axis Studio放在后台容易掉帧。

我们没有立刻把它认定为根因,而是把它作为一个待验证变量。

可能的机制包括:

  • Axis窗口最小化后降低实时更新频率;
  • Windows降低后台进程调度优先级;
  • MuJoCo、录屏或其他程序抢占CPU/GPU;
  • Axis实时捕捉页面失去激活;
  • 系统处于节能模式。

我们形成的验证方法是:

复制代码
测试A:Axis保持前台20秒
测试B:Axis最小化到后台20秒

然后比较:

复制代码
source_received
source_missing_indices
first_posture_index
last_posture_index

思考原则是:

不因为别人说"后台会掉帧"就直接下结论,而是通过两个只改变"前台/后台"条件的实验验证。

今天没有实际执行这组测试,只确定了测试方法。

三、重点分析MocapApi覆盖问题

MocapApi可能只保留最新姿态,旧姿态会被覆盖。

这个解释与当前现象是相符的。

假设Axis内部按96 Hz更新:

复制代码
约每10.42毫秒产生一次姿态

如果原程序按下面方式串行运行:

复制代码
调用MocapApi取一帧
    ↓
执行GMR IK
    ↓
protobuf序列化
    ↓
网络发送
    ↓
回来再取下一帧

假设这一整套处理花15毫秒,那么SDK内部可能已经产生下一帧甚至下下帧。

如果MocapApi内部只保留"最新Avatar状态",就可能出现:

复制代码
取到100
忙着执行GMR
SDK内部出现101
SDK内部又出现102,并覆盖101
程序回来后只能取到102

最终看到:

复制代码
100 → 102

这就是一种合理的"覆盖"机制。

四、确认之前拆线程加FIFO的改造是有效的

当前程序已经改成:

复制代码
MocapApi采集线程
        ↓
Input FIFO
        ↓
GMR线程
        ↓
Robot FIFO
        ↓
ZMQ发送线程

这个改造的目的不是直接让Axis输出96 Hz,而是防止:

复制代码
GMR计算太慢
    ↓
阻塞MocapApi下一次读取

拆分后,采集线程只负责:

复制代码
读取Avatar
转换成NoitomFrame
放入FIFO
立即继续读取

GMR在另一个线程中慢慢处理。

过去的审计结果已经证明:

复制代码
source_received = gmr_processed
input_pending最终为0

说明程序从MocapApi拿到的每一帧,都被GMR处理了。

五、进一步确认FIFO保存的是独立数据

今天直接审查了实际代码,接收线程中使用:

复制代码
input_queue_.push_back(std::move(*frame));

队列类型是:

复制代码
std::deque<NoitomFrame>

也就是说,FIFO中保存的是完整的NoitomFrame对象,不是MocapApi内部Avatar指针。

GMR线程再从队列取出:

复制代码
source_frame = std::move(input_queue_.front());
input_queue_.pop_front();

因此确认:

已经从MocapApi读取并生成的NoitomFrame,不会因为SDK下一次更新而被覆盖。

这一步排除了一个重要风险:

复制代码
FIFO保存SDK内部指针
    ↓
SDK复用内存
    ↓
队列里的旧帧全部变成最新帧

当前代码不是这种错误做法。

六、明确FIFO能够解决什么、不能解决什么

FIFO位于:

复制代码
MocapApi读取之后

所以它能解决:

  • GMR阻塞采集线程;
  • GMR偶尔计算慢;
  • 已读取帧等待处理;
  • 已经生成的NoitomFrame被后续处理覆盖;
  • 采集和IK速度不完全一致。

但它不能解决:

  • Axis本身只广播约63 Hz;
  • MocapApi在返回事件之前已经覆盖中间姿态;
  • SDK只产生约63个AvatarUpdated事件;
  • posture_index本来就是内部时钟编号;
  • Axis后台运行导致广播变慢。

可以用一句大白话概括:

FIFO能保证"拿到手以后不再丢",但不能找回"SDK交给我们之前就已经被覆盖的数据"。

七、审查MocapApi当前轮询代码

当前核心逻辑大致是:

复制代码
PollApplicationNextEvent();

如果没有事件:
    return null

如果不是Avatar事件:
    return null

如果是AvatarUpdated:
    读取Avatar
    生成NoitomFrame
    return frame

外层NoitomMocapApiSource当前还有:

复制代码
if (!frame) {
    sleep_for(1ms);
}

这带来一个潜在问题:

复制代码
遇到空事件或非Avatar事件
    ↓
固定等待1毫秒
    ↓
再调用MocapApi

如果SDK真的只保留最新姿态,这1毫秒就是一个额外的覆盖窗口。

虽然1毫秒不太可能单独解释稳定少约32 Hz,但为了把自己的程序做到最好,这个固定等待值得优化。

七、第一项新改造:sleep改为yield

计划从:

复制代码
if (!frame) {
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

改成:

复制代码
if (!frame) {
    std::this_thread::yield();
}

两者区别:

复制代码
sleep_for(1ms)
= 明确暂停至少约1毫秒
= Windows实际唤醒可能更晚

yield()
= 主动让其他可运行线程先执行
= 通常更快重新获得CPU

这样做的依据是:

  • 采集线程已经独立;
  • 它应该尽快再次轮询SDK;
  • 不应在采集热路径中加入固定等待;
  • 同时使用yield()避免完全空转占满CPU。

这项改造属于:

复制代码
低风险
可回滚
不影响GMR和通信协议

八、第二项新改造:立即排空非Avatar事件

当前程序遇到非Avatar事件后,会返回外层,然后触发1毫秒等待。

更合理的逻辑是:

复制代码
for (;;) {
    poll一个SDK事件;

    if没有任何事件:
        返回空;

    if系统错误:
        抛出异常;

    if不是Avatar事件:
        立即继续poll;

    如果是AvatarUpdated:
        立即深拷贝为NoitomFrame;
        返回这一帧;
}

这样如果SDK队列为:

复制代码
Notify
SensorUpdated
AvatarUpdated

程序不会:

复制代码
读Notify → 等1ms
读SensorUpdated → 再等1ms
读AvatarUpdated

而是:

复制代码
读Notify
立即继续
读SensorUpdated
立即继续
读AvatarUpdated
深拷贝并返回

这样做的依据是:

  • 非Avatar事件不是GMR需要的动作数据;
  • 但不能让这些事件拖慢后续Avatar读取;
  • SDK提供了Error_MoreEvent,说明可能存在多个待取事件;
  • 我们应尽快排空无关事件。

九、为什么没有再次尝试事件数组和Handle缓存

之前曾尝试:

  • 修改PollApplicationNextEvent读取数量;
  • 缓存关节Handle;
  • 使用更激进的SDK事件读取方式。

结果出现:

复制代码
收不到动作
机器人保持站立
程序闪退
退出码-1073741819

回退后程序恢复正常。

所以今天的设计遵循:

不再改变SDK对象生命周期,不猜测事件数组参数,不跨Avatar更新缓存Joint Handle。

当前源码中也明确写着:

复制代码
MocapApi joint handles与当前缓存姿态绑定,
跨AvatarUpdated事件保留是不安全的。

因此这轮只优化:

复制代码
轮询节奏
事件排空
统计证据

不改危险的SDK内存用法。

十、第三项新改造:增加更细的MocapApi审计

当前日志只有:

复制代码
posture_index
avg_posture_delta
skipped_frames
avg_avatar_read_ms
max_avatar_read_ms

它告诉我们"编号有跳",但还不能解释为什么跳。

所以准备增加:

复制代码
poll_calls
avatar_events
empty_polls
other_events
more_event_returns
duplicate_indices
backward_indices
max_poll_gap_ms
max_poll_block_ms

poll_calls

每秒调用PollApplicationNextEvent多少次。

作用:

复制代码
判断我们的线程到底有没有积极轮询

avatar_events

SDK每秒实际交付多少个AvatarUpdated事件。

作用:

复制代码
直接测量MocapApi输出事件频率

empty_polls

SDK回答"当前没有事件"的次数。

如果:

复制代码
poll_calls非常高
empty_polls非常多
avatar_events只有约64

就说明程序一直在问SDK,但SDK只提供约64个Avatar事件。

other_events

每秒收到多少个非Avatar事件。

如果这个数很大,说明之前"非Avatar事件后睡1毫秒"可能确实影响采集。

more_event_returns

SDK返回Error_MoreEvent的次数。

它可以说明SDK是否经常提示:

复制代码
后面还有未读取事件

如果这个数字很高,立即排空策略可能有效。

如果始终为0,说明SDK很少向应用暴露事件积压。

duplicate_indices

连续收到相同posture_index的次数。

它可以检查:

复制代码
SDK是否触发了多个事件,
但事件内容都指向已经被覆盖的最新姿态

backward_indices

姿态编号倒退的次数。

它可以识别:

  • SDK重连;
  • Axis状态重置;
  • 索引回绕;
  • 异常乱序。

max_poll_gap_ms

两次调用SDK之间最长间隔。

如果超过:

复制代码
10.42ms

就有可能错过96 Hz周期。

max_poll_block_ms

单次SDK调用最长阻塞多久。

如果SDK调用始终只需:

复制代码
0.03~0.1ms

但每秒只提供64个Avatar事件,说明瓶颈不在函数执行速度。

十一、今天哪些内容实际改了,哪些还没改

已经存在并确认有效的代码改造

这些是此前已完成、今天重新检查确认的:

  • MocapApi采集线程与GMR线程分离;
  • 中间使用FIFO;
  • FIFO保存独立NoitomFrame
  • GMR与ZMQ发送分离;
  • 分层审计已经存在;
  • ZMQ与MuJoCo无损计数已验证;
  • 逐条NoitomJump日志默认关闭;
  • Axis和代码现在统一使用XYZ。

今天完成的分析和设计

  • 明确"丢帧"重点位于Axis/BVH/MocapApi侧;
  • 分析MocapApi最新姿态覆盖机制;
  • 审查当前pollFrame()实现;
  • 找到空轮询后固定休眠1毫秒;
  • 找到非Avatar事件会返回外层并休眠;
  • 设计立即排空非Avatar事件;
  • 设计新的九项MocapApi审计指标;
  • 确定不再使用危险的事件数组和Handle缓存方案;
  • 确定在8_3使用全新构建目录;
  • 确定7_28必须在8_3独立验证后再删除。

今天尚未写入的改动

由于当前可视化任务仍绑定7_28,以下内容还没有真正落盘:

  • sleep_for(1ms)改为yield()
  • 非Avatar事件内部循环排空;
  • 新增poll和索引审计字段;
  • 新建8_3干净构建目录;
  • 重新编译和测试。

十二、今天的完整思考框架

复制代码
```mermaid
flowchart TD
    A["现象:内部索引约96 Hz<br/>MocapApi约63 Hz"] --> B["重新确认问题边界"]
    B --> C["不是先怀疑ZMQ<br/>重点检查Axis/BVH/MocapApi"]
    C --> D["审查现有线程结构"]
    D --> E["确认采集与GMR已分离"]
    E --> F["确认FIFO保存独立NoitomFrame"]
    F --> G["已拿到的数据不会再被SDK覆盖"]
    G --> H["继续检查MocapApi读取热路径"]
    H --> I["发现无帧后固定sleep 1 ms"]
    H --> J["发现非Avatar事件返回外层"]
    I --> K["计划改为yield"]
    J --> L["计划内部立即排空"]
    K --> M["减少SDK覆盖窗口"]
    L --> M
    M --> N["增加精细轮询审计"]
    N --> O["poll次数"]
    N --> P["Avatar事件数"]
    N --> Q["空轮询/其他事件/MoreEvent"]
    N --> R["重复/倒退索引"]
    N --> S["最大调度/阻塞时间"]
    O --> T["20秒实物对照测试"]
    P --> T
    Q --> T
    R --> T
    S --> T
    T --> U{"结果"}
    U -->|"接近96 Hz"| V["程序轮询优化有效"]
    U -->|"略有提升"| W["程序与SDK均有影响"]
    U -->|"仍约63 Hz"| X["SDK/Axis实际只交付约63个事件"]
```

十三、今天形成的工程逻辑

整个问题可以划成四道边界。

复制代码
第一道:Axis内部
96 Hz姿态解算和posture_index

第二道:Axis输出
普通BVH二进制TCP 7003

第三道:MocapApi
解析BVH并产生AvatarUpdated事件

第四道:我们的程序
深拷贝 → FIFO → GMR → ZMQ → MuJoCo

我们已经能够保证第四道:

复制代码
MocapApi实际交给我们的每一帧
    ↓
都被复制
    ↓
都进入FIFO
    ↓
都被GMR处理
    ↓
都被ZMQ发送
    ↓
都被MuJoCo接收和应用

今天继续优化的是第三道与第四道之间的缝隙:

复制代码
SDK有事件
    ↓
程序是否足够快地poll
    ↓
是否因无关事件或sleep扩大覆盖窗口

二十一、一句话汇报版本

今天我主要继续分析MocapApi可能覆盖姿态的问题。我检查了现有代码,确认采集和GMR已经分线程,中间FIFO保存的是独立的NoitomFrame,所以数据只要被程序取出来,后面不会再被覆盖。接着发现当前MocapApi空轮询或遇到非Avatar事件后会回到外层固定休眠1毫秒,这可能扩大SDK内部覆盖窗口。因此准备把固定休眠改成线程让出,并在SDK内部连续排空非Avatar事件,同时增加poll次数、Avatar事件数、空轮询、MoreEvent、重复索引和最大轮询间隔等统计。这样既能尽量减少程序侧漏取,也能进一步证明约63 Hz到底是不是SDK实际交付上限。今天还检查到8_3中的旧CMake缓存仍带有7_28绝对路径,因此后续必须在8_3重新建立干净构建,验证独立运行后再删除7_28。

相关推荐
二十雨辰2 小时前
[Java]-Spring面试题
java·开发语言
萧瑟余晖2 小时前
Java深入解析篇二十二之虚拟线程
java·开发语言
油丶酸萝卜别吃3 小时前
Java 集合类全景介绍
java·开发语言
Iruoyaoxh3 小时前
类和对象~
开发语言·c++
程序喵大人3 小时前
【C++进阶】STL算法与函数对象 - 04 find、count和any_of把查询写成意图
开发语言·c++·算法
Livia要学习3 小时前
Python装饰器
开发语言·python
一直都在5723 小时前
LangChain4j精讲
开发语言·人工智能
吹什么轩4 小时前
c++复习:c++11:lambda表达式
开发语言·c++
不会代码的小猴4 小时前
标准模板库(STL)
开发语言·c++·笔记·算法