---
title: "机器人视频质检（一）：帧级检测器的实现与标定"
description: "用 OpenCV 从零实现具身智能数据集的帧级视频质检：黑屏、冻结、模糊、曝光四类检测器，半开区间合并，Parquet + JSON 输出契约。阈值全部由真实机器人数据标定得出。"
---

> 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)（HuggingFace 官方仓库）
>
> **本文所有数值都来自实际运行**。数据集真实规格（读自 `meta/info.json`）：**32,246 个 episode、76,046,658 帧、30 fps、3 路相机（left / right / top）、AV1 编码、640×360**。
>
> 文中每一段代码都可以在 `examples/robot-video-qc/` 下直接跑通；每一个阈值都附带了标定时的精确率 / 召回率。

**平台侧**：这套检测器如何作为 `DockerOperator` 任务接入调度、产物如何按 run 版本化落盘，见 [Sparrow Data Platform：从 DDL 到真实具身数据质检](/dataplatform-build-and-real-qc/)。

采集机器人示教数据时，相机朝向是固定的，场景光照是可控的，机械臂的动作是重复的。这些"好"的性质让视频看起来天然可靠——直到你打开第 12,000 个 episode 的第 3 号相机文件，发现整整 4 秒画面全黑。

具身智能的数据质检和 NLP 的数据质检有一个根本区别：**视频里没有 token 可以统计**。你不能靠采样就能发现一段黑屏。你必须**解码每一帧**。

这篇文章把这件事拆成可执行的三段式流水线，并把四个检测器的阈值放在真实数据上标定——不是拍脑袋定的。

## 1. 真实数据集长什么样

`allenai/MolmoAct2-BimanualYAM-Dataset` 由 Allen Institute for AI（Ai2）为 MolmoAct2 发布，card 记录超过 720 小时的桌面操作示教，论文说明是遥操作 YAM 双臂轨迹；仓库按 LeRobot v3 组织。数据集是什么、一条 episode 由什么组成，[专题首页](/robot-video-qc/)有完整说明；这里只看质检直接依赖的那部分。

先看清楚要处理的东西。`meta/info.json` 是 LeRobot v3 格式的清单文件：

```json title="meta/info.json（节选）"
{
    "codebase_version": "v3.0",
    "robot_type": "bi_yam_follower",
    "total_episodes": 32246,
    "total_frames": 76046658,
    "fps": 30,
    "features": {
        "observation.images.left": {
            "dtype": "video",
            "shape": [360, 640, 3],
            "info": { "video.codec": "av1", "video.fps": 30 }
        },
        "observation.state": {
            "dtype": "float32",
            "shape": [14],
            "names": [
                "left_joint_0.pos", "left_joint_1.pos", "left_joint_2.pos",
                "left_joint_3.pos", "left_joint_4.pos", "left_joint_5.pos",
                "left_gripper.pos",
                "right_joint_0.pos", "right_joint_1.pos", "right_joint_2.pos",
                "right_joint_3.pos", "right_joint_4.pos", "right_joint_5.pos",
                "right_gripper.pos"
            ]
        }
    }
}
```

几个值得注意的事实，它们直接决定了质检要做什么：

- **76,046,658 帧**。每帧一行指标表，就是 **7,605 万行**。这就是为什么指标表要存 Parquet 而不是 JSON。
- **三路相机**。left 和 right 是**腕部相机**，top 是俯视。腕部相机的画面里，手臂在动的时候画面就在动——这个事实是第二篇文章的全部基础。（这个判断在第二篇里有实测验证，不是从命名推断的。）
- **14 维状态向量**，前 7 维是左臂（含夹爪），后 7 维是右臂。
- 视频是 **AV1**。AV1 的解码比 H.264 慢，这一点在性能章节会体现。

`meta/episodes/chunk-000/file-000.parquet` 是一张 32,246 行的 episode 清单，包含每个 episode 的长度、任务名，以及它在各路相机视频文件里的**时间偏移**：

```python title="读 episode 元数据"
import pyarrow.parquet as pq

cols = [
    "episode_index", "length", "tasks",
    "videos/observation.images.left/from_timestamp",
    "videos/observation.images.left/to_timestamp",
    "videos/observation.images.left/file_index",
]
meta = pq.read_table(
    "data/allenai__MolmoAct2-BimanualYAM-Dataset/meta/episodes/chunk-000/file-000.parquet",
    columns=cols,
).to_pandas()

print(meta.head())
```

实测输出（前 3 行）：

```text
   episode_index  length          tasks  left/from_timestamp  left/to_timestamp  left/file_index
0              0   1732  [Spell out Ai2]            0.000000           57.733333                0
1              1   2399  [Spell out Ai2]           57.733333          137.700000                0
2              2   2104  [Spell out Ai2]          137.700000          207.833333                0
```

episode 的 `length` 是帧数，`from_timestamp` / `to_timestamp` 是它在视频文件里的时间区间（秒）。**episode 是按时间首尾相接拼在同一个 mp4 里的**，一个 `file-000.mp4` 装了 16 个 episode、1037.17 秒、31,115 帧。

这一点对质检很关键：一个 episode 出问题，不代表整个文件作废。质检的输出粒度必须是 episode，而不是文件。

### 1.1 一条 episode 的字段清单

