机器人基础知识 · 第 6 篇
阅读准备:知道坐标变换与基本速度关系即可。本文使用离线的采样和排队模型解释时间问题,不连接机器人,不报告八界实机帧率或延迟性能。

一个感知节点每秒都在输出结果,日志没有报错,目标的位置也看起来合理。但机器人运动起来以后,总像是在追赶过去的目标。

问题可能不在识别算法,而在一个经常被省略的问题:这份数据描述的是哪个时刻的世界?

传感器采集、消息到达、算法完成、控制器使用,是四个不同的事件。更高的输出频率不一定意味着更短的延迟;刚刚收到的数据也可能已经过时。

前几篇补齐了空间坐标、运动学与不确定性。这一篇把时间补回来:一个位置除了坐标值,还需要坐标系、采集时刻和可比较的时钟语义,才能被正确使用。

在这里插入图片描述

图 1:基于八界 URDF 与作者提供的外观参考制作的概念示意,非实机拍摄。机器人姿态由模型底图约束,右侧画面切片和时间流表示采样与延迟,不是实际界面或性能记录。

1. 一个数据包,至少涉及四个时刻

假设所有时间已经映射到同一个时钟域,某一帧经历了以下过程:

采集时刻      10.000 s
接收时刻      10.080 s
处理完成      10.140 s
控制使用      10.180 s

控制器使用它时,数据年龄为:

age = t_use − t_capture = 180 ms

其中采集到接收占 80 ms,接收到处理完成占 60 ms,处理完成到使用又占了 40 ms。这个简单例子把传输、计算和调度分开列出;真实系统中,每一段可能还包含排队、拷贝或设备内部缓冲。

在这里插入图片描述

图 2:使用时年龄应从采集时刻算起。若只统计接收后的耗时,本例会把 180 ms 误记为 100 ms。

这几个时刻服务于不同问题:接收时间帮助诊断通信;处理耗时帮助诊断算法;使用时年龄帮助判断信息是否还适合当前动作。不能用某一个方便取得的时间戳替代所有语义。

相机的“采集时刻”也不是天然统一的:它可能表示曝光开始、中点或设备定义的其他事件;滚动快门的不同图像行甚至不在同一时刻曝光。需要以设备与驱动定义为准,不能把整幅图想当然地当作瞬时快照。

转发消息时,用“当前时间”覆盖原始采集时间,虽然会让日志里的延迟变小,却抹掉了数据真正的年龄。更合理的做法是保留采集时间,另记接收、处理或发布时刻。

2. 两个时间戳可以相减,前提是什么

相机给出 123.4 s,主机给出 987.6 s,二者并不因为单位都是秒就可直接相减。它们可能分别从相机上电和主机启动开始计时,也可能一个是 UTC、一个是仿真时间。

即使两个时钟曾经对齐,也可能存在频率漂移。一个局部近似模型是:

t_host ≈ a · t_sensor + b

b 表示时钟偏移,a 表示速率关系。只修正一次偏移,不能自动消除之后持续累积的漂移。同步方法还会留下残余误差,应将其与通信延迟分开评估。

在 ROS 2 中,系统时间、稳态时间和 ROS 时间有不同用途。ROS 时间可以来自仿真 /clock,可能暂停、加速或向后跳变;不能假设每次调用“现在”都比上次更大。[1]

对本机执行耗时或超时,通常使用单调时钟,例如 Python 的 time.monotonic_ns()。它不受系统时钟校时影响,但参考起点未定义,适合比较同一时钟中的差值。[2]

因此应当区分两个动作:

  • 判断一次本机计算用了多久,用同一单调时钟的起止差值。
  • 判断传感器数据有多旧,先验证传感器时间到当前时间的映射,再计算年龄。

不能把相机时间戳与 monotonic_ns() 直接相减。也不能仅凭两者都叫“系统时间”,就认定不同机器已经完成同步。

回放数据、设备重启或仿真时钟回跳后,旧缓存和序号可能失去原有含义。工程上应有明确的时钟身份或纪元管理,识别这类变化后重建对应缓存;不能把负年龄一律当作“特别新鲜”。

