Skip to content

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

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

26 min read

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

数据来源: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 到真实具身数据质检。

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

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

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

四层质检模型。每一层回答一个不同的问题,上一层失败时下层不给出结论

1. 真实数据集长什么样

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

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

meta/info.json(节选)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 的长度、任务名,以及它在各路相机视频文件里的时间偏移:

读 episode 元数据python
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 行):

   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 直接挡掉。但它们只能证明“文件自洽”,不能证明“这一段示教有意义”——语义层面的判断见专题首页的说明。

2. 三段式流水线

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

robot_video_qc/pipeline.py(骨架)python
# 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 是唯一昂贵的一段。一次解码同时算出所有检测器需要的量,这样重新标定阈值时不需要碰视频。

robot_video_qc/metrics.pypython
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 倍的差距。

拉普拉斯核

K=[0101−41010]K = \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}

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

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

4. 四个检测器

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

4.1 黑屏

robot_video_qc/detectors.pypython
@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 冻结

robot_video_qc/detectors.pypython
@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 模糊

robot_video_qc/detectors.pypython
@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 曝光

三个独立故障,分开报:

robot_video_qc/detectors.pypython
@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 是经过时长门槛后仍然成立的帧数):

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     <- 同一批帧

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

robot_video_qc/detectors.pypython
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),全部用整数纳秒。

robot_video_qc/intervals.pypython
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 阈值:

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

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

6. 标定:阈值怎么定

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

6.1 缺陷注入

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

robot_video_qc/inject.pypython
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,而实际上根本没用上随机数。

6.2 标定结果

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

### 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
阈值扫描曲线,竖虚线是标定后采用的阈值。注意 blur 在 30.0 处精确率掉到 0.71——这是从直方图上随手取一个数的后果

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 这个阈值切在了这条尾巴上。

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 最终验证

标定后的四个检测器,在五个样本上跑(干净 + 四种注入缺陷):

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:帧级指标

robot_video_qc/contract.pypython
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 帧片段):

quality_summary.json(节选)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 跑起来

examples/robot-video-qc/run_qc.pybash
python run_qc.py path/to/video.mp4 --camera left --episode 7 --out out/

实测输出:

========================================================================
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 再解一遍,直接对比:

  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 的成本会显著下降——这是选数据集时值得问一句的事。

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 的特征是:数字看起来比正确的结果更差。如果你不核对,就会以为“模糊很难检出”,从而放弃调阈值。

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

exp_calibrate2.pypython
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 也一并消掉了。)

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

robot_video_qc/detectors.pypython
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。标定后的四个检测器在五样本验证里全部精确命中真值区间,零误报零漏报。
  • 检测器要分所有权,否则同一个黑屏会被报三次。
  • 评测框架必须无损,否则你测的是编码器。

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

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

附:完整代码

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

examples/robot-video-qc/text
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           # 命令行入口

依赖:

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。

参考

Type to search…

↑↓ navigate↵ selectEsc close