`data/chunk-000/file-000.parquet` 的一行就是一帧。实测列如下（以数据集实际文件结构为准）：

| 字段 | 类型 / 形状 | 含义 | 在质检里的用途 |
|---|---|---|---|
| `observation.state` | `float32[14]` | 左臂 6 关节 + 夹爪、右臂 6 关节 + 夹爪的位置 | 跨模态配对；维度与 NaN 检查 |
| `action` | `float32[14]` | 与 `state` 同名同维度的动作字段 | 维度与 NaN 检查；card 未说明其具体语义 |
| `timestamp` | `float32` | 秒；每个 episode 从 0 开始，步长 1/30 s | L2 时间检查、区间切分 |
| `frame_index` | `int64` | 每个 episode 内从 0 开始的帧号 | 与 `length`、区间对齐 |
| `episode_index` | `int64` | 全局 episode 编号 | 输出粒度 |
| `index` | `int64` | 全局行号，跨 episode 连续 | 校验 `dataset_from_index` / `dataset_to_index` |
| `task_index` | `int64` | 指向 `meta/tasks.parquet` 的任务 id | 任务完整性检查 |

三路视频不在这张表里：每个 mp4 装着多个 episode 的连续帧，episode 的边界由 `meta/episodes` 的 `from_timestamp` / `to_timestamp` 给出。实测 episode 0 的 `length = 1732`，`57.733333 s × 30 = 1732`——两个数字必须对得上；对不上就说明元数据或视频有一边被截断。

`meta/episodes` 的每一行还带 `stats/` 下的逐特征统计量（count、min / max / mean / std 与 q01 / q10 / q50 / q90 / q99），可以按 episode 做分布级粗筛。任务文本有两层：`meta/tasks.parquet` 里是任务短语（34 行），`meta/tasks_annotated.parquet` 里是每个 episode 一条更完整的指令（32,246 行）。

### 1.2 哪些检查不需要看画面

| 检查 | 用哪些字段 | 对应层 |
|---|---|---|
| 声明帧数等于实际行数 | `length` / `dataset_from_index` / `dataset_to_index` | L2 |
| 时间戳单调且步长 1/30 s | `timestamp` | L2 |
| 视频区间与帧数闭合 | `from_timestamp` / `to_timestamp` / `length` | L1–L2 |
| 14 维、无 NaN | `action` / `state` | 解码前的结构校验 |
| 任务 id 可解析 | `task_index` | 解码前的结构校验 |
| 每路相机都有区间 | `videos/observation.images.*/*` | L1 |

这些检查的共同点是**不需要看画面**，可以在解码前先跑一遍，把明显损坏的 episode 直接挡掉。但它们只能证明"文件自洽"，不能证明"这一段示教有意义"——语义层面的判断见专题首页的说明。

> **为什么只下载了一个 mp4**
>
> MolmoAct2 的完整数据集有 **5,860 个 mp4**（left 1,765 / right 1,607 / top 2,488，按仓库文件清单统计）。本文实际下载的 4 个文件是 213–522 MB（`file-000.mp4` 三份 + `file-001.mp4` 一份）。
>
> 为了让本文的每个数字都能复现，本文只用了 `file-000.mp4`（三路相机各一份），它完整包含 episode 0–15。这 16 个 episode 覆盖了 31,115 帧、1037.17 秒，足够做统计。

## 2. 三段式流水线

质检分成三段，因为它们的成本相差三个数量级：

```python title="robot_video_qc/pipeline.py（骨架）"
# stage 1: 指标提取 —— 唯一昂贵的一段，10^5 帧/分钟量级
rows = extract_metrics(video_path)

# stage 2: 质量评估 —— 微秒级，纯阈值比较
results = detect_all(rows)

# stage 3: 可用性判定 —— 微秒级
summary = build_summary(video_path, results, probe_info)
```

这么切分有一个实际好处：**stage 1 的结果可以落盘，阈值可以事后重调**。7.6 亿帧的指标表存下来之后，重新标定阈值只是读一遍 parquet，不需要重新解码任何视频。

这也是为什么输出要分两个文件：

| 产物 | 格式 | 粒度 | 消费者 |
|---|---|---|---|
| `frame_metrics.parquet` | Parquet | 每帧一行 | 下游批处理：找出所有 `lap_var < 12` 的帧 |
| `quality_summary.json` | JSON | 每 episode 一个对象 | 流水线决策：这一条数据要不要 |

Parquet 是列式的，所以下游查"哪些帧模糊"只需要读 `lap_var` 这一列，而不是整张表。

## 3. 一次解码，七个指标

`stage 1` 是唯一昂贵的一段。一次解码同时算出所有检测器需要的量，这样重新标定阈值时不需要碰视频。

```python title="robot_video_qc/metrics.py"
import cv2
import numpy as np


def _frame_moments(bgr, black_pixel, bright_pixel, changed_pixel_threshold):
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    h, w = gray.shape
    n = h * w

    mean_gray = float(gray.mean())

    # 逐像素取颜色通道的最大值来判定黑/白，而不是用灰度均值。
    # 纯红像素 (0,0,220) 的灰度值很低，但它显然不是黑的。
    b, g, r = cv2.split(bgr)
    ch_max = cv2.max(cv2.max(b, g), r)
    black_ratio = float((ch_max < black_pixel).sum()) / n
    bright_ratio = float((ch_max > bright_pixel).sum()) / n

    lap = cv2.Laplacian(gray, cv2.CV_16S)
    lap_var = float(lap.var())

    gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
    gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
    tenengrad = float(np.mean(gx.astype(np.float64) ** 2 + gy.astype(np.float64) ** 2))

    return mean_gray, black_ratio, bright_ratio, lap_var, tenengrad
```

