本系列:机器人视频质检 — 专题首页 · 本篇是(二)
数据来源:allenai/MolmoAct2-BimanualYAM-Dataset
上一篇文章的帧级质检覆盖了 16 个 episode、31,115 帧、1037.17 秒的真实机器人视频。本文继续用同一批数据,做跨模态校验。
本文所有相关系数、判定结果和漏检统计都来自实际运行。
上一篇文章解决的是“这段画面好不好”。但有一类错误,帧级质检永远发现不了:
双臂机器人的左右腕部相机接反了。
← 上一篇:机器人视频质检(一):帧级检测器的实现与标定
平台侧:跨模态校验如何进入平台,以及 UMI 这类无标定数据为什么只能输出 UNKNOWN,见 Sparrow Data Platform:从 DDL 到真实具身数据质检。
这种错误的特征是——每一帧都是完美的。画面清晰、曝光正常、时间戳连续、帧率标准。帧级质检会给出全绿的 PASS。但整个数据集的语义是错的:策略学到的是“抬左臂时右手在动”。
这是本文要解决的问题。
1. 思路:腕部相机看得见自己的手臂
双臂机器人有左右两个腕部相机。当左臂伸出去时:
- 左腕相机的画面剧烈变化——手臂就在镜头前面;
- 右腕相机的画面几乎不变——左臂根本不在它的视野里。
这就是可用的信号。腕部相机对自己那条手臂的运动有反应,对另一条手臂没有反应。
于是可以构造一个 2×2 的相关性矩阵。设 表示“相机 X 的画面运动”与“手臂 Y 的关节运动”之间的 Pearson 相关系数:
| 左臂运动 | 右臂运动 | |
|---|---|---|
| 左腕相机 | m_LL(对角) |
m_LR(非对角) |
| 右腕相机 | m_RL(非对角) |
m_RR(对角) |
- 正确配对:
m_LL和m_RR都高,m_LR和m_RL都低。 - 接反了:
m_LR和m_RL都高,m_LL和m_RR都低。
判定规则:
if min(cross_cells) > max(self_cells):
crossed = True
verdict = "CROSSED"
else:
crossed = False
verdict = "ALIGNED"min(cross) > max(self) 是个严格的规则:它要求两个交叉格子都超过两个自相关格子,而不是只看其中一个。
为什么交叉相关会是负数?因为两台相机拍的是同一个工作台。当左臂伸出去时,它挡住了左腕相机的部分视野,右腕相机看到的背景反而露出更多——两路画面的变化方向相反。
2. 两个运动代理量
相关性需要两个可比的序列。左边来自相机,右边来自关节。
2.1 相机侧:帧间差
def camera_motion_series(mad_series, window=SMALL_W):
"""相机运动代理量:帧间平均绝对差。
MAD 本身就是逐帧的运动度量,所以"相机运动序列"就是 MAD 本身。
移动窗口把编码器噪声抹平,让相关性反映手臂的运动而不是编码器的。
"""
return moving_window_motion(mad_series, window)MAD 就是上一篇文章算出来的 mad 指标,直接复用。帧级质检和跨模态校验共享同一次解码——这又是一个“一次解码,多次使用”的例子。
2.2 关节侧:关节位移的模
def joint_motion_series(joint_positions, window=SMALL_W):
"""手臂运动代理量:逐帧关节位移的 L2 范数。
joint_positions 形状是 (n_frames, n_joints)。
取相邻帧之差,平方求和,开方。
"""
j = np.asarray(joint_positions, dtype=np.float64)
if j.ndim != 2 or j.shape[0] < 2:
return np.array([]), np.array([], dtype=bool)
d = np.linalg.norm(np.diff(j, axis=0), axis=1)
# 补回一帧,让下标和相机序列对齐
d = np.concatenate([[0.0], d])
return moving_window_motion(d, window)状态向量是 14 维的,前 7 维左臂、后 7 维右臂:
arms = {
"left": state[:, 0:7], # left_joint_0..5 + left_gripper
"right": state[:, 7:14], # right_joint_0..5 + right_gripper
}用所有 7 个自由度而不是只用一个关节,是因为手臂的“运动”不是单个关节的事——肘部伸展时肩部和腕部都在动。7 维位移的模是“这条手臂在不在动”的更稳的代理量。
2.3 窗口长度
SMALL_W = 64
"""运动信号的窗口宽度(采样点数)。
窗口要短到能捕捉一次伸手(双臂抓取大约几秒),
又要长到让相关性不被编码器噪声主导。
64 个采样点 @ 30fps 是 2.13 秒。
这个值是验证过的:在实测的 episode 上,
32、64、128 给出同样的判定,取中间的 64。
"""实测的窗口敏感性(第 5 节):16、32、64、128、256 全部 ALIGNED。
3. 实测:16 个真实 episode
3.1 数据准备
import numpy as np
import pyarrow.parquet as pq
# 1. episode -> 视频里的时间区间
meta = pq.read_table(
"data/.../meta/episodes/chunk-000/file-000.parquet",
columns=["episode_index", "length",
"videos/observation.images.left/from_timestamp",
"videos/observation.images.left/to_timestamp",
"videos/observation.images.left/file_index",
"videos/observation.images.right/file_index"],
).to_pandas()
# 只取完整落在 file-000.mp4 里的 episode
meta = meta[(meta["videos/observation.images.left/file_index"] == 0) &
(meta["videos/observation.images.right/file_index"] == 0)].reset_index(drop=True)
# 2. 状态
state = pd.concat([
pq.read_table(f"data/.../data/chunk-000/file-{i:03d}.parquet",
columns=["episode_index", "frame_index", "observation.state"]).to_pandas()
for i in range(4)
], ignore_index=True)
# 3. 每个 episode 的帧区间
f0 = int(round(row["videos/observation.images.left/from_timestamp"] * 30))
f1 = int(round(row["videos/observation.images.left/to_timestamp"] * 30))from_timestamp 是秒,乘 30 得到帧号。file-000.mp4 里首尾相接装着 16 个 episode,所以时间区间直接就是帧区间。
这里有两个容易踩的细节:
frame_index和timestamp都是每个 episode 重新从 0 开始的,index才是全局行号(实测 episode 1 的首行是index = 1732、frame_index = 0、timestamp = 0.0)。所以不能用index当 episode 内的帧号,必须先用 episode 元数据切出区间。- state 行要按
(episode_index, frame_index)与视频帧对齐;只按行号切片会在跨 episode 时错位。错位之后,本篇 6.3 节那类假阴性就会更难发现。
任务文本也分两层:task_index 指向 meta/tasks.parquet 里的短语,meta/tasks_annotated.parquet 则给每个 episode 一条更完整的指令。本文只用到 state 和视频,不依赖任务文本。
3.2 结果
两路腕部相机各解了 31,115 帧(实测 761 s / 767 s),然后逐 episode 判定:
ep L-L L-R R-L R-R 判定
0 +0.8035 -0.4407 -0.3384 +0.8109 ALIGNED
1 +0.7521 -0.3949 -0.1430 +0.7440 ALIGNED
2 +0.7641 -0.5614 -0.5240 +0.7094 ALIGNED
3 +0.6873 -0.5766 -0.5391 +0.7941 ALIGNED
4 +0.7564 -0.5901 -0.4672 +0.8496 ALIGNED
5 +0.7662 -0.5605 -0.6571 +0.7400 ALIGNED
6 +0.6575 -0.6097 -0.5262 +0.7306 ALIGNED
7 +0.7620 -0.5996 -0.5437 +0.8558 ALIGNED
8 +0.6217 -0.5830 -0.4982 +0.7158 ALIGNED
9 +0.8463 -0.4476 -0.6058 +0.7254 ALIGNED
10 +0.7001 -0.5887 -0.5336 +0.8157 ALIGNED
11 +0.7166 -0.5850 -0.4937 +0.7085 ALIGNED
12 +0.6941 -0.4922 -0.4962 +0.7139 ALIGNED
13 +0.8075 -0.5452 -0.5233 +0.7724 ALIGNED
14 +0.8043 -0.5255 -0.6137 +0.7250 ALIGNED
15 +0.6528 -0.5576 -0.4640 +0.6874 ALIGNED统计(16 个 episode):
| 最小 | 最大 | 均值 | |
|---|---|---|---|
m_LL 左腕 ↔ 左臂 |
+0.6217 | +0.8463 | +0.7370 |
m_RR 右腕 ↔ 右臂 |
+0.6874 | +0.8558 | +0.7562 |
m_LR 左腕 ↔ 右臂 |
−0.6097 | −0.3949 | −0.5411 |
m_RL 右腕 ↔ 左臂 |
−0.6571 | −0.1430 | −0.4979 |
符号完全分离:16 个 episode 的 32 个对角格子全部为正(最小 +0.6217),32 个非对角格子全部为负(最大 −0.1430)。最小间隔是 +0.6217 − (−0.1430) = 0.7647。
也就是说,在这份数据上,判定只需要看符号就够了——min(cross) > max(self) 这个严格规则甚至有些过于保守。
3.3 俯视相机不是腕部相机(实测验证)
meta/info.json 里只有键名(observation.images.top),没有写相机装在哪。“top 是俯视、left/right 是腕部”这个判断一开始是从命名推断的。
推断可以被验证:把三路相机都拿去和两条手臂求相关,如果 top 真是俯视,它的两个相关系数应该都为正且强度接近(俯视相机看得见两条手臂,但都不占据画面的主导);而腕部相机必然出现“一强一负”的结构。
实测(同一批 16 个 episode,三路相机全解,共 31,115 帧 × 3):
ep | LEFT cam vs L arm vs R arm | RIGHT cam vs L arm vs R arm | TOP cam vs L arm vs R arm
----------------------------------------------------------------------------------------------------
0 | +0.8035 -0.4407 | -0.3384 +0.8109 | +0.6111 +0.5167
1 | +0.7521 -0.3949 | -0.1430 +0.7440 | +0.5029 +0.4949
2 | +0.7641 -0.5614 | -0.5240 +0.7094 | +0.3644 +0.5839
3 | +0.6873 -0.5766 | -0.5391 +0.7941 | +0.6371 +0.3089
4 | +0.7564 -0.5901 | -0.4672 +0.8496 | +0.5230 +0.4701
5 | +0.7662 -0.5605 | -0.6571 +0.7400 | +0.5817 +0.3042
6 | +0.6575 -0.6097 | -0.5262 +0.7306 | +0.3630 +0.5962
7 | +0.7620 -0.5996 | -0.5437 +0.8558 | +0.5862 +0.3392
8 | +0.6217 -0.5830 | -0.4982 +0.7158 | +0.5235 +0.4359
9 | +0.8463 -0.4476 | -0.6058 +0.7254 | +0.5979 +0.3480
10 | +0.7001 -0.5887 | -0.5336 +0.8157 | +0.5279 +0.4685
11 | +0.7166 -0.5850 | -0.4937 +0.7085 | +0.5469 +0.4660
12 | +0.6941 -0.4922 | -0.4962 +0.7139 | +0.4600 +0.5289
13 | +0.8075 -0.5452 | -0.5233 +0.7724 | +0.6022 +0.3783
14 | +0.8043 -0.5255 | -0.6137 +0.7250 | +0.6424 +0.2734
15 | +0.6528 -0.5576 | -0.4640 +0.6874 | +0.5759 +0.4487汇总(平均 |r|):
| 相机 | vs 左臂 | vs 右臂 | 特征 |
|---|---|---|---|
| left | 0.7370 | 0.5411 | 对自己的臂最强,另一条臂是负相关 |
| right | 0.4979 | 0.7562 | 对自己的臂最强,另一条臂是负相关 |
| top | 0.5404 | 0.4351 | 两条臂都是正相关,且强度接近 |
结论很清楚:
- left 和 right 是腕部相机。它们对各自那条臂的相关性最强(0.74 / 0.76),对另一条臂是负相关——负号说明两路相机拍的是同一块工作台,一条手臂伸出时另一路的视野反而“露出更多”。
- top 是俯视相机。它对两条手臂的相关性都在 +0.36 ~ +0.64 之间,符号全为正、没有明显的自有臂。如果它是腕部相机,必然会出现和 left/right 一样的“一强一负”结构。
4. 对照实验:人为交换,判定必须翻转
一个检测器如果看不出交换,那它就不是检测器。所以做交换对照:把两路相机的键对调,重跑。
cams = {"left": mad_l[f0:f0+n], "right": mad_r[f0:f0+n]}
swapped_cams = {"left": cams["right"], "right": cams["left"]}
a = stream_pairing(cams, arms) # 原始数据
b = stream_pairing(swapped_cams, arms) # 人为交换实测结果:
ep L-L L-R R-L R-R 正确 交换后
0 +0.8035 -0.4407 -0.3384 +0.8109 ALIGNED CROSSED
1 +0.7521 -0.3949 -0.1430 +0.7440 ALIGNED CROSSED
2 +0.7641 -0.5614 -0.5240 +0.7094 ALIGNED CROSSED
3 +0.6873 -0.5766 -0.5391 +0.7941 ALIGNED CROSSED
4 +0.7564 -0.5901 -0.4672 +0.8496 ALIGNED CROSSED
5 +0.7662 -0.5605 -0.6571 +0.7400 ALIGNED CROSSED
6 +0.6575 -0.6097 -0.5262 +0.7306 ALIGNED CROSSED
7 +0.7620 -0.5996 -0.5437 +0.8558 ALIGNED CROSSED
8 +0.6217 -0.5830 -0.4982 +0.7158 ALIGNED CROSSED
9 +0.8463 -0.4476 -0.6058 +0.7254 ALIGNED CROSSED
10 +0.7001 -0.5887 -0.5336 +0.8157 ALIGNED CROSSED
11 +0.7166 -0.5850 -0.4937 +0.7085 ALIGNED CROSSED
12 +0.6941 -0.4922 -0.4962 +0.7139 ALIGNED CROSSED
13 +0.8075 -0.5452 -0.5233 +0.7724 ALIGNED CROSSED
14 +0.8043 -0.5255 -0.6137 +0.7250 ALIGNED CROSSED
15 +0.6528 -0.5576 -0.4640 +0.6874 ALIGNED CROSSED
verdict flipped on 16 / 16 episodes16/16 全部翻转。 而且注意交换后的数值就是交换前的镜像:m_LL 变成 −0.4979(原来是 m_RL),依此类推。矩阵结构完全对调,这正是应该发生的事。
5. 窗口长度敏感性
如果判定结果随窗口长度剧烈变化,那说明它在拟合噪声而不是在检测信号。
实测(episode 3):
| 窗口 | 采样点数 | m_LL |
m_RR |
m_LR |
m_RL |
有效样本 | 判定 |
|---|---|---|---|---|---|---|---|
| 16 | 0.53 s | +0.6479 | +0.7142 | −0.3803 | −0.3720 | 1849 | ALIGNED |
| 32 | 1.07 s | +0.6625 | +0.7557 | −0.4583 | −0.4326 | 1833 | ALIGNED |
| 64 | 2.13 s | +0.6873 | +0.7941 | −0.5766 | −0.5391 | 1801 | ALIGNED |
| 128 | 4.27 s | +0.7172 | +0.8341 | −0.7641 | −0.6977 | 1737 | ALIGNED |
| 256 | 8.53 s | +0.7946 | +0.9261 | −0.8824 | −0.8279 | 1609 | ALIGNED |
窗口越长,有效样本越少(边缘的半个窗口被丢掉),相关系数的绝对值也越大——但符号结构完全稳定。判定对窗口长度不敏感,说明它捕捉的是真实的运动结构。
6. 陷阱:缺失帧不能默认为零运动
这是本文最重要的一节。
6.1 问题
假设相机在 episode 的某一段停摆了——丢帧、解码卡顿、编码器停顿。这段时间的“运动”序列是缺失的,不是“零”。
朴素实现会怎么做?把缺失当成 0 填进去:
def naive_pairing(cam_mad, arm_state, window=SMALL_W):
"""错误做法:把缺失的采样当作真实的零运动。"""
out = {}
for c in cam_mad:
s = np.convolve(np.asarray(cam_mad[c], float), np.ones(window)/window, mode="same")
for a in arm_state:
js, _ = joint_motion_series(np.asarray(arm_state[a]), window)
n_ = min(len(s), len(js))
out[f"{c}_vs_{a}"] = _corr(s[:n_], js[:n_])
return out这段代码看起来完全合理。但它有一个致命的问题。
6.2 为什么补零是错的
相关性对“平坦”有天然的偏好。如果一段序列是常数,它的方差为零;虽然纯数学上相关系数是 0/0,但实际计算出来往往接近某个小的非零值,而且两段平坦序列之间会得到虚高的相关性。
更糟的是:把相机的运动填成 0,等于告诉相关性算法“这段时间相机没动”。如果这段时间手臂在动,自相关就被稀释了。
正确做法是把缺失的采样剔除,而不是填 0:
def moving_window_motion(values, window=SMALL_W):
v = np.asarray(values, dtype=np.float64)
n = len(v)
if n == 0:
return np.array([]), np.array([], dtype=bool)
w = min(window, n)
kernel = np.ones(w) / w
smoothed = np.convolve(v, kernel, mode="same")
valid = np.zeros(n, dtype=bool)
if n >= w:
# "same" 卷积在边缘是零填充的,那些采样不是真实的窗口均值,丢掉。
valid[w // 2: n - (w - w // 2) if w > 1 else n] = True
# 一个所有取值都(几乎)相同的窗口不携带运动信息。
# 用窗口上的 max-min 得到它的跨度,代价很低。
if n >= w:
spread = np.zeros(n, dtype=np.float64)
for i in range(n):
lo = max(0, min(i - w // 2, n - w))
seg = v[lo:lo + w]
spread[i] = seg.max() - seg.min()
valid &= spread > 1e-9
return smoothed, validvalid 掩码做两件事:丢掉卷积边缘的补零采样,丢掉窗口内完全平坦的采样。后者正是“相机停摆”的表现。
两个序列的有效掩码取交集后再算相关:
def _align(a, a_valid, b, b_valid):
"""在两个序列的公共有效采样上求交。"""
n = min(len(a), len(b))
a = a[:n]; b = b[:n]
av = a_valid[:n]; bv = b_valid[:n]
both = av & bv
return a[both], b[both], both6.3 实测:补零会漏掉真实的相机互换
这是本文的核心实验。构造一个真实的相机互换,然后让其中一个相机在 20%–60% 的区间里停摆(运动填 0),看两种实现分别怎么判。
stall frac naive missed correct missed verdict
0.0 0/16 0/16 OK
0.1 0/16 0/16 OK
0.2 0/16 0/16 OK
0.3 0/16 0/16 OK
0.4 2/16 0/16 naive misses real swaps
0.5 0/16 0/16 OK
0.6 0/16 0/16 OK在 40% 停摆时,朴素实现漏掉了 16 个真实相机互换中的 2 个。 本文实现在所有停摆比例下都是 0 漏检。
6.4 漏检长什么样
episode 0,单相机停摆 40%:
朴素实现 : left/left=+0.2218 left/right=+0.1589 right/left=+0.8034 right/right=-0.4138
判定 = ALIGNED
本文实现 : left/left=-0.1204 left/right=+0.6174 right/left=+0.8035 right/right=-0.4407
判定 = CROSSED
决定性的那一组比较:
朴素 min(cross)=+0.1589 max(self)=+0.2218 -> min(cross) > max(self) 为假
本文 min(cross)=+0.6174 max(self)=-0.1204 -> min(cross) > max(self) 为真看 left/left 这一格:本文实现算出的值是 −0.1204(负相关——左腕相机被接到了右臂上,这正是“接反了”的信号),朴素实现却算出了 +0.2218(正相关)。
它把负相关翻转成了正相关。 原因正是 6.2 说的:相机停摆期间运动被填成 0,这段“平坦”的序列和另一条也趋于平坦的序列产生了虚高的相关性,把本该是负数的自相关抬成了正数。
结果就是:min(cross) = +0.1589 没能超过 max(self) = +0.2218,判定为 ALIGNED——一个真实的相机互换被漏掉了。
(对照:同一份数据在没有停摆污染时,交换后的 left/left 是 −0.3384。污染加上错误的处理方式,把它从 −0.3384 抬到了 +0.2218。)
6.5 一个诚实的补充
补零并不总是导致漏检。实测:
- 在干净数据上做同样的污染,朴素实现和本文实现的判定从未分歧(48 组测试,0 次分歧);
- 把两个相机同时停摆 30%,两种实现都判 ALIGNED,判定一致;
- 在“相机停摆时自己的手臂恰好静止”的场景下,朴素实现把
left/left高估了 +0.0121(方向是对的,幅度有偏)。
所以补零的问题不是“总是错”,而是”在特定条件下会错,而且错在最危险的方向上“。这正是它值得单独一节的原因。
7. 完整的实现
def _corr(a, b):
"""Pearson 相关系数,不可定义时返回 None。
守卫:
n < 3 相关性至少需要几个点才有意义
std < 1e-9 其中一个序列是常数 -> 相关性是 0/0
"""
n = min(len(a), len(b))
if n < 3:
return None
a = np.asarray(a[:n], dtype=np.float64)
b = np.asarray(b[:n], dtype=np.float64)
sa = a.std()
sb = b.std()
if sa < 1e-9 or sb < 1e-9:
return None
return float(np.corrcoef(a, b)[0, 1])
def stream_pairing(cam_mad, arm_state, window=SMALL_W):
"""判定相机流是否接到了正确的臂上。
cam_mad 相机名 -> 每帧 MAD 序列
arm_state 臂名 -> (n_frames, n_joints) 关节位置
相机名和臂名必须对应:相机 "left" 应当在臂 "left" 上。
"""
cameras = list(cam_mad.keys())
arms = list(arm_state.keys())
res = PairingResult()
if len(cameras) != 2 or len(arms) != 2:
res.verdict = "UNKNOWN"
res.detail["reason"] = f"need exactly 2 cameras and 2 arms, got {cameras} and {arms}"
return res
cam_series, cam_valid = {}, {}
for c in cameras:
s, v = camera_motion_series(cam_mad[c], window)
cam_series[c], cam_valid[c] = s, v
arm_series, arm_valid = {}, {}
for a in arms:
s, v = joint_motion_series(np.asarray(arm_state[a]), window)
arm_series[a], arm_valid[a] = s, v
corrs = {}
for c in cameras:
for a in arms:
xs, ys, both = _align(cam_series[c], cam_valid[c],
arm_series[a], arm_valid[a])
corrs[f"{c}_vs_{a}"] = _corr(xs, ys)
res.n_samples = max(res.n_samples, len(xs))
res.correlations = corrs
res.n_valid = res.n_samples
# 规则需要全部四格都有值。
if any(v is None for v in corrs.values()):
res.verdict = "UNKNOWN"
res.detail["reason"] = "at least one correlation is undefined (constant or too-short series)"
return res
cam0, cam1 = cameras
arm0, arm1 = arms
self_cells = [corrs[f"{cam0}_vs_{arm0}"], corrs[f"{cam1}_vs_{arm1}"]]
cross_cells = [corrs[f"{cam0}_vs_{arm1}"], corrs[f"{cam1}_vs_{arm0}"]]
res.detail["min_cross"] = min(cross_cells)
res.detail["max_self"] = max(self_cells)
if min(cross_cells) > max(self_cells):
res.crossed = True
res.verdict = "CROSSED"
else:
res.crossed = False
res.verdict = "ALIGNED"
return res三个状态:ALIGNED / CROSSED / UNKNOWN。UNKNOWN 覆盖两种情况——不是正好两个相机两个臂,或者四个格子里有任何一个算不出来(序列是常数、或者太短)。
UNKNOWN 必须是独立的状态,不能并进 ALIGNED。 “算不出来”和“没问题”是两回事;把前者当成后者,就等于在数据有缺陷时给出虚假的安心。
8. 接进质检流水线
跨模态校验和帧级质检的产物结构一致,可以直接挂在同一个 episode 判定上:
def _episode_status(per_camera):
"""最差的那个相机决定 episode 的状态。"""
order = [Status.FAIL, Status.INCOMPLETE, Status.UNKNOWN, Status.WARN, Status.PASS]
present = {c["summary"].status for c in per_camera.values()}
for s in order:
if s in present:
return s
return Status.UNKNOWN配对校验的状态建议单独一个字段,因为它和画面质量是正交的维度:
| 画面质量 | 配对 | 含义 |
|---|---|---|
| PASS | ALIGNED | 可以用 |
| PASS | CROSSED | 画面完美但语义错了——最危险 |
| WARN | ALIGNED | 可用但有瑕疵 |
| FAIL | 任意 | 不可用 |
PASS + CROSSED 这一格就是需要单独拎出来报警的地方:帧级质检全绿,跨模态校验发现了互换。
9. 这个方法什么时候不适用
诚实地划一下边界:
- 只适用于腕部相机。 俯视相机对两条手臂的相关性都是正且强度接近(实测 0.43 / 0.54),
m矩阵对它没有意义。MolmoAct2 的三路相机里,只有 left 和 right 是腕部相机——这一点在 3.3 节有实测验证。 - 需要两条手臂同时动。 如果 episode 里左臂一直不动,
m_LL就没有信号,相关系数会退化到None(或者被_corr的守卫挡住)。这也是UNKNOWN状态存在的原因。 - 需要足够长的 episode。 实测最短的 episode 是 1,532 帧(51 秒),最长 2,500 帧。几百帧以下的短片段,滑动窗口之后剩下的有效样本可能不够。
- 单一任务的验证不够。 第 3 节已经说明,16 个 episode 全是同一个任务。换到“左右手各做各的”的任务上,交叉相关会变弱,阈值需要重新验证。
10. 小结
- 腕部相机看得见自己的手臂。这是跨模态校验的全部物理基础——3.3 节用三路相机实测验证了 left/right 是腕部相机、top 是俯视相机。
- 2×2 相关性矩阵:对角高、非对角低 = 正确;反之 = 接反了。判定规则
min(cross) > max(self)。 - 实测 16 个 episode 全部 ALIGNED,对角 +0.62~+0.86 全为正,非对角 −0.66~−0.14 全为负,符号完全分离。
- 交换对照 16/16 全部翻转,矩阵结构完全对调。
- 判定对窗口长度不敏感:16 到 256 全部 ALIGNED。
- 缺失帧必须剔除,不能补零。 补零在 40% 停摆时漏掉了 2/16 个真实相机互换,而且是把负相关翻转成正相关这种最隐蔽的方式。假阴性比假阳性危险得多。
附:复现
第二篇文章的全部实验在 examples/robot-video-qc/ 下:
robot_video_qc/pairing.py # 本文的全部实现pairing.py 提供两个函数:
| 函数 | 作用 |
|---|---|
stream_pairing(cam_mad, arm_state, window) |
两相机 × 两臂的配对判定,返回 ALIGNED / CROSSED / UNKNOWN |
camera_arm_correlations(cam_mad, arm_state, window) |
每台相机对每条臂的相关系数,不给判定——用来识别哪台是腕部相机 |
关键参数:
| 参数 | 值 | 含义 |
|---|---|---|
SMALL_W |
64 | 运动信号的窗口宽度(采样点) |
| 相关系数守卫 | n < 3 或 std < 1e-9 → None |
不可计算时返回 None 而不是 0 |
| 判定规则 | min(cross) > max(self) |
严格规则:两个交叉格子都要超过两个自相关格子 |
实测环境:Python 3.x、NumPy 2.2.6、PyArrow 22.0.0、OpenCV 4.10.0(Windows,CPU only)。
本文的三路相机实测耗时:left 761 s、right 767 s、top 628 s(各 31,115 帧)。
参考
- 数据集主页(dataset card):allenai/MolmoAct2-BimanualYAM-Dataset
- 数据集文件树(meta / data / videos):Files and versions
- LeRobot v3.0 格式说明:LeRobotDataset v3.0
- 论文:MolmoAct2: Action Reasoning Models for Real-world Deployment(arXiv:2605.02881)