建立一个"Axis 原始输出---MocapApi---我们的程序"三层同步审计。只有这样才能判定缺失到底发生在哪一层。
基于目前证据,我的判断是:
- "当前程序收到的每一个 SDK Avatar 事件都没有再丢":证据充分。
- "MocapApi 是否漏交事件":尚未证明,也没有排除。
- "Axis 是否本来就没有发出该姿态":尚未证明。
- "UDP/WiFi 是否丢了报文":尚未证明。
- "多个 SDK 事件是否最终读取到同一个最新 Avatar 状态":这是目前最值得怀疑、但还没有彻底验证的隐患。
- 使用 r70、非 Cache、UDP、普通 Windows、WiFi 时,无法给出数学意义上的"永不丢一帧"保证。
- 工程上可以通过正确调用、原始流旁路、TCP、冗余和缺帧显式处理,把风险降到很低,但必须先定位。
一、第一性原理:一帧到底经过了什么
实际链路不是简单的"MocapApi → 程序":
```mermaid
flowchart LR
A["人体动作"] --> B["无线惯性传感器"]
B --> C["接收器 / Transceiver"]
C --> D["Axis Studio融合与骨架求解"]
D --> E["Axis生成第 N 帧姿态"]
E --> F["Axis BVH UDP/TCP广播"]
F --> G["Windows网络栈"]
G --> H["MocapApi内部接收与解析"]
H --> I["MocapApi事件队列或最新状态"]
I --> J["PollApplicationNextEvent"]
J --> K["通过Avatar句柄读取关节"]
K --> L["深拷贝NoitomFrame"]
L --> M["采集FIFO"]
M --> N["后续处理"]
```
诺亦腾官方说明也明确:MocapApi 不直接与传感器通信,它接收 Axis Studio 输出的 UDP/TCP socket 流;传感器融合和骨架求解在 Axis 中完成。Noitom 官方 GitHub
因此,观察到:
100 → 101 → 103
只能说明应用最终没看到 102,不能立刻断定是哪一层丢的。至少有以下可能:
- Axis 根本没有生成姿态 102。
- Axis 生成了,但没有广播。
- UDP 数据报在系统或网络层丢失。
- MocapApi 收到数据报,但解析失败。
- MocapApi 内部只保留最新状态,102 被 103 覆盖。
- 事件存在,但我们没有正确取完。
- 我们取到了两个事件,但两个事件的 Avatar 句柄都读取成最新的 103。
- 我们读取到了 102,后面的 FIFO/GMR 丢掉了。
现在的六层计数相等已经基本排除了第 8 种,但没有排除第 1~7 种。
二、技术人员的判断哪里是对的
1. 旧代码的参数确实调用错过
PollApplicationNextEvent 的第二个参数是事件数组容量/数量,不是字节数。
旧代码如果是:
MCPEvent_t event;
uint32_t count = sizeof(MCPEvent_t);
PollApplicationNextEvent(&event, &count, app);
就相当于:
实际只准备了一个房间,却告诉 SDK 后面准备了几十个房间。
这可能造成越界写、事件破坏或未定义行为。这个错误必须修复,且已经不是争议点。
2. 非 Cache 模式必须正确处理批量事件
诺亦腾官方文档明确要求:
pEvent中每一个MCPEvent_t.size都必须初始化;- 非 Cache 模式先查询所需事件数量;
- 分配对应数量的事件数组;
- 再次调用取出事件;
- 缓冲区不足时重新取。Noitom MocapApi 官方说明,第 4--5 页
当前源代码已经实现了官方"两段式"分支:
- 查询
required_count - 创建对应数组
- 初始化每个
event.size - 批量读取
- 处理
Error_InsufficientBuffer - 将一批中的 Avatar 事件逐个深拷贝
对应位置是 noitom_client.cpp (line 393)(/C:/Users/RX01296/Desktop/全身控制8_3/gmr-master_PN_xsenscpp最终版/gmr-master_PN/cpp_noitom_g1/src/noitom_client.cpp:393)。
3. 但实际运行默认走的是预分配模式
这里必须质疑当前运行路径。
当前主程序和 bridge 都把:
use_preallocated_poll = true;
设为了真。因此实际运行默认走 256 个事件的预分配批量读取,而不是官方文档展示的"查询数量---按数量读取"分支。
预分配模式不是必然错误,因为代码:
- 初始化了每个事件的
size; - 提供了真实数组容量;
- 遇到
Error_InsufficientBuffer会扩大数组; - 没有再把字节数当事件数。
但它不是官方文档示例的原样实现。因此,"调用方式已经百分之百被官方背书"这个说法不成立。它仍然需要与官方两段式方法严格 A/B。
三、当前代码中最危险的隐藏问题:事件不一定等于历史帧快照
官方对事件和句柄的描述非常关键:
- 事件携带被更新对象的 handle;
- handle 是 MocapApi 内部对象的索引;
- 真正的数据需要通过接口和 handle 再去读取。Noitom 官方对象模型
也就是说,事件可能只是:
"Avatar对象更新了"
+ Avatar句柄
而不一定包含该时刻完整、不可变的骨架快照。
假设 SDK 内部这样工作:
事件1:AvatarHandle=5,实际对应帧100
事件2:AvatarHandle=5,实际对应帧101
事件3:AvatarHandle=5,实际对应帧102
AvatarHandle=5背后的当前状态已经更新到102
应用批量拿到三个事件后依次调用:
readAvatarFrame(handle=5);
readAvatarFrame(handle=5);
readAvatarFrame(handle=5);
可能读到:
102、102、102
而不是:
100、101、102
这种情况下:
sdk_avatar_events = 3
frames_deep_copied = 3
依然完全相等,但前两帧已经在 SDK 对象模型内部被"最新状态覆盖"。
这正是以下统计的意义:
multi_avatar_batches
batch_duplicate_posture_reads
duplicate_indices
如果出现:
multi_avatar_batches > 0
batch_duplicate_posture_reads > 0
那么问题极可能不是我们的 FIFO,而是:
非 Cache 模式的一批 AvatarUpdated 事件并不保存各自独立历史姿态,多个事件读取了同一个最新对象状态。
这个问题不能靠"深拷贝再快一点"解决,因为深拷贝开始前,旧姿态可能已经消失。
需要诺亦腾明确回答:
- 一个
AvatarUpdated事件是否绑定不可变的历史姿态? - 还是只通知某个 Avatar 对象已经更新?
- 多个事件带相同 Avatar handle 时,getter 如何定位各自帧?
- 非 Cache 模式内部保存多少帧?
- 队列满时丢最旧还是丢最新?
GetAvatarPostureIndex()返回事件对应索引,还是对象当前最新索引?
官方公开文档没有充分说明这几个关键语义。
四、关于 Cache 模式
r70 头文件包含:
EnableApplicationCacheEvents
DisableApplicationCacheEvents
ApplicationCacheEventsIsEnabled
但实际 DLL 返回 Error_NotSupported,所以"头文件声明了"不等于"r70 DLL 实现了"。
如果新版 SDK 真正支持 Cache,理论上它可能让 SDK 为事件保留对应的历史数据,从而避免同一 Avatar 对象不断覆盖。但这必须通过下面的测试证明:
同一批多个Avatar事件
→ 每次读取GetAvatarPostureIndex
→ 是否得到连续且不同的索引
不能仅凭 EnableApplicationCacheEvents() 返回成功就认为不会覆盖。
另外,当前代码还有一个潜在缺陷:cache_events_enabled_ 的赋值依赖 log_connection_info 条件。如果未来新版 SDK 成功启用 Cache,但关闭连接日志,程序可能仍按非 Cache 分支运行。r70 因为明确不支持 Cache,当前测试暂时不受这个缺陷影响,但升级 SDK 前必须修正。
五、20 次结果重新解释
已有结果:
总理论帧数:63040
总缺失:24
帧缺失率:0.0381%
完整运行:6/20
出现缺失:14/20
如果粗略假定每帧独立,单轮 3152 帧保持完整的概率约为:
(1 - 0.000381)^3152 ≈ 30.1%
这几乎正好对应实际的 6/20 = 30%。
这意味着该缺失率对你这种约一分钟的运行长度来说,不是"偶尔极端异常",而是足以导致大多数运行至少缺一次。
换算:
平均约每2627帧缺1帧
50 Hz下约每52.5秒缺1帧
10分钟期望约11.4帧
1小时期望约68.6帧
因此当前版本不适合宣称"逐帧可靠"。
但六层计数相等证明的是:
SDK 已经交给程序的 Avatar 事件,都成功进入了深拷贝、管道及后续处理。
它不能证明:
Axis 生成的每一帧都被 MocapApi 交了出来。
六、能否实现真正的零丢帧
需要区分三个目标。
目标 A:测试中观察不到缺帧
有较高概率实现。通过正确调用、换 TCP、降低争用、升级 SDK、增加冗余,可能做到数小时甚至数十小时没有观察到缺失。
目标 B:将缺失率降到工程上足够低
可以实现,但需要定义数字,例如:
连续运行8小时
总姿态帧数约1,440,000
缺失0
重复0
倒序0
端到端最大延迟小于规定值
如果观察到 0 次失败,根据常用的"rule of three",95% 置信上界大约为:
p < 3 / N
例如想证明单帧缺失概率低于 10^-6,至少需要约 300 万帧零缺失,即 50 Hz 下约 16.7 小时。实际还应做多天、不同干扰条件测试。
目标 C:数学意义上永不缺帧
在以下条件下无法保证:
WiFi
+ UDP
+ 普通Windows调度
+ 闭源MocapApi
+ 无发送端重传
+ 无独立冗余链路
UDP 标准明确不保证交付,也不保证重复保护;需要可靠有序交付时应使用 TCP。RFC 768
而且即使改成 TCP,也只能保证 Axis 到 MocapApi 的字节流可靠、有序;不能保证:
- 传感器到 Axis 没丢;
- Axis 每次都生成姿态;
- Axis 内部没有覆盖;
- 程序永远不宕机;
- 延迟永远小于机器人期限。
TCP通过序列号、检测和重传提供可靠有序字节流,但丢包时可能等待重传,形成延迟尖峰。RFC 9293
所以真正可以承诺的应是:
在给定硬件、网络、负载和持续时间下,达到明确的缺帧率与最大延迟指标。
而不是无限时间的绝对零丢帧。
七、所有主要解决方案及对抗性评价
| 方案 | 能解决什么 | 主要质疑 | 优先级 |
|---|---|---|---|
| 官方非 Cache 两段式 Poll | 排除事件数组容量、初始化和取批错误 | 仍可能只读到最新 Avatar 状态 | 最高 |
| 预分配大事件数组 | 减少动态分配和 count/fetch 竞态 | 不完全等同官方示例;仍有对象覆盖风险 | 高,需 A/B |
| 收到一批后立即返回 | 减少持续空 Poll 对 SDK 线程的竞争 | 可能不能及时排空其他事件 | 高,需 A/B |
| 持续 Poll 到空 | 尽快排空 SDK 队列 | 高频空 Poll、最高优先级线程可能抢占 SDK 内部接收线程 | 已证明当前配置不足 |
RegisterEventHandler 回调 |
避免用户态疯狂轮询 | 回调可能就在 SDK 接收线程;在回调中读取全部关节可能反而阻塞 SDK | 仅实验 |
| SDK Cache 模式 | 理论上保存事件历史 | r70 不支持;新版具体语义未知 | 升级后重点测试 |
| 升级 MocapApi/Axis | 可能获得 Cache、bug 修复 | ABI、姿态格式、许可证和兼容性风险 | 高 |
| TCP | Axis→客户端可靠有序,避免 UDP 永久丢包 | 丢包时增加延迟;不能修复 Axis 上游缺失 | 很高 |
| 原始 BVH UDP 旁路解析 | 绕过 MocapApi,直接掌握每个数据报 | 需要正确解析协议;UDP仍可能丢 | 诊断价值最高 |
| Wireshark/Npcap原始抓包 | 判定数据报是否到达操作系统 | 抓到包不等于 MocapApi成功消费;抓包自身也可能丢 | 必须做 |
| Axis 本地录制作为真值 | 判断 Axis 是否生成完整姿态 | 录制路径和广播路径可能不是同一内部队列 | 必须同时做 |
| 双 UDP 端口合并 | 一路缺失时用另一路补齐 | 如果两个端口共享 Axis 同一发送循环,缺失会高度相关 | 条件性 |
| UDP+TCP 双路合并 | 两种传输机制更独立 | 实现复杂,时间戳/帧号匹配必须可靠 | 很有潜力 |
| 两个独立无线/采集系统 | 真正增加上游独立性 | 成本高、两套姿态需要对齐 | 高可靠场景 |
| WiFi 7 MLO/FRER | 多链路复制、去重,降低单链路丢失 | 需要端到端设备支持;相关故障仍存在;不是现有 Axis 一键功能 | 中长期 |
| 扩大 socket 接收缓冲 | 缓解应用调度不及时造成的内核丢包 | MocapApi未公开 socket 句柄,可能无法直接设置 | 需供应商支持 |
| CPU affinity/合理优先级 | 减少采集线程被其他任务打断 | 最高优先级忙轮询可能饿死 SDK 内部线程 | 必须 A/B |
| 不在热路径打印/分配 | 减小消费时延 | 当前 FIFO 深度只有2,说明它可能不是主因 | 保留 |
| 缺帧时 hold/rebuffer | 防止机器人时间轴持续错位 | 不能恢复真实丢失姿态;会有短暂停顿 | 必须作为安全兜底 |
| 插值 | 补齐时间长度 | 用户目前不希望插值,而且不是真实原始帧 | 暂不采用 |
| 前向纠错/FEC | 无重传恢复部分丢包 | 必须发送端配合,Axis现有协议大概率不支持 | 供应商级改造 |
2025 年公开研究表明,把 WiFi 7 多链路和 IEEE 802.1CB FRER 结合,能够提高交付率并约束延迟,但代价是信道效率下降;它依然是概率性可靠性提升,不是无限条件下的绝对零丢失。Ergenç 等,Computer Communications 2026 / 2025 在线发表
工业网络中成熟的思路也是"复制---独立路径传输---按序号消重",例如 IEEE 802.1CB FRER 和 RFC 7198 RTP 双流。但它们有效的核心前提是路径故障尽量不相关。两个 Axis 端口如果共享同一数据源和发送线程,就可能只是"同一份缺失复制两遍"。
八、我建议的决定性实验
不要先继续做 20 次单一程序测试。下一步必须同时取得三份证据:
```mermaid
flowchart TB
A["Axis生成/本地录制"] --> B["原始UDP/TCP数据"]
B --> C["Wireshark/Npcap抓包"]
B --> D["MocapApi"]
D --> E["Avatar事件及posture_index"]
E --> F["NoitomFrame FIFO"]
A -.比较帧号和时间.-> C
C -.比较报文序号.-> E
E -.比较深拷贝序号.-> F
```
实验 1:建立 Axis 真值
同时执行:
- Axis 本地录制动作。
- 开启 BVH 广播。
- Wireshark/Npcap 抓取目标端口。
- MocapApi 审计程序记录:
event.fTimestampposture_index- PTP time
- 每批事件数量
- 每批 Avatar 事件数
- 每批读取到的所有 posture index
- 缺失、重复、倒序的完整列表。
如果 Axis 记录本身缺号,问题在传感器/Axis,不在 SDK 调用。
如果 Axis 完整、原始 UDP 缺号,问题在 Axis 广播、网络栈或 WiFi。
如果原始 UDP 完整、MocapApi 缺号,才能确认 MocapApi/调用方式有问题。
实验 2:专门验证"最新状态覆盖"
每次批量取到事件后记录:
batch_event_count
avatar_event_count
每个事件的avatar_handle
每次getter返回的posture_index
每次getter返回的PTP time
关键判据:
一批有3个Avatar事件
读取结果却是 [102,102,102]
一旦发生,就证明"多事件句柄读取成最新状态"。这种情况不能靠下游 FIFO 修复。
实验 3:四组随机 A/B
只修改一个变量,运行顺序随机化:
- 官方两段式 + 读完一批即返回。
- 预分配批量 + 读完一批即返回。
- 官方两段式 + 持续排空到空。
- 预分配批量 + 持续排空到空。
每组至少 20 次。不要同时修改线程优先级、日志和传输模式,否则无法归因。
实验 4:线程争用 A/B
比较:
Normal
Above Normal
Highest
我特别质疑 Highest + yield + 高频空Poll。
yield() 只表示当前线程让出一次执行机会,不保证 SDK 内部较低优先级线程立即获得 CPU。如果采集线程优先级高于 MocapApi 内部接收线程,它可能反过来降低可靠性。
重点关联:
缺帧发生前后的poll_calls
empty_polls
max_poll_block_ms
CPU占用
SDK内部接收线程调度
实验 5:UDP 与 TCP
使用相同动作、相同帧率、相同程序分别测试 UDP 和 TCP。
判定:
- TCP 完整、UDP 缺失:网络/UDP接收路径是主要原因。
- TCP 与 UDP 缺相同索引:Axis 上游或共同 SDK 层更可疑。
- 原始流完整但两者在 MocapApi 后缺失:SDK内部交付最可疑。
实验 6:双路缺失集合相关性
记录:
missing_A
missing_B
intersection
union
真正有价值的不是"两路总共丢多少",而是:
P(B也丢 | A已丢)
如果 A 丢失时 B 几乎总是也丢,双路没有意义。如果交集接近零,按 posture_index 合并很有价值。
九、对现有工程的最终审查意见
已经做对的
- 采集线程和后续处理分离。
- FIFO 保存独立
NoitomFrame。 - Avatar 数据立即深拷贝。
- 非 Avatar 事件不再额外等待。
- 无事件使用
yield()。 - 事件数组不再把字节数当容量。
- 初始化每个
MCPEvent_t.size。 - 有完整的全链路计数。
- 有姿态复制前后 index 检查。
- 下游没有证据显示继续丢帧。
仍然没有证明的
- Axis 是否生成每个缺失 index。
- 原始 socket 流是否包含每个缺失 index。
AvatarUpdated是否对应不可变历史帧。- r70 非 Cache 模式是否只保存最新 Avatar 状态。
- 持续排空是否与 SDK 内部线程争用。
- 预分配模式和官方两段式哪一个在 r70 上更可靠。
- 回调方式是否优于轮询。
- SDK 更新是否能真正启用 Cache 并保存独立姿态。
当前最可能的四类原因排序
在没有原始流证据前,我给出的暂定排序是:
- MocapApi 非 Cache 对象覆盖或内部事件交付机制:较高可疑。
- UDP/WiFi/Windows接收路径偶发丢包:较高可疑。
- 高优先级持续空 Poll 与 SDK 内部线程竞争:中高可疑。
- Axis 源头本身没有产生/广播某些姿态:中等可疑。
目前没有足够依据把任何一项定为唯一原因。
最终建议
最值得做的不是再改 FIFO,而是按下面顺序推进:
- 先完成"Axis录制 + 原始数据抓包 + MocapApi日志"的同步三层审计。
- 默认运行路径临时切到完全遵循官方说明的两段式 Poll,且每批深拷贝后立即返回,不强制持续 Poll 到空。
- 保留当前预分配版本作为唯一变量进行严格 A/B。
- 对
multi_avatar_batches和batch_duplicate_posture_reads做逐批详细记录。 - 测试 TCP,与 UDP 对照。
- 向诺亦腾索要事件/handle/Cache 的精确生命周期契约。
- 若确认 MocapApi 内部覆盖,优先尝试新版 SDK Cache;否则考虑直接解析原始 BVH 流。
- 真正上机器人前,即使采集达到长期零缺失,也必须加入"发现帧号跳变后暂停推进、重新积累 buffer、重新同步"的安全策略,避免机器人静默累积时间轴误差。
核心判断可以浓缩为一句话:
MocapApi 调用方式曾经确实有问题,当前已经修复了明显的容量错误,但还没有证明 r70 非 Cache 模式能够逐事件保存历史姿态;在查清"原始数据是否存在"和"同 handle 是否只读最新状态"之前,继续优化 poll 频率无法证明能解决零缺帧。