### 3.1 为什么要两个清晰度指标

`lap_var`（拉普拉斯方差）和 `tenengrad` 都是经典的清晰度度量。拉普拉斯方差是事实标准，但它由少数高频像素主导——一个椒盐噪点就能让本来清晰的帧显得"模糊"。`tenengrad` 更平滑。

保留两个的理由是：**当两者剧烈不一致时，帧的问题大概率不是"模糊"**。只用一个指标，你分不清"镜头失焦"和"传感器噪声炸了"。

### 3.2 性能：踩过的坑

第一次实现用 `numpy` 的 `bgr.max(axis=2)` 和 64 位浮点的 Sobel。实测（640×360 的 AV1 帧，本机，200 帧取 5 轮最优值）：

| 操作 | 耗时 |
|---|---|
| `numpy` `bgr.max(axis=2)` | **5.998 ms/帧** |
| `cv2.split` + 两次 `cv2.max` | **0.092 ms/帧** |
| `cvtColor` BGR2GRAY | 0.041 ms/帧 |
| `Laplacian` CV\_64F | 1.200 ms/帧 |
| `Laplacian` CV\_32F | 0.736 ms/帧 |
| `Laplacian` CV\_16S | **0.128 ms/帧** |
| `Sobel` ×2 CV\_64F | 2.469 ms/帧 |
| `Sobel` ×2 CV\_32F | **0.430 ms/帧** |
| `absdiff` | 0.015 ms/帧 |
| 整条流水线（初版：numpy max + CV\_64F） | 17.228 ms/帧 |
| 整条流水线（优化后：cv2.max + CV\_16S/32F） | **8.684 ms/帧** |

换通道最大值这一项就是 **65 倍**的差距。

拉普拉斯核

只有整数系数，所以整数运算和 float64 在这里是**位等价**的。在 300 个真实帧上实测最大相对误差是 `0.0e+00`——`lap_var` 和 `tenengrad` 都是 `0.000e+00`，黑/白像素桶的计数差异也是 `0`。

整条流水线快了 **1.98 倍**，指标数值完全一致。

## 4. 四个检测器

每个检测器都是**指标表到布尔掩码的纯函数**——不碰视频文件。这样重新标定阈值时，检测器本身是免费的。

### 4.1 黑屏

```python title="robot_video_qc/detectors.py"
@dataclass(frozen=True)
class BlackThresholds:
    black_pixel: int = 20
    min_black_ratio: float = 0.99
    min_duration_s: float = 0.5


class BlackDetector(Detector):
    name = "black"
    severity = Status.FAIL
    dominates = ("blur", "freeze")

    def flag_frames(self, rows):
        return np.array([r.black_ratio >= self.t.min_black_ratio for r in rows], dtype=bool)
```

用**比例**而不是灰度均值，是这里的关键。腕部相机对着深色垫子上的黑色夹爪时，灰度均值很低，但那不是故障；一帧 99% 全黑才是相机丢了一次曝光。

### 4.2 冻结

```python title="robot_video_qc/detectors.py"
@dataclass(frozen=True)
class FreezeThresholds:
    max_mad: float = 0.01
    changed_pixel_threshold: int = 3
    max_changed_ratio: float = 1e-06
    min_duration_s: float = 1.0


class FreezeDetector(Detector):
    def flag_frames(self, rows):
        out = []
        for r in rows:
            if r.frame_index == 0:
                out.append(False)   # 没有前一帧可比
                continue
            out.append(r.mad <= self.t.max_mad and r.changed_ratio <= self.t.max_changed_ratio)
        return np.array(out, dtype=bool)
```

冻结检测器最难的地方在于：**机器人本来就会静止**。抓放任务里机械臂要等夹爪闭合，这段静止是合法的，不是故障。所以冻结判定必须带**时长门槛**——短暂冻结是artifact，长时间冻结可能就是任务本身。

两个信号必须同时成立：`mad`（相对前一帧的平均绝对差）和 `changed_ratio`（变化超过 3 个灰度级的像素比例）。

### 4.3 模糊

```python title="robot_video_qc/detectors.py"
@dataclass(frozen=True)
class BlurThresholds:
    max_lap_var: float = 12.0
    min_duration_s: float = 0.4
```

阈值 `12.0` 不是猜的。实测干净帧的 `lap_var` 是 p0.1 = 9.91、p1 = 26.33，而注入模糊的帧是 1.05–1.17。**`12.0` 落在这道缝里。**

### 4.4 曝光

三个独立故障，分开报：

```python title="robot_video_qc/detectors.py"
@dataclass(frozen=True)
class ExposureThresholds:
    dark_pixel: int = 20
    bright_pixel: int = 245
    min_dark_ratio: float = 0.75
    min_bright_ratio: float = 0.75
    max_brightness_jump: float = 20.0
    min_duration_s: float = 0.5
```