3. 帧率高,不代表没有混叠

采样频率回答的是“每秒观察多少次”。延迟回答的是“看到的内容离现在有多远”。这是两个不同维度。

先看采样本身。设连续信号为 17 Hz 的余弦,用 20 Hz 采样:

t_n = n/20
x[n] = cos(2π · 17 · n/20)

它与下面这个 3 Hz 信号在每一个采样点上完全一致:

cos(2π · 17 · n/20)
= cos(2πn − 2π · 3 · n/20)
= cos(2π · 3 · n/20)

在这里插入图片描述

图 3:蓝色与橙色是两个不同的连续信号,黑点却完全重合。仅靠这些黑点,后处理无法判断原来是哪条曲线。

这就是混叠。对于普通基带采样,要从采样恢复带限信号,采样频率需高于最高相关频率的两倍,并满足理想采样定理的相应条件。实际系统还需要留出滤波器过渡带,常在采样前通过抗混叠滤波限制带宽。[3]

机器人中的振动、轮速变化、快速转动或图像中的周期运动,都可能碰到类似问题。不过,不能把“20 FPS”机械地解释成“任意机器人动作低于 10 Hz 就一定看得清”:曝光、空间纹理、特征跟踪方式和不规则采样同样影响观测。

这一节要保留的结论是:时间戳能告诉我们何时观察,却不能凭空补回采样已经丢失的信息。 插值可以在模型假设下补点,不能把原本不可区分的两个信号自动分开。

4. 坐标变换写对了,时间用错了仍然会偏

设一个固定路标在世界 x=2 m 的位置。机器人在采集时位于世界原点,因此相机在简化的一维模型中看到路标距离为 2 m。

机器人以 0.4 m/s 向前移动,150 ms 后使用这份观测。此时机器人已经前进:

Δx = vΔt = 0.4 × 0.15 = 0.06 m

如果把旧观测 2 m 配给当前机器人位置 0.06 m,就会算出世界位置 2.06 m。路标没动,是我们把两个时刻拼在了一起。

正确的关系应当是:

p_world(t_capture)
    = T_world_camera(t_capture) · p_camera(t_capture)

如果目标还需要表达在当前底盘系,并且假设路标在世界中静止,则可以继续写为:

p_base(t_use)
    = T_world_base(t_use)⁻¹
      · T_world_camera(t_capture)
      · p_camera(t_capture)

这段变换明确使用了两个时刻和一个稳定的中间参考系。若目标本身也在运动,就还需要运动模型或重新观测;仅更新机器人的变换无法补偿目标运动。

纯转动也会造成位置偏差。对位于旋转平面内、距离旋转中心 r 的点,时间差 Δt 引起的精确弦长误差为:

e_rot = 2r · sin(|ω|Δt/2)
小角度时约为 r|ω|Δt

设 r=1.2 m、角速度 0.5 rad/s、错配 120 ms,位置误差约为 72 mm。
在这里插入图片描述

图 4:蓝线对应 0.4 m/s 平移,橙线对应半径 1.2 m、角速度 0.5 rad/s 的纯转动。两条线分别描述两种设定,不是相加后的实机误差。

tf2 保存随时间变化的坐标变换,支持查询指定时刻的变换。它的“最新可用”与“观测采集时刻”不是同一个请求;查询当前时刻还可能因为变换尚未传到本地而遇到外推错误。[4]

等待变换到达、查询观测时刻的历史变换、改用新观测,是三种不同策略。简单捕获异常后无条件改查“最新”,可能让错误消失在日志里,却把时间错配保留在结果中。

5. 同步允许相差 20 ms,意味着什么

多传感器系统常需要把图像、深度、关节状态或惯性数据配在一起。精确同步要求时间戳满足严格关系;近似同步允许时间戳在一定窗口内配对。ROS 的 ApproximateTimeSynchronizer 就提供了队列与时间容差等参数。[5]

