本系列:机器人视频质检 — 专题首页 · 本篇是(一)
数据来源: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 格式的清单文件:
{
"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 的长度、任务名,以及它在各路相机视频文件里的时间偏移:
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 0episode 的 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. 三段式流水线
质检分成三段,因为它们的成本相差三个数量级:
# 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 是唯一昂贵的一段。一次解码同时算出所有检测器需要的量,这样重新标定阈值时不需要碰视频。
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, tenengrad3.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 黑屏
@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 冻结
@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 模糊
@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 曝光
三个独立故障,分开报:
@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.5brightness_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 <- 同一批帧同一批帧被报了三次。修法是让更具体的检测器拥有这些帧:黑屏检测器先跑,它命中的帧从后面检测器的掩码里去掉。
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),全部用整数纳秒。
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三点理由:
- 半开保证 N 个连续坏帧正好是 N 帧长,接缝处不会差一。
- 整数纳秒保证帧边界落在
1/30 s的精确倍数上,两个共享边界的 episode 不会因为一个 float ULP 而重叠。 - 结束时刻取下一帧的开始时刻,而不是“这一帧的最后一毫秒”。这样单帧区间的时长是这一帧的真实时长(33.33 ms),而不是 0。
区间合并支持一个 gap 阈值:
def merge_intervals(intervals, max_gap_ns=0):
"""max_gap_ns 桥接短于阈值的间隙。
从操作员的视角,隔 0.2 秒好帧的两段坏画面是"一次事故";
从检测器的视角是两段。两个参数都在,才能既表达"一次事故",
又不谎报坏帧到底在哪。
"""6. 标定:阈值怎么定
这是整篇文章的核心。阈值不是拍的,是测出来的。
6.1 缺陷注入
要评估检测器,得有带真值的缺陷样本。做法是拿真实视频,注入已知缺陷,重新编码:
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.83336.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:帧级指标
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 帧片段):
{
"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 跑起来
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=True8. 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 天 |
这说明两件事:
- 必须并行。
extract_metrics是纯 CPU、逐帧独立的任务,按文件切分到多进程/多机是直接的。 - 必须落盘。指标表存下来之后,重新标定阈值就是分钟级的事,而不是几十天。
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 fpsAV1 比 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 的特征是:数字看起来比正确的结果更差。如果你不核对,就会以为“模糊很难检出”,从而放弃调阈值。
修正后的脚本对每个指标显式指定缺陷来源,避免这种串台:
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 也一并消掉了。)
修法是让状态从通过了时长门槛的帧推导,而不是从原始标记数推导:
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/ 下:
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。
参考
- 数据集主页(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)