`brightness_jump` 抓的是前两条规则都漏掉的情况：**自动曝光在来回震荡**。画面既不是 80% 黑也不是 80% 白，但亮度剧烈跳动，策略学到的却是前后完全不像的两帧。

### 4.5 检测器之间会互相误报

这是实现过程中最容易被忽略的一点。一个全黑的帧同时满足三条规则：

- `black_ratio` = 1.0 → 它是黑的
- `lap_var` ≈ 0 → 它没有细节，所以它"模糊"
- `mad` = 0 → 它和前一帧一样，所以它"冻结"

初版实现不做处理时，实测注入黑屏的那一段（`raw_flag` 是检测器掩码的原始计数，`bad_frames` 是经过时长门槛后仍然成立的帧数）：

```text
black     FAIL  raw_flag= 120  bad_frames= 120  intervals=1
exposure  WARN  raw_flag= 120  bad_frames= 120  intervals=1     <- 同一批帧
blur      WARN  raw_flag= 122  bad_frames= 120  intervals=1     <- 同一批帧
freeze    WARN  raw_flag= 119  bad_frames= 119  intervals=1     <- 同一批帧
```

同一批帧被报了三次。修法是**让更具体的检测器拥有这些帧**：黑屏检测器先跑，它命中的帧从后面检测器的掩码里去掉。

```python title="robot_video_qc/detectors.py"
def detect_all(rows, detectors=None):
    if detectors is None:
        detectors = [registry()[n]() for n in ("black", "exposure", "blur", "freeze")]

    results = []
    owned = None
    for det in detectors:
        res = det.run(rows, suppressed=owned)
        results.append(res)
        mask = det.flag_frames(rows)
        # 只有通过时长门槛的帧才算"被拥有"——
        # 两帧的模糊抖动不该盖住它底下一个真实的冻结。
        if res.intervals:
            own = np.zeros(len(rows), dtype=bool)
            for iv in res.intervals:
                for i, r in enumerate(rows):
                    if iv.start_ns <= r.timestamp_ns < iv.end_ns:
                        own[i] = True
            mask = own
        owned = mask if owned is None else (owned | mask)
    return results
```

顺序也有讲究：**黑屏先于曝光**（过曝的帧是亮不是黑，两者不重叠）；**模糊先于冻结**（一个"静止的模糊画面"是合法场景，模糊才是更值得报的问题）。

## 5. 区间：半开、纳秒、合并

时间区间用**半开区间 `[start_ns, end_ns)`**，全部用整数纳秒。

```python title="robot_video_qc/intervals.py"
def find_intervals(flags, timestamps_ns, min_duration_ns=0):
    if len(flags) != len(timestamps_ns):
        raise ValueError(f"flags ({len(flags)}) and timestamps_ns ({len(timestamps_ns)}) length mismatch")
    if not flags:
        return []

    n = len(flags)
    intervals = []
    i = 0
    while i < n:
        if not flags[i]:
            i += 1
            continue
        j = i
        while j + 1 < n and flags[j + 1]:
            j += 1
        start = timestamps_ns[i]
        # 帧 [i..j] 的结束时刻是第 j+1 帧的开始时刻。
        if j + 1 < n:
            end = timestamps_ns[j + 1]
        else:
            step = timestamps_ns[-1] - timestamps_ns[-2] if n >= 2 else 0
            end = timestamps_ns[-1] + max(step, 0)
        intervals.append(Interval(int(start), int(end)))
        i = j + 1

    if min_duration_ns > 0:
        intervals = [iv for iv in intervals if iv.duration_ns >= min_duration_ns]
    return intervals
```

三点理由：

1. **半开**保证 N 个连续坏帧正好是 N 帧长，接缝处不会差一。
2. **整数纳秒**保证帧边界落在 `1/30 s` 的精确倍数上，两个共享边界的 episode 不会因为一个 float ULP 而重叠。
3. **结束时刻取下一帧的开始时刻**，而不是"这一帧的最后一毫秒"。这样单帧区间的时长是这一帧的真实时长（33.33 ms），而不是 0。

区间合并支持一个 gap 阈值：

```python title="robot_video_qc/intervals.py"
def merge_intervals(intervals, max_gap_ns=0):
    """max_gap_ns 桥接短于阈值的间隙。

    从操作员的视角，隔 0.2 秒好帧的两段坏画面是"一次事故"；
    从检测器的视角是两段。两个参数都在，才能既表达"一次事故"，
    又不谎报坏帧到底在哪。
    """
```

## 6. 标定：阈值怎么定

这是整篇文章的核心。阈值不是拍的，是**测出来的**。

### 6.1 缺陷注入

要评估检测器，得有带真值的缺陷样本。做法是拿真实视频，注入已知缺陷，重新编码：

```python title="robot_video_qc/inject.py"
def inject_video(src, dst, injections, start_frame=0, num_frames=600, fps=30.0,
                 blur_ksize=21, blur_sigma=6.0, exposure_gain=3.0,
                 seed=20261103, fourcc_name="FFV1"):
    """从 src 构造 dst，把 injections 注入进去。

    返回实际注入的列表（含解析后的帧号），调用方据此给检测器打分。
    """
```

四种注入：`black`（画面变黑）、`freeze`（重复前一帧）、`blur`（高斯卷积）、`exposure`（放大直到削顶）。全部确定性——唯一的随机种子是 `seed=20261103`，而实际上根本没用上随机数。