这里要区分匹配规则与物理补偿。同步器把相近的消息交给同一个回调,并不意味着这两份数据真的在同一时刻采集,更不代表已经补偿了运动。

如果允许 20 ms 的差异,目标以 0.5 m/s 相对移动,仅平移就可能相差 10 mm。这个差异是否可接受,要由任务的空间误差预算决定。

增大容差可能更容易配出消息对,但也接受了更大的时刻差;增大队列可能等到更合适的数据,也可能增加等待。它们都不是无代价的“稳定性参数”。

若两个设备的时钟本来就错开 100 ms,近似同步还可能稳定地配错。正确顺序应是先核对时间基准与时间戳语义,再设置匹配窗口,最后评估是否需要插值或运动补偿。

6. 每帧都处理,为什么结果越来越旧

现在把网络和同步问题暂时拿掉,只看一个处理队列。

输入频率为 30 Hz,每隔约 33.3 ms 来一帧;算法处理一帧需要 60 ms,处理能力只有约 16.7 帧/秒。如果坚持按 FIFO 处理每一帧,长期输入速率大于处理速率,积压必然增长。

在这个确定性模型中,第 k 帧从 0 开始编号,没有其他延迟:

采集时刻 t_k = k/30
完成时刻 f_k = (k+1)×0.060
结果年龄 age_k = 0.060 + k×(0.060−1/30)

每处理下一帧,结果年龄增加约 26.7 ms。第 30 帧采集于 0.9667 s,却要到 1.8000 s 才处理完,年龄已经达到 833.3 ms。

下面几行就能复现:

rate, service = 30, 0.060
finish = 0.0
ages = []
for k in range(30):
    capture = k / rate
    start = max(finish, capture)
    finish = start + service
    ages.append(finish - capture)
print(f"首帧年龄:{ages[0]*1000:.1f} ms")
print(f"末帧年龄:{ages[-1]*1000:.1f} ms")

如果任务更关心当前状态,可以让等待区只保留最新一帧:算法继续处理已经开始的帧;运行期间新来的帧覆盖等待帧;处理完成后,再取等待区中最新的一帧。
在这里插入图片描述

图 5:相同 30 帧输入,FIFO 全部处理,最后结果年龄为 833.3 ms;最新帧策略处理 18 帧、丢弃 12 帧。两种策略使用相同的 60 ms 单帧处理时间。

在持续周期输入阶段,最新帧策略取到的待处理帧通常不到一个采样周期旧,完成时年龄小于“处理时间 + 采样周期”。本例这一阶段实际最大值为 86.7 ms。

但这个边界有前提:采样仍在持续。 本例只采集 30 帧后停止,最后一帧可能在等待区多留一段时间,最终年龄为 113.3 ms。随文模拟保留了这个停止后的排空过程,没有只截取好看的稳定阶段。

这不是在推荐所有模块都丢帧。离线重建、数据归档或某些需要连续积分的算法,可能不能接受同样的丢弃策略;一张重要事件图像也可能必须保留。应根据任务决定保存什么,而不是笼统选择“队列越大越安全”或“永远只看最新”。

最新帧策略也不能缩短已经开始的那次 60 ms 计算。如果任务最多允许 50 ms 数据年龄,仅调整等待队列仍然不够,需要降低计算耗时、改变处理方案或在明确模型下进行状态预测。

7. 数据有效期应当从误差预算倒推

“超过 100 ms 就丢弃”很容易实现,但 100 ms 从哪里来?

若任务允许由时间误差单独引入的位置偏移不超过 15 mm,并认为相关运动速度上界是 0.3 m/s,在只考虑平移的简化条件下:

最大年龄 Δt_max ≤ 0.015 / 0.3 = 0.050 s

也就是 50 ms。这是一个误差预算推导,不是对所有机器人适用的固定阈值。

如果还存在旋转,小角度近似下可以使用更保守的预算关系:

e_time ≲ (v_max + r·ω_max) · Δt

其中 v_max 与 ω_max 需要是与任务相关的运动上界,r 是目标相对旋转中心的距离。感知噪声、标定误差和控制误差还会消耗其他预算,不能把全部容差都留给时间延迟。

