Skip to content

机器人视频质检(二):跨模态摄像头配对校验

用腕部相机画面运动与手臂关节运动的 Pearson 相关性矩阵,检测左右腕相机是否接反。真实 MolmoAct2 双臂数据实测,并给出"缺失帧不能默认为零运动"这一陷阱的量化后果。

18 min read

本系列:机器人视频质检 — 专题首页 · 本篇是(二)

数据来源:allenai/MolmoAct2-BimanualYAM-Dataset

上一篇文章的帧级质检覆盖了 16 个 episode、31,115 帧、1037.17 秒的真实机器人视频。本文继续用同一批数据,做跨模态校验。

本文所有相关系数、判定结果和漏检统计都来自实际运行。

上一篇文章解决的是“这段画面好不好”。但有一类错误,帧级质检永远发现不了:

双臂机器人的左右腕部相机接反了。

← 上一篇:机器人视频质检(一):帧级检测器的实现与标定

平台侧:跨模态校验如何进入平台,以及 UMI 这类无标定数据为什么只能输出 UNKNOWN,见 Sparrow Data Platform:从 DDL 到真实具身数据质检。

这种错误的特征是——每一帧都是完美的。画面清晰、曝光正常、时间戳连续、帧率标准。帧级质检会给出全绿的 PASS。但整个数据集的语义是错的:策略学到的是“抬左臂时右手在动”。

这是本文要解决的问题。

跨模态配对矩阵。每行是一个 episode 的四个相关系数:前两个是对角(自相关),后两个是非对角(交叉)。交换后结构完全反转

1. 思路:腕部相机看得见自己的手臂

双臂机器人有左右两个腕部相机。当左臂伸出去时:

  • 左腕相机的画面剧烈变化——手臂就在镜头前面;
  • 右腕相机的画面几乎不变——左臂根本不在它的视野里。

这就是可用的信号。腕部相机对自己那条手臂的运动有反应,对另一条手臂没有反应。

于是可以构造一个 2×2 的相关性矩阵。设 mXYm_{XY} 表示“相机 X 的画面运动”与“手臂 Y 的关节运动”之间的 Pearson 相关系数:

对角(自相关)稳定在 +0.62~+0.86,非对角(交叉)稳定为负。交换后两者互换
左臂运动 右臂运动
左腕相机 m_LL(对角) m_LR(非对角)
右腕相机 m_RL(非对角) m_RR(对角)
  • 正确配对:m_LL 和 m_RR 都高,m_LR 和 m_RL 都低。
  • 接反了:m_LR 和 m_RL 都高,m_LL 和 m_RR 都低。

判定规则:

robot_video_qc/pairing.pypython
if min(cross_cells) > max(self_cells):
    crossed = True
    verdict = "CROSSED"
else:
    crossed = False
    verdict = "ALIGNED"

min(cross) > max(self) 是个严格的规则:它要求两个交叉格子都超过两个自相关格子,而不是只看其中一个。

为什么交叉相关会是负数?因为两台相机拍的是同一个工作台。当左臂伸出去时,它挡住了左腕相机的部分视野,右腕相机看到的背景反而露出更多——两路画面的变化方向相反。

2. 两个运动代理量

相关性需要两个可比的序列。左边来自相机,右边来自关节。

2.1 相机侧:帧间差

robot_video_qc/pairing.pypython
def camera_motion_series(mad_series, window=SMALL_W):
    """相机运动代理量:帧间平均绝对差。

    MAD 本身就是逐帧的运动度量,所以"相机运动序列"就是 MAD 本身。
    移动窗口把编码器噪声抹平,让相关性反映手臂的运动而不是编码器的。
    """
    return moving_window_motion(mad_series, window)

MAD 就是上一篇文章算出来的 mad 指标,直接复用。帧级质检和跨模态校验共享同一次解码——这又是一个“一次解码,多次使用”的例子。

2.2 关节侧:关节位移的模

robot_video_qc/pairing.pypython
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 窗口长度

robot_video_qc/pairing.pypython
SMALL_W = 64
"""运动信号的窗口宽度(采样点数)。

窗口要短到能捕捉一次伸手(双臂抓取大约几秒),
又要长到让相关性不被编码器噪声主导。
64 个采样点 @ 30fps 是 2.13 秒。

这个值是验证过的:在实测的 episode 上,
32、64、128 给出同样的判定,取中间的 64。
"""

实测的窗口敏感性(第 5 节):16、32、64、128、256 全部 ALIGNED。

3. 实测:16 个真实 episode

3.1 数据准备

对齐 episode、视频片段和状态python
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. 对照实验:人为交换,判定必须翻转

一个检测器如果看不出交换,那它就不是检测器。所以做交换对照:把两路相机的键对调,重跑。

交换对照python
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 episodes

16/16 全部翻转。 而且注意交换后的数值就是交换前的镜像:m_LL 变成 −0.4979(原来是 m_RL),依此类推。矩阵结构完全对调,这正是应该发生的事。

5. 窗口长度敏感性

如果判定结果随窗口长度剧烈变化,那说明它在拟合噪声而不是在检测信号。

窗口长度敏感性。16 到 256 全部 ALIGNED,对角始终为正、非对角始终为负

实测(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 填进去:

朴素实现(有 bug)python
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:

robot_video_qc/pairing.pypython
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, valid

valid 掩码做两件事:丢掉卷积边缘的补零采样,丢掉窗口内完全平坦的采样。后者正是“相机停摆”的表现。

两个序列的有效掩码取交集后再算相关:

robot_video_qc/pairing.pypython
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], both

6.3 实测:补零会漏掉真实的相机互换

这是本文的核心实验。构造一个真实的相机互换,然后让其中一个相机在 20%–60% 的区间里停摆(运动填 0),看两种实现分别怎么判。

左:真实相机互换被漏掉的数量。右:episode 0 在 40% 停摆时两种实现的相关系数对比,判定完全相反
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. 完整的实现

robot_video_qc/pairing.pypython
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 判定上:

robot_video_qc/pipeline.pypython
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. 这个方法什么时候不适用

诚实地划一下边界:

  1. 只适用于腕部相机。 俯视相机对两条手臂的相关性都是正且强度接近(实测 0.43 / 0.54),m 矩阵对它没有意义。MolmoAct2 的三路相机里,只有 left 和 right 是腕部相机——这一点在 3.3 节有实测验证。
  2. 需要两条手臂同时动。 如果 episode 里左臂一直不动,m_LL 就没有信号,相关系数会退化到 None(或者被 _corr 的守卫挡住)。这也是 UNKNOWN 状态存在的原因。
  3. 需要足够长的 episode。 实测最短的 episode 是 1,532 帧(51 秒),最长 2,500 帧。几百帧以下的短片段,滑动窗口之后剩下的有效样本可能不够。
  4. 单一任务的验证不够。 第 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/ 下:

实验脚本text
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 帧)。

参考

Type to search…

↑↓ navigate↵ selectEsc close