> **编码器必须是无损的**
>
> 第一版这里用的是 `mp4v`（有损）。结果**测试框架本身把检测器的信号给毁了**：一段被刻意冻结的画面，解码回来 `mad` 是 0.1088 而不是 0.0，`changed_ratio` 是 0.0026 而不是 0——因为有损编码器会给每一份拷贝写出略微不同的字节。
>
> 换成 FFV1（AVI，无损）之后，源视频和解码后的 `mad` 最小值、`lap_var` 最小值**完全一致**。
>
> 这一点值得单独强调：**如果你的评测框架有损，测出来的所有数字都是关于编码器的，不是关于你的检测器的。**

### 6.2 标定结果

在 900 帧的真实片段（`file-000.mp4` 的前 900 帧）上，于第 300–419 帧（10.0 s–14.0 s）注入缺陷。干净对照走**完全相同的编码路径**，只是不做修改。

```text
### black (black_ratio)
  clean     : p0.1=0.0079 p1=0.0233 p25=0.0300 p50=0.0311 p95=0.0709 p99=0.0911
  defective : p0.1=1.0000 p1=1.0000 p25=1.0000 p50=1.0000 p95=1.0000 p99=1.0000

    thr      0.1  P 1.0000  R 1.0000  F1 1.0000
    thr      0.5  P 1.0000  R 1.0000  F1 1.0000
    thr     0.99  P 1.0000  R 1.0000  F1 1.0000

### blur (lap_var)
  clean     : p0.1=9.9137 p1=26.3291 p25=33.7904 p50=35.7160 p95=47.9056 p99=65.8176
  defective : p0.1=1.0496 p1=1.0500 p25=1.1266 p50=1.1524 p95=1.1645 p99=1.1678

    thr        2  P 1.0000  R 1.0000  F1 1.0000
    thr        8  P 0.9836  R 1.0000  F1 0.9917
    thr       12  P 0.9677  R 1.0000  F1 0.9836
    thr       20  P 0.9524  R 1.0000  F1 0.9756
    thr       30  P 0.7143  R 1.0000  F1 0.8333
```

### 6.3 常见阈值错在哪

把"设计稿里的默认值"和实测标定值放在一起看：

| 指标 | 设计稿默认 | 实测结果 | 标定值 | 实测结果 |
|---|---|---|---|---|
| `black_ratio` | 0.98 | P 1.0000 R 1.0000 F1 1.0000 | **0.99** | P 1.0000 R 1.0000 F1 1.0000 |
| `lap_var` | 30.0 | P **0.7143** R 1.0000 F1 **0.8333** | **12.0** | P 0.9677 R 1.0000 F1 0.9836 |
| `mad` | 0.20 | P **0.3960** R 1.0000 F1 **0.5674** | **0.01** | P 1.0000 R 1.0000 F1 1.0000 |
| `changed_ratio` | 0.001 | P **0.4412** R 1.0000 F1 **0.6122** | **1e-06** | P 0.9375 R 1.0000 F1 0.9677 |
| `bright_ratio` | 0.80 | P 1.0000 R **0.9667** F1 0.9831 | **0.75** | P 1.0000 R 1.0000 F1 1.0000 |

**`lap_var = 30.0` 这个阈值是最有教育意义的失败案例。** 30 看起来是个合理的数——你看着直方图，觉得"低于 30 就算模糊吧"。但干净帧的 p0.1 是 9.91、p1 是 26.33，**30 落在干净分布的中间**。结果是 1680 个干净帧里误报 48 个，精确率只有 0.71。

冻结检测器更糟：`mad <= 0.20` 误报 183 个，`changed_ratio <= 0.001` 误报 152 个。两者合起来的规则精确率只有 0.48。

原因很直接：**在有损视频上，帧间差本来就很小**。干净帧的 `mad` 中位数是 0.9055，但 p0.1 是 0.0187——尾部有大量"几乎没变"的帧。`0.20` 这个阈值切在了这条尾巴上。

> **标定的通用做法**
>
> 1. 在**真实干净数据**上算指标的百分位分布；
> 2. 在**已知缺陷**上算同一指标的分布；
> 3. 找两个分布之间的**空隙**，把阈值放进去；
> 4. 用扫描确认精确率和召回率；
> 5. 在多个候选里选 F1 最高的；若多个接近，取**更保守**（标记更少）的那一个。

### 6.4 为什么标定值不是"F1 最高的那一个"

扫描工具 `best_f1()` 会在 F1 最高的候选里挑**最保守**（标记最少）的那一个。按这个规则，`lap_var` 扫到 2.0 时 F1 是 1.0000，应该选 2.0；`mad` 扫到 0.0 时 F1 是 1.0000，应该选 0.0。

但实际这两个值**没有采用**，理由是：

- `lap_var = 2.0` 是在**这一段视频**上最优的。真实机器人数据里会有比这段更暗的画面——比如机械臂伸进阴影里。`2.0` 几乎没有安全边际，一旦换数据就会大面积误报。`12.0` 落在干净分布的 p0.1（9.91）和 p1（26.33）之间，留下了余量。
- `mad = 0.0` 要求**逐位完全相同**。这是无损编码下的精确冻结，但任何有损编码都会破坏它。`0.01` 在实测数据上同样是 P/R 全 1.0000，却给编码噪声留了空间。