一份数据在算法入口新鲜,也可能在排队、规划和执行等待之后过期。因此检查应放在真正消费它的位置,必要时在下发动作前再次评估。接口返回“收到命令”也不意味着动作已经在这一时刻执行。

下面是一个简化的年龄检查函数。调用方必须先确认时间已映射到相同的时钟身份与纪元;这个函数不会自己完成时钟同步。

def age_status(capture_ns, now_ns, clock_id,
               expected_clock, max_age_ns):
    if max_age_ns < 0:
        raise ValueError("有效期不能为负")
    if clock_id != expected_clock:
        return "clock_mismatch"
    age = now_ns - capture_ns
    if age < 0:
        return "future_stamp"
    if age > max_age_ns:
        return "stale"
    return "fresh"

print(age_status(920_000_000, 1_000_000_000,
                 "sync-A:1", "sync-A:1", 50_000_000))

结果是 stale:年龄为 80 ms,超过 50 ms 预算。这里使用整数纳秒避免大时间戳的浮点精度损失,并对未来时间戳单独分类;真实系统若允许有限的同步误差,需要显式定义容忍区间及处理策略。

乱序和重复也需要明确策略。用于“当前状态”的消费端可以拒绝旧序号;用于历史优化的模块则可能接受迟到数据并重算。两者用途不同,不应在通用传输层里不加区分地清除所有迟到消息。

8. 把空间、误差与时间放到同一份数据约定里

一个机器人观测要能被正确使用,至少应让消费者知道:测了什么、在哪个坐标系、何时采集、时钟如何解释,以及它的不确定性和来源是什么。

落到八界这样的系统,图像与机械臂关节状态的时刻应对应;移动底盘的位姿应按观测时刻查取;等待动作执行期间,还应判断目标是否发生了变化。不能仅凭“消息已到达”或“变换树能连通”,就认定可以直接执行。

排查运动中的偏差时,可以有顺序地核对:

  1. 原始采集时间有没有在转发中被覆盖,设备与主机是否属于同一个可比较的时钟域。
  2. 延迟主要发生在设备、传输、等待、算法还是执行阶段,年龄是否随运行时间增长。
  3. 点、变换和关节状态是否对应同一时刻,近似同步窗口是否符合误差预算。
  4. 使用前是否检查年龄、时钟变化和乱序,过期后的行为是否明确。

本文的 examples/06_timing.py 可以离线复现混叠、时间错配误差、两种队列策略与数据有效期检查。运行方式为:

python3 examples/06_timing.py

06_timing.py
代码使用有理数模拟采样与处理事件,避免浮点边界改变“同一时刻到达”的先后关系;既检查过载,也检查处理速度足够快时两种策略应完全一致。时间检查覆盖正常、有效期边界、过期、未来时间、时钟纪元变化与乱序。

欢迎关注科沃斯八界 AtomGit [6],相关代码将逐步释放,实际开放内容与使用说明以仓库为准。这些离线脚本是教学示例,不代表已经公开的实机同步或控制接口。

到这里,第一部分形成了一条完整的阅读线:系统怎样协作,空间怎样表达,目标怎样转为运动,位姿怎样更新,测量怎样表达不确定性,以及数据为什么必须附带时间语义。这些基础会继续支撑后续的建模、感知、规划与控制文章。

参考链接

[1] ROS 2 Design:Clock and Time,系统、稳态与 ROS 时间,仿真时间和时间跳变。
[2] Python 官方文档:time.monotonic 与 monotonic_ns,单调时钟的含义与使用边界。
[3] Analog Devices:MT-002 — What the Nyquist Criterion Means to Your Sampled Data System Design,采样、混叠与抗混叠滤波。
[4] ROS 2 官方文档源:Using time with tf2,按时刻查询变换、最新变换与等待。
[5] ROS 2 message_filters:Approximate Time Synchronizer 官方教程,近似时间匹配的队列与容差配置。
[6] 科沃斯八界 AtomGit,项目代码关注入口,相关代码逐步释放。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