---
title: "机器人视频质检（二）：跨模态摄像头配对校验"
description: "用腕部相机画面运动与手臂关节运动的 Pearson 相关性矩阵，检测左右腕相机是否接反。真实 MolmoAct2 双臂数据实测，并给出\"缺失帧不能默认为零运动\"这一陷阱的量化后果。"
---

> Documentation Index
> Fetch the complete documentation index at: https://petwan.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# 机器人视频质检（二）：跨模态摄像头配对校验

> **本系列**：机器人视频质检 — [专题首页](/robot-video-qc/) · 本篇是（二）
>
> **数据来源**：[allenai/MolmoAct2-BimanualYAM-Dataset](https://huggingface.co/datasets/allenai/MolmoAct2-BimanualYAM-Dataset)
>
> 上一篇文章的帧级质检覆盖了 16 个 episode、31,115 帧、1037.17 秒的真实机器人视频。本文继续用同一批数据，做**跨模态**校验。
>
> 本文所有相关系数、判定结果和漏检统计都来自实际运行。

上一篇文章解决的是"这段画面好不好"。但有一类错误，帧级质检**永远发现不了**：

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

← 上一篇：[机器人视频质检（一）：帧级检测器的实现与标定](/robot-video-qc/frame-detectors/)

**平台侧**：跨模态校验如何进入平台，以及 UMI 这类无标定数据为什么只能输出 `UNKNOWN`，见 [Sparrow Data Platform：从 DDL 到真实具身数据质检](/dataplatform-build-and-real-qc/)。

这种错误的特征是——**每一帧都是完美的**。画面清晰、曝光正常、时间戳连续、帧率标准。帧级质检会给出全绿的 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` 都低。

判定规则：

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

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

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

## 2. 两个运动代理量

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

### 2.1 相机侧：帧间差

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

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

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

### 2.2 关节侧：关节位移的模

```python title="robot_video_qc/pairing.py"
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 维右臂：

```python
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 窗口长度

```python title="robot_video_qc/pairing.py"
SMALL_W = 64
"""运动信号的窗口宽度（采样点数）。

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

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

实测的窗口敏感性（第 5 节）：16、32、64、128、256 **全部 ALIGNED**。

## 3. 实测：16 个真实 episode

### 3.1 数据准备

```python title="对齐 episode、视频片段和状态"
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 判定：

```text
   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)` 这个严格规则甚至有些过于保守。

> **这个样本的局限**
>
> `file-000.mp4` 里的 16 个 episode **任务都是同一个**（`Spell out Ai2`）。也就是说，配对校验在"双臂协同操作同一目标"这类任务上验证过，但在"左右手各做各的"这类任务上还没验证——后者的交叉相关可能更弱。判断新任务上的表现，需要重跑一遍，不能直接套用这里的阈值。

### 3.3 俯视相机不是腕部相机（实测验证）

`meta/info.json` 里只有键名（`observation.images.top`），没有写相机装在哪。"top 是俯视、left/right 是腕部"这个判断一开始是**从命名推断的**。

推断可以被验证：把三路相机都拿去和两条手臂求相关，如果 top 真是俯视，它的两个相关系数应该都**为正且强度接近**（俯视相机看得见两条手臂，但都不占据画面的主导）；而腕部相机必然出现"一强一负"的结构。

实测（同一批 16 个 episode，三路相机全解，共 31,115 帧 × 3）：

```text
  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 一样的"一强一负"结构。

> **这个验证的价值**
>
> 这也解释了为什么配对校验**只能用在腕部相机上**：俯视相机的两个格子都是正的且接近，`min(cross) > max(self)` 这个规则对它根本不适用。

## 4. 对照实验：人为交换，判定必须翻转

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

```python title="交换对照"
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)  # 人为交换
```

实测结果：

```text
   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. 窗口长度敏感性

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

实测（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 填进去：

```python title="朴素实现（有 bug）"
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：

```python title="robot_video_qc/pairing.py"
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` 掩码做两件事：丢掉卷积边缘的补零采样，丢掉**窗口内完全平坦**的采样。后者正是"相机停摆"的表现。

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

```python title="robot_video_qc/pairing.py"
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），看两种实现分别怎么判。

```text
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%：

```text
朴素实现 : 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。）

> **这是最危险的一类错误**
>
> 假阳性会被人发现，假阴性不会。一个"检测到疑似互换"的告警，你还会去查；一个"一切正常"的结论，你就直接拿去训练了。
>
> 而且注意：**这不是一个只会在极端情况下出现的问题**。40% 的停摆在真实数据里并不罕见——一段快速的来回移动、抓取动作中的遮挡、编码器的一次缓冲——任何让相机"看起来静止"的情况都会触发它。

### 6.5 一个诚实的补充

补零**并不总是**导致漏检。实测：

- 在干净数据上做同样的污染，朴素实现和本文实现的判定**从未分歧**（48 组测试，0 次分歧）；
- 把两个相机**同时**停摆 30%，两种实现都判 ALIGNED，判定一致；
- 在"相机停摆时自己的手臂恰好静止"的场景下，朴素实现把 `left/left` **高估**了 +0.0121（方向是对的，幅度有偏）。

所以补零的问题不是"总是错"，而是"**在特定条件下会错，而且错在最危险的方向上**"。这正是它值得单独一节的原因。

## 7. 完整的实现

```python title="robot_video_qc/pairing.py"
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 判定上：

```python title="robot_video_qc/pipeline.py"
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 title="实验脚本"
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](https://huggingface.co/datasets/allenai/MolmoAct2-BimanualYAM-Dataset)
- 数据集文件树（meta / data / videos）：[Files and versions](https://huggingface.co/datasets/allenai/MolmoAct2-BimanualYAM-Dataset/tree/main)
- LeRobot v3.0 格式说明：[LeRobotDataset v3.0](https://huggingface.co/docs/lerobot/en/lerobot-dataset-v3)
- 论文：[MolmoAct2: Action Reasoning Models for Real-world Deployment（arXiv:2605.02881）](https://arxiv.org/abs/2605.02881)

Source: https://petwan.tech/robot-video-qc/camera-pairing/index.mdx