所以最终采用的是**在多个独立片段上验证过、且留有余量**的阈值。这不是"最优 F1"，而是"换个数据集还站得住"。

这一点在第 6.5 节的五样本验证表里可以看到：标定后的四个检测器全部精确命中真值区间，零误报零漏报——虽然 `lap_var` 和 `changed_ratio` 的 F1 分别是 0.9836 和 0.9677，而不是 1.0000。

### 6.5 最终验证

标定后的四个检测器，在五个样本上跑（干净 + 四种注入缺陷）：

```text
case       detector   status   bad  intervals  flagged range
-----------------------------------------------------------------
  clean      black      PASS       0          0  -
  clean      exposure   PASS       0          0  -
  clean      blur       PASS       0          0  -
  clean      freeze     PASS       0          0  -

  black      black      FAIL     120          1  [300,419]
  black      exposure   PASS       0          0  -
  black      blur       PASS       0          0  -
  black      freeze     PASS       0          0  -

  freeze     black      PASS       0          0  -
  freeze     exposure   PASS       0          0  -
  freeze     blur       PASS       0          0  -
  freeze     freeze     WARN     120          1  [300,419]

  blur       black      PASS       0          0  -
  blur       exposure   PASS       0          0  -
  blur       blur       WARN     120          1  [300,419]
  blur       freeze     PASS       0          0  -

  exposure   black      PASS       0          0  -
  exposure   exposure   WARN     120          1  [300,419]
  exposure   blur       PASS       0          0  -
  exposure   freeze     PASS       0          0  -

========================================================================================
Summary (each detector must fire on exactly one case, and only that one):
  black      fired on ['black']      expected ['black']      OK
  exposure   fired on ['exposure']   expected ['exposure']   OK
  blur       fired on ['blur']       expected ['blur']       OK
  freeze     fired on ['freeze']     expected ['freeze']     OK
```

**四个检测器全部精确命中真值区间 `[300, 419]`，零误报，零漏报。**

## 7. 输出契约

### 7.1 Parquet：帧级指标

```python title="robot_video_qc/contract.py"
PARQUET_SCHEMA_VERSION = "1.0"

FRAME_METRICS_SCHEMA = pa.schema([
    pa.field("episode_index", pa.int64()),
    pa.field("camera", pa.string()),
    pa.field("video_path", pa.string()),
    pa.field("frame_index", pa.int64()),
    pa.field("timestamp_ns", pa.int64()),
    pa.field("timestamp_s", pa.float64()),
    pa.field("mean_gray", pa.float64()),
    pa.field("black_ratio", pa.float64()),
    pa.field("bright_ratio", pa.float64()),
    pa.field("lap_var", pa.float64()),
    pa.field("tenengrad", pa.float64()),
    pa.field("mad", pa.float64()),
    pa.field("changed_ratio", pa.float64()),
    pa.field("schema_version", pa.string()),
])
```

Schema 是**固定的**。加一个指标就要升版本号——锁在 1.0 的消费者不应该莫名其妙看到它不认识的列。

### 7.2 JSON：episode 级判定

五种状态，刻意分得开：

| 状态 | 含义 |
|---|---|
| `PASS` | 没有检测器报警 |
| `WARN` | 有 WARN 级检测器报警——可用，但标记了 |
| `FAIL` | 有 FAIL 级检测器报警——不可直接使用 |
| `UNKNOWN` | 无法评估（文件打不开、没解出帧） |
| `INCOMPLETE` | 文件解出来了，但没覆盖预期的 episode |

`INCOMPLETE` 和 `FAIL` 分开是刻意的。**文件被截断和文件全黑是两个不同的问题、两种不同的修法**，合并成一个状态就丢掉了操作员需要的信息。

实测一份真实输出（对注入模糊的 900 帧片段）：

```json title="quality_summary.json（节选）"
{
  "schema_version": "1.1",
  "status": "WARN",
  "total_frames": 900,
  "duration_s": 30.0,
  "fps": 30.0,
  "detectors": {
    "blur": {
      "name": "blur",
      "status": "WARN",
      "severity": "WARN",
      "bad_frames": 120,
      "total_frames": 900,
      "bad_ratio": 0.133333,
      "intervals": [
        { "start_ns": 9999999900, "end_ns": 13999999860, "duration_ns": 3999999960 }
      ],
      "thresholds": { "max_lap_var": 12.0, "min_duration_s": 0.4 },
      "detail": { "min_lap_var": 1.0495, "p01_lap_var": 1.0694, "median_lap_var": 35.0216 }
    }
  },
  "file_layer": {
    "size_bytes": 57908532,
    "width": 640, "height": 360,
    "codec_name": "ffv1", "pix_fmt": "bgra",
    "container_nb_frames": 900
  }
}
```

区间是 `[9.9999999 s, 13.99999986 s)`——**精确对应真值的第 300–419 帧**（10.0 s–14.0 s）。

### 7.3 跑起来

```bash title="examples/robot-video-qc/run_qc.py"
python run_qc.py path/to/video.mp4 --camera left --episode 7 --out out/
```

实测输出：

```text
========================================================================
L1  file & decode
========================================================================
  path        C:\...\qc-lab\calib\blur.avi
  size        57,908,532 bytes
  codec       ffv1   640x360
  declared    900 frames @ 30.0 fps
  problems    none

========================================================================
L2  time & sync
========================================================================
  fps                        30.0
  monotonic                  True
  non_monotonic_positions    0
  max_gap_s                  0.033333333
  gap_count_over_threshold   0
  total_unaccounted_s        0.0
  coverage_ratio            0.998889
  problems    none

========================================================================
L3  image quality detectors
========================================================================
  black     PASS         0 / 900 frames ( 0.00%)  intervals=0
  exposure  PASS         0 / 900 frames ( 0.00%)  intervals=0
  blur      WARN       120 / 900 frames (13.33%)  intervals=1
            [10.000s, 14.000s)
  freeze    PASS         0 / 900 frames ( 0.00%)  intervals=0

========================================================================
L4  dataset usability
========================================================================
  status      WARN
  duration    30.000 s

  wrote ...\out\quality_summary.json
  wrote ...\out\frame_metrics.parquet
  parquet rows=900 columns_match=True types_match=True
```

## 8. L4：可用性判定

四层模型里，前三层给出**事实**，第四层给出**判定**。

| 层 | 问题 | 产出 |
|---|---|---|
| L1 文件与解码 | 能打开吗？声明帧数和实解帧数一致吗？ | 文件事实 + 问题列表 |
| L2 时间与同步 | 时间戳单调吗？间隔均匀吗？最大间隙多久？ | 帧率、单调性、最大间隙、覆盖率 |
| L3 画面质量 | 黑屏？冻结？模糊？曝光？ | 每类检测器的区间 + 帧数 |
| L4 任务可用性 | 这一条数据能不能用？ | `PASS` / `WARN` / `FAIL` / `UNKNOWN` / `INCOMPLETE` |

多个相机时，**最差的那个相机决定 episode 的状态**。

`coverage_ratio` 值得单独说：它衡量的是"声明的时长"和"实际的时间跨度"之比。上面那次运行的 `coverage_ratio` 是 0.998889 而不是 1.0——因为 900 帧的最后半帧不在跨度里。这个数字掉到 0.9 以下，就说明文件被截断了。

## 9. 性能

实测吞吐（本机，Windows，CPU only，单线程）：

| 阶段 | 耗时 | 说明 |
|---|---|---|
| `probe`（ffprobe + OpenCV） | 0.133 s | 每文件一次 |
| `extract_metrics`（900 帧） | **20.02 s** | **45.0 fps** |
| `detect_all` | 0.0164 s | 微秒级 |
| `check_time` | 0.0002 s | 微秒级 |
| `build_summary` | 0.0000 s | 微秒级 |

解码 31,115 帧的完整 mp4 实测 **761 秒**（左腕相机）和 **767 秒**（右腕相机）。

按 45.0 fps 算：

| 范围 | 单线程耗时 |
|---|---|
| 单相机，完整数据集 76,046,658 帧 | **19.6 天** |
| 三路相机，完整数据集 | **58.7 天** |

这说明两件事：

1. **必须并行**。`extract_metrics` 是纯 CPU、逐帧独立的任务，按文件切分到多进程/多机是直接的。
2. **必须落盘**。指标表存下来之后，重新标定阈值就是分钟级的事，而不是几十天。

### 9.1 AV1 的解码开销

把同一段视频转成 H.264 再解一遍，直接对比：

```text
  AV1 (source)             900 frames in 9.33s -> 96.4 fps
  H.264 (re-encoded)       900 frames in 0.55s -> 1640.5 fps
```

**AV1 比 H.264 慢一个数量级以上**（96.4 fps vs 1640.5 fps）。这个差距远超"AV1 压缩率高所以解码慢一点"的直觉。

需要说明的是，这个对比**不是完全公平的**：重新编码的 H.264 流用的是 `libx264 -preset veryfast -crf 23`，解码端本身没有 B 帧、分辨率和帧率完全一致，但码流特征不同。真实的 H.264 机器人数据集通常不会用 veryfast 预设，解码会慢一些。但量级上的差距是真实的。

如果数据集提供方能提供 H.264 版本，stage 1 的成本会显著下降——这是选数据集时值得问一句的事。

> **另一个观察**
>
> `extract_metrics` 的 45.0 fps 明显低于纯解码的 96.4 fps，说明**指标计算本身占了大约一半的时间**。也就是说，指标计算已经是和解码同一量级的开销，而不是"解码完了顺手算一下"。
>
> 这也是 3.2 节那 1.98 倍优化的意义所在：它直接作用在整个流程里占比最大的一半上。

## 10. 几个真实的坑

这一节记的是实现过程中真实踩到、并且在代码里留了注释的地方。

### 10.1 有损编码器会毁掉你的评测框架

前面已经详细说过：用 `mp4v` 编码注入样本时，冻结帧的 `mad` 从 0.0 变成 0.1088。**测试工具的缺陷伪装成了检测器的缺陷。** 换成 FFV1 之后，源和解码结果的 `mad` 最小值、`lap_var` 最小值完全一致。

### 10.2 第一帧没有前一帧

`mad` 和 `changed_ratio` 在第 0 帧没有意义。实现里有两处处理：

- `FreezeDetector` 显式跳过第 0 帧；
- 标定时把第 0 帧从 clean 和 defective 两侧同时剔除。

不剔除的话，第 0 帧会被算进 clean 侧，凭空制造一个假阳性。

### 10.3 一个 bug：复用了错误的序列

标定脚本的第一版里，`lap_var` 的扫描**复用了 `black_ratio` 的 clean 序列**——变量没更新。结果 `lap_var` 在阈值 2 处显示 F1 = **0.210526**（精确率 0.117647），而那是拿黑屏帧的 `black_ratio`（全是 1.0）去扫 `lap_var` 阈值得到的荒谬结果。

修正后的结果见 6.2 节：`lap_var` 在阈值 2 处确实是 F1 = 1.0000，但**在阈值 30 处掉到 0.8333**，而错误的版本在 30 处显示 0.210526。

这类 bug 的特征是：**数字看起来比正确的结果更差**。如果你不核对，就会以为"模糊很难检出"，从而放弃调阈值。

修正后的脚本对每个指标**显式指定缺陷来源**，避免这种串台：

```python title="exp_calibrate2.py"
def run_metric(key, attr, cands, higher_is_bad, skip_first=False, defect_case=None):
    bad_case = defect_case or key
    clean = series("clean", attr)
    defect = series(bad_case, attr)
    ...
```

`defect_case` 参数是显式的：每个指标都必须说清楚自己的缺陷样本来自哪个视频。

### 10.4 模糊检测器会吃掉冻结

一个"静止的模糊画面"同时满足两条规则。修法是固定检测器顺序（模糊先于冻结）并让模糊拥有那些帧。反过来（冻结先于模糊）会把模糊场景报成冻结。

### 10.5 时长门槛必须作用在**状态**上，不只是**区间**上

初版实现只要有任何一帧被标记就报 `WARN`。实测在干净片段（`clean.avi`，900 帧）上有 **2 帧**的 `lap_var` 低于阈值 12.0，落在帧索引 `[3, 4]`，跨度 **66.67 ms**。

这个抖动短于 `min_duration_s = 0.4 s`，**它根本不该成为一个区间**，整段也不该被判 `WARN`。

（顺带一提：同样这段干净数据在旧阈值 30.0 下有 **25 帧**低于阈值，散落在 **10 个 run** 里，最长的 `[2, 9]` 是 266.67 ms——这已经接近 `min_duration_s` 了。换句话说，阈值从 30 降到 12 不只是"少误报"，还把"本来会擦着门槛过关"的长 run 也一并消掉了。）

修法是让状态从**通过了时长门槛的帧**推导，而不是从原始标记数推导：

```python title="robot_video_qc/detectors.py"
def run(self, rows, suppressed=None):
    mask = self.flag_frames(rows)
    if suppressed is not None and len(suppressed) == len(mask):
        mask = mask & ~suppressed

    ivs = self.intervals(rows, mask)

    # 状态从"通过了时长门槛的帧"推导，而不是从原始标记数推导。
    # 两帧的模糊抖动短于 min_duration，不该是对这一段 episode 的警告。
    bad = _frames_in_intervals(rows, ivs) if ivs else 0
    ...
```

修复后实测：`clean.avi` 的 blur 检测器 `raw_flag=2`，但 `bad_frames=0`、`intervals=0`、状态 `PASS`。

## 11. 小结

- **四层模型**：文件 → 时间 → 画面 → 可用性，每层回答一个不同的问题。
- **一次解码，七个数值指标**（外加帧号和时间戳）。stage 2/3 是微秒级，所以阈值可以事后重调。
- **半开纳秒区间**，接缝处不会差一。
- **阈值是测出来的**。设计稿里的 `lap_var = 30.0` 在真实数据上精确率只有 0.71，`mad = 0.20` 只有 0.40。标定后的四个检测器在五样本验证里全部精确命中真值区间，零误报零漏报。
- **检测器要分所有权**，否则同一个黑屏会被报三次。
- **评测框架必须无损**，否则你测的是编码器。

下一篇会处理一个更隐蔽的问题：**两路腕部相机接反了，怎么发现？** 那种错误在帧级质检里完全看不见——每一帧都是清晰的、曝光正常的、时间戳也对。它需要跨模态的证据。

→ 继续读：[机器人视频质检（二）：跨模态摄像头配对校验](/robot-video-qc/camera-pairing/)

## 附：完整代码

本文的代码在 `examples/robot-video-qc/` 下：

```text title="examples/robot-video-qc/"
robot_video_qc/
├── __init__.py
├── metrics.py      # 指标提取（stage 1）
├── detectors.py    # 四个检测器 + 所有权仲裁
├── intervals.py    # 半开纳秒区间
├── checks.py       # L1 文件检查、L2 时间检查
├── summary.py      # L4 判定
├── contract.py     # Parquet / JSON 契约
├── calibrate.py    # 阈值扫描
├── inject.py       # 缺陷注入（无损）
├── pairing.py      # 跨模态配对（第二篇）
└── pipeline.py     # 三段式流水线
run_qc.py           # 命令行入口
```

依赖：

```bash
pip install opencv-python pyarrow numpy matplotlib
```

实测版本：Python 3.x、OpenCV 4.10.0、PyArrow 22.0.0、NumPy 2.2.6、Matplotlib 3.9.2。

## 参考

- 数据集主页（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/frame-detectors/index.mdx
