Spaces:
Running
Running
File size: 28,359 Bytes
bb59211 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 | """证据采集 —— 从批次目录读出检查项需要的一切。
检查项**不碰磁盘**(见 ``checks.CheckContext`` 的 IO 注入设计)。所有读取集中在
这里,好处有两个:
* 检查项本体是纯逻辑,测试喂假证据即可,不需要真 mp4 / parquet / cv2;
* "读什么、怎么读"只有一处实现,不会出现两个检查项对同一列用不同口径。
**本模块只读** —— 不写任何文件、不改任何数据。质检出结论,不出数据。
"""
from __future__ import annotations
import glob
import re
from dataclasses import dataclass, field as dc_field
from pathlib import Path
from typing import Any, Iterable
import numpy as np
from app.processing.cleaning.checks import StreamEvidence
from app.processing.cleaning.contract import (
pose_is_valid,
channels_for, normalize_device_modality,
)
# SLAM 列的命名:``observation.slam_*`` 是默认相机;双目/多把第二路存成
# ``observation.<相机名>_slam_*``。这里按**相机**分组,每组里 trajectory 优先。
#
# 为什么不能写死两个列名:实测 Test94/episode_000003 **只有** gripper_2 前缀的
# 两列,没有不带前缀的 —— 那种集有 SLAM 数据、却一个 SLAM 检查都不跑,且不报
# 任何错。同一项目里不同集的列名不一致是常态(见 MODALITY_CHANNELS 的告诫)。
#
# 两个**不要**匹配进去的近亲:
# observation.slam_trajectory_ns —— 时间戳伴生列,不是位姿
# processing.umi_slam_action.data_4.action —— 处理缓存列,名字里恰好含 slam
_SLAM_COLUMN_RE = re.compile(r"^observation\.(?:(.+)_)?slam_(trajectory|pose)$")
# 列名 → 信道。值为**可能的列名**(含历史名)。同一信道的每个存在的列都会
# 登记成一条流(左右两路力就是两条),流名取列名最后一段。
#
# ★ 这里**不能**用 `break` 只取第一个 —— 那样左右两路力只剩一路。
# 要与别名列去重的话看 ``_COLUMN_ALIASES``,别在这里提前退出。
#
# slam 不在下表里 —— 它按相机分组解析,见 ``_slam_columns``。
_COLUMN_CHANNELS: dict[str, tuple[str, ...]] = {
"slam": (
"observation.slam_trajectory", # 高频样本缓冲(优先)
"observation.slam_pose", # 低频快照(旧批才有)
),
"gripper_state": ("observation.gripper_state",),
"force": (
"observation.gripper_left_force",
"observation.gripper_right_force",
"observation.gripper_force",
),
"tactile": (
"observation.left_glove",
"observation.right_glove",
"observation.glove",
),
"device_status": (
"status.left_glove",
"status.right_glove",
"status.glove",
),
"depth": ("observation.images.depth",),
"action": ("action",),
}
# 别名列 → 协议列名。**同一份数据的第二套命名**,不新增信道,只是改名。
#
# 触觉数据有两套名字:
# 采集协议写 observation.left_glove / right_glove
# 导出时统一成 observation.tactile.left / right
# (见 lerobot_export._TACTILE_RENAME,hdf5_export / ai_annotation 读的是后者)
#
# 原始 session 目录里两套可能同时在(导出产物被合并回 canonical);而**只跑过
# 导出的树里只有后者**。此前只认协议名 —— 那种树上手套信道直接为空,所有手套
# 检查静默跳过,不报任何错。
_COLUMN_ALIASES: dict[str, str] = {
"observation.tactile.left": "observation.left_glove",
"observation.tactile.right": "observation.right_glove",
}
# 各信道的向量宽度 —— 用于把列值切成定长元组。
#
# ★ 不在表里的信道**不截断**(见 ``vector_series``)。默认截断到某个数字是
# 危险的:触觉阵列是 16×16=256 维,此前因为这里没有 ``tactile`` 项而落到
# 3 的兜底值,256 个感应点被静默截成 3 个 —— 检查项只看得到 1% 的阵列。
# 换手套(或加新阵列通道)时同样会静默丢数据,所以默认必须是"不截断"。
_CHANNEL_WIDTH: dict[str, int] = {
"gripper_state": 3,
"force": 3,
"action": 7,
}
def _apply_column_aliases(frame):
"""把别名列归一成协议列名。
**只在协议名缺失时改** —— 两套都在时保留协议名:它们是同一份数据,
登记成两条流会让同一处缺陷被报两次,也让"左右手"的流名对不上。
(实测 S80C / D435 两套都在时逐元素完全相同。)
"""
rename = {alias: canonical for alias, canonical in _COLUMN_ALIASES.items()
if alias in frame.columns and canonical not in frame.columns}
return frame.rename(columns=rename) if rename else frame
_DEFAULT_FPS = 30.0
_MAX_VIDEO_PROBES = 4
@dataclass(frozen=True)
class Evidence:
"""一个 episode 的全部证据 —— engine 交给检查项的那一份。"""
batch_dir: Path
streams: tuple[StreamEvidence, ...] = ()
channels: frozenset[str] = frozenset()
modalities: tuple[str, ...] = ()
fps: float = _DEFAULT_FPS
rows: int = 0
columns: tuple[str, ...] = ()
parquet: str = ""
notes: dict[str, Any] = dc_field(default_factory=dict)
@property
def duration_sec(self) -> float:
return (self.rows / self.fps) if self.fps else 0.0
# ── 读文件 ────────────────────────────────────────────────────
def _glob(pattern: str, root: Path) -> list[Path]:
return [Path(item) for item in sorted(glob.glob(str(root / pattern)))]
def _file_holding(parquets: list[Path], episode_index: int) -> Path | None:
"""哪个文件里装着这一集 —— 只读 ``episode_index`` 一列,不整份加载。
文件名对不上时的兜底(历史命名、手工搬过的目录)。返回 None 表示都没有。
"""
import pyarrow.parquet as pq
for path in parquets:
try:
column = pq.ParquetFile(path).read(columns=["episode_index"])
except Exception:
continue
values = column.column("episode_index").to_pylist()
if episode_index in values:
return path
return None
def resolve_episode_data(batch_dir: Path,
episode_index: int | None = None) -> tuple[Path | None, bool]:
"""定位这一集的数据文件。返回 ``(parquet, 还要不要按行过滤)``。
两种布局的粒度**不一样**,混了就会读出空帧:
canonical ``data/chunk-*/episode_NNNNNN.parquet`` —— 一集一文件。
选对**文件**即可,行全是这一集的。
导出产物 ``data/chunk-*/file-NNN.parquet`` —— 一文件多集,
必须再按 ``episode_index`` 列筛行。
★ 这里曾经是「取 ``parquets[0]`` 再按列过滤」。canonical 布局下第 0 个文件
只装第 0 集,于是传任何**别的** ``episode_index`` 都筛出空帧 —— 不报错,
只是报告变成 ``empty_report``(``episode.status = ERROR``)。在"数据不对
就拦回人工审核"的链路里,表现是**除第 0 集外每一集都被卡住**。
"""
root = Path(batch_dir)
canonical = _glob("data/chunk-*/episode_*.parquet", root)
if canonical:
if episode_index is None:
return canonical[0], False
# 文件名就是集号(LeRobot canonical 约定,注意编号可能有空洞)
wanted = f"episode_{int(episode_index):06d}.parquet"
for path in canonical:
if path.name == wanted:
return path, False
# 文件名对不上 —— 按列找,别静默返回空
return _file_holding(canonical, int(episode_index)), False
multi = _glob("data/chunk-*/*.parquet", root)
if not multi:
return None, False
if episode_index is None:
return multi[0], False
return _file_holding(multi, int(episode_index)), True
def load_rows(parquet: Path, filter_episode_index: int | None = None):
"""读 parquet;给了 ``filter_episode_index`` 就只取那一集的行。
只在**一文件多集**的布局下才该传它 —— canonical 的一集一文件布局下传了会
把这一集自己的行全筛掉。用 ``resolve_episode_data`` 拿到的第二个返回值决定。
"""
import pyarrow.parquet as pq
frame = pq.ParquetFile(parquet).read().to_pandas()
if filter_episode_index is not None and "episode_index" in frame.columns:
frame = frame[frame["episode_index"].values == filter_episode_index]
return frame.reset_index(drop=True)
# ── 列 → 序列 ─────────────────────────────────────────────────
def slam_valid_counts(values) -> tuple[int, ...]:
"""每帧的有效位姿样本数 —— 空帧即为 0。
slam_trajectory 是扁平的 ``[t,x,y,z,qx,qy,qz,qw]×N``。导出产物会把它补齐到
定长(补 NaN),所以"有效"= 非 NaN 的 8 元组个数。
"""
counts: list[int] = []
for item in values:
if item is None:
counts.append(0)
continue
array = np.asarray(item, dtype=float).ravel()
valid = 0
for index in range(array.size // 8):
if np.isfinite(array[index * 8:index * 8 + 8]).all():
valid += 1
counts.append(valid)
return tuple(counts)
def slam_poses(values, counts) -> tuple[tuple, ...]:
"""逐帧取【第一个有效样本】的位姿(位置 + 四元数)。
★ 刻意不重用 ``umi_slam_action.poses_from_trajectory`` 的插值重建 ——
那是生产口径(含时间基拟合与插值)。连续性检查要看的是**原始落盘数据**
有没有跳变;用插值后的平滑轨迹会掩盖真实跳变。
"""
poses: list[tuple] = []
for item, count in zip(values, counts):
if not count:
poses.append(())
continue
array = np.asarray(item, dtype=float).ravel()
poses.append(tuple(float(v) for v in array[1:8]))
return tuple(poses)
def _slam_columns(columns) -> list[str]:
"""按**相机**挑出 SLAM 列,每个相机一个:trajectory 优先,回落到 pose。
相机名 = ``observation.`` 与 ``_slam_`` 之间那段,默认相机没有前缀:
observation.slam_trajectory → 相机 ""
observation.gripper_2_slam_trajectory → 相机 "gripper_2"
每个相机只取一个 —— trajectory 是高频样本缓冲、pose 是低频快照,
两个都有时用前者(与旧行为一致,只是现在**每个相机各自**取一个,
而不是全局只取一个)。
"""
by_camera: dict[str, dict[str, str]] = {}
for column in sorted(str(name) for name in columns):
match = _SLAM_COLUMN_RE.match(column)
if not match:
continue
camera, kind = match.group(1) or "", match.group(2)
by_camera.setdefault(camera, {})[kind] = column
picked: list[str] = []
for camera in sorted(by_camera):
kinds = by_camera[camera]
picked.append(kinds.get("trajectory") or kinds["pose"])
return picked
def _column_series(values, channel: str, width: int | None = None) -> tuple:
"""一列 → 序列。**字符串列原样保留,不试着数值化。**
``vector_series`` 对字符串会走 ``np.asarray(..., dtype=float)`` 抛
``ValueError``,被 ``except`` 吞掉后**每帧都变成空元组** —— 检查项拿到的是
"这一列没有数据",看起来像"检查通过"。设备状态列(``status.left_glove``)
正是字符串,所以这里必须先分流。
"""
if len(values) and isinstance(values.iloc[0], str):
return tuple("" if item is None else str(item) for item in values)
if width is None:
width = _CHANNEL_WIDTH.get(channel)
return vector_series(values.tolist(), width)
def vector_series(values, width: int | None = None) -> tuple[tuple, ...]:
"""把一列向量值转成元组序列;空/坏的写成空元组。
``width=None``(默认)**不截断**。截断是静默的 —— 调用方只有显式声明了
正确宽度时才该发生(如 action 固定 7 维);未知列宽一律保留整条,
免得"少了一半感应点"这种问题要等到训练效果不对才发现。
"""
out: list[tuple] = []
for item in values:
if item is None:
out.append(())
continue
try:
array = np.asarray(item, dtype=float).ravel()
if width is not None:
array = array[:width]
out.append(tuple(float(v) for v in array))
except (TypeError, ValueError):
out.append(())
return tuple(out)
def _video_files(video_dir: Path) -> list[Path]:
"""列出视频目录下的 mp4(按名字排序,稳定可预期)。"""
root = Path(video_dir)
if not root.is_dir():
return []
return sorted(path for path in root.rglob("*.mp4") if path.is_file())
def videos_for_episode(root: Path, episode_index: int | None) -> list[Path]:
"""**这一集自己的**视频文件。取不到就返回空 —— 绝不猜。
★ 不能"把 videos/ 下的 mp4 都拿来用":v3.0 导出把 52 集的视频全放在
``videos/chunk-000/<流名>/file-NNN.mp4`` 同一个目录里,随便取几个会拿
别的集的画面去比对这一集的数据行数,得出荒谬的丢帧结论(实测出现过
"视频 9164 帧 / 数据 819 行")。
两种布局各有映射来源:
* canonical —— ``videos/<流名>/chunk-XXX/episode_XXXXXX.mp4``,
目录本身就对应这一集;有 ``project_dataset.episode_files`` 时用它。
* 导出产物 —— ``meta/episodes`` 里逐流记着 ``chunk_index``/``file_index``。
"""
root = Path(root)
if episode_index is None:
# 一集一文件的布局:videos/ 下就是这一集的全部视频
return _video_files(root / "videos")
# canonical:走既有索引
try:
from app.project_dataset import episode_files
entry = episode_files(root, episode_index) or {}
found: list[Path] = []
for item in entry.get("videos") or []:
# episode_files 返回 (source_key, path) 的形式
path = item[1] if isinstance(item, (tuple, list)) and len(item) > 1 else item
candidate = Path(path)
if candidate.is_file():
found.append(candidate)
if found:
return found
except Exception:
pass
# 导出产物:查 meta/episodes 的逐流索引
return _videos_from_episode_meta(root, episode_index)
def _videos_from_episode_meta(root: Path, episode_index: int) -> list[Path]:
"""从 ``meta/episodes`` 的逐流 chunk/file index 拼出视频路径。"""
import pyarrow.parquet as pq
for path in sorted(glob.glob(str(root / "meta" / "episodes" / "**" / "*.parquet"),
recursive=True)):
try:
frame = pq.ParquetFile(path).read().to_pandas()
except Exception:
continue
if "episode_index" not in frame.columns:
continue
rows = frame[frame["episode_index"].values == episode_index]
if rows.empty:
continue
# 列名形如 videos/<流名>/chunk_index 与 videos/<流名>/file_index
columns = {str(name) for name in frame.columns}
streams = sorted({
name[len("videos/"):-len("/chunk_index")]
for name in columns
if name.startswith("videos/") and name.endswith("/chunk_index")
})
row = rows.iloc[0]
found: list[Path] = []
for stream in streams:
try:
chunk = int(row[f"videos/{stream}/chunk_index"])
file_index = int(row[f"videos/{stream}/file_index"])
except (KeyError, TypeError, ValueError):
continue
candidate = (root / "videos" / f"chunk-{chunk:03d}" / stream
/ f"file-{file_index:03d}.mp4")
if candidate.is_file():
found.append(candidate)
if found:
return found
return []
def _is_depth_key(name: str) -> bool:
"""这个流名是不是纯深度流。
复用 ``lerobot_v21.is_depth_source`` —— 深度口径在这个仓库里有好几处实现
(``device_naming.is_depth_only_key`` 是同一套),但权威定义在那儿。
导入失败时回落到保守判断:名字含 depth 且不含 rgb/color。
"""
try:
from app.lerobot_v21 import is_depth_source
return bool(is_depth_source(name))
except Exception:
low = str(name or "").lower()
return "depth" in low and not any(
token in low for token in ("rgb", "color", "video"))
# ── 主入口 ────────────────────────────────────────────────────
def gather_evidence(batch_dir: Path, *,
episode_index: int | None = None,
probe_video: bool = False,
modality_hint: str | Iterable[str] | None = None) -> Evidence:
"""读一个 episode 的全部证据。
``probe_video=False``(默认)只读 parquet,毫秒级;视频检查要解码抽样,
一集几秒到几十秒,按需开。
``modality_hint`` 用于告诉采集层"这台设备是什么"(来自工作流连线的设备
卡片)。**可以是多个** —— 一个质检节点上游同时连相机和夹爪时就有多个。
给不出时按数据特征推断;两个来源取并集,理由见 ``_detect_modalities``。
★ ``batch_dir`` 必须是**单个 episode 的目录**,且 ``episode_index`` 必须
与它匹配。``localstore.get_episode()`` 返回的 ``path`` 是**项目目录**
(几十集共享),直接传进来会拿第 0 集的 parquet 去比全部集的视频
—— 实测过,会得出"视频 9164 帧 / 数据 819 行"。见 ``videos_for_episode``。
"""
root = Path(batch_dir)
parquet, filter_rows = resolve_episode_data(root, episode_index)
if parquet is None:
return Evidence(batch_dir=root, notes={"reason": "no_parquet"})
# ★ 按行过滤只在"一文件多集"的布局下做。canonical 是一集一文件,文件已经
# 选对了,再按 episode_index 列筛会把这一集自己的行全筛空 —— 结果是不报
# 任何错的 empty_report。见 resolve_episode_data。
frame = load_rows(parquet, episode_index if filter_rows else None)
if frame.empty:
return Evidence(batch_dir=root, parquet=str(parquet.relative_to(root)),
notes={"reason": "empty_episode"})
# 别名列先归一成协议名,再算 columns —— 必须在下面所有列名判断之前,
# 否则 observation.tactile.* 会被当成"这列不存在"
frame = _apply_column_aliases(frame)
columns = {str(name) for name in frame.columns}
fps = _detect_fps(root)
rows = len(frame)
streams: list[StreamEvidence] = []
channels: set[str] = {"time"}
def _relative(path: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
# SLAM —— **每个相机一路**,各取 trajectory 优先(见 _slam_columns)。
# 这里曾经是 `break`:全局只登记一路,双目集第二路的 SLAM 从来没被采集过。
for column in _slam_columns(columns):
values = frame[column].tolist()
series: dict[str, Any] = {}
if column.endswith("trajectory"):
counts = slam_valid_counts(values)
series["valid_count"] = counts
series["pose"] = slam_poses(values, counts)
# 覆盖率必须走生产口径 —— "插值帧"的定义就是 poses_from_trajectory
# 里本地与相邻格子都够不到的那些,只读原始数据推不出来。
try:
from app.umi_slam_action import poses_from_trajectory
stamps = (frame["timestamp"].to_numpy(dtype=float)
if "timestamp" in columns
else np.arange(rows) / fps)
diag: dict[str, Any] = {}
poses_from_trajectory(values, stamps, rows, diagnostics=diag)
series["coverage"] = diag
except Exception as exc:
series["coverage_error"] = str(exc)[:200]
else:
poses = vector_series(values, 7)
series["valid_count"] = tuple(
1 if pose_is_valid(item) else 0 for item in poses)
series["pose"] = poses
# 流名取列名最后一段 —— 多相机时是 "gripper_2_slam_trajectory",
# 单相机时是 "slam_trajectory",两条流不会撞名。
streams.append(StreamEvidence(
key=column.rsplit(".", 1)[-1],
channel="slam", path=_relative(parquet),
columns=(column,), rows=rows, fps=fps, series=series,
))
channels.add("slam")
# 其余标量/向量列
#
# ★ series 的键一律用【信道名】。检查项按信道取序列(``series["force"]``),
# 如果这里用列名(``series["gripper_left_force"]``),检查项就静默地取不到
# 数据、全部判通过 —— 而且不报任何错。左右两路力共用 ``force`` 键,
# 靠 stream.key 区分。
for channel, candidates in _COLUMN_CHANNELS.items():
if channel == "slam":
continue
for column in candidates:
if column not in columns:
continue
streams.append(StreamEvidence(
key=column.rsplit(".", 1)[-1], channel=channel,
path=_relative(parquet), columns=(column,), rows=rows, fps=fps,
# 走 _column_series:宽度取不到就**不截断**(兜底成某个数字会
# 静默丢数据),字符串列原样保留(数值化会变成全空元组)
series={channel: _column_series(frame[column], channel)},
))
channels.add(channel)
# 视频 —— ★ 无论检不检查,都要登记成一条流。
#
# 只登记不检查时 ``video`` 是空 dict(视频检查项会自行跳过),但信道
# ``rgb`` 必须出现 —— 否则报告会写成"未采集 rgb",而实际上视频就在那儿,
# 只是这一轮没去解码。那会让"训练用途"给出错误的结论。
video_root = root / "videos"
for path in videos_for_episode(root, episode_index)[:_MAX_VIDEO_PROBES]:
report: dict[str, Any] = {}
if probe_video:
try:
from app.video_quality import _check_stream
report = _check_stream(path, rows, fps)
except Exception as exc:
report = {"passed": False, "reason": "video_check_failed",
"error": str(exc)[:200]}
# 流名 = 流名目录那一层(observation.images.xxx),**不是** chunk-XXX。
# 两种布局的层级顺序相反,所以不能写死取第几段:
# canonical/v2.1 videos/<流名>/chunk-000/episode_000000.mp4
# v3.0 videos/chunk-000/<流名>/file-000.mp4
# 取"路径里第一个不是 chunk-* 的目录段",两边都成立。
# 写错的话所有视频流会同名(都叫 chunk-000),报告分不清哪路相机,
# 且下游按 stream 去重时会互相覆盖。
try:
relative = str(path.relative_to(root))
segments = path.relative_to(video_root).parts[:-1] # 去掉文件名
key = next((seg for seg in segments
if not seg.startswith("chunk-")), path.stem)
except ValueError:
relative, key = str(path), path.stem
# ★ 深度流不能算作 rgb 信道。之前一律按 rgb 登记,结果 D435 项目的
# 深度视频引出了"视频 9164 帧 / 数据 819 行"这种荒谬的丢帧结论 ——
# 那是拿深度流跟 rgb 数据行数比。判定复用 lerobot_v21 的既有口径,
# 不另起一套字符串匹配。
channel = "depth" if _is_depth_key(key) else "rgb"
streams.append(StreamEvidence(
key=key, channel=channel, path=relative,
rows=rows, fps=fps, video=report,
))
channels.add(channel)
modalities = _detect_modalities(channels, modality_hint)
return Evidence(
batch_dir=root, streams=tuple(streams), channels=frozenset(channels),
modalities=modalities, fps=fps, rows=rows,
columns=tuple(sorted(columns)), parquet=_relative(parquet),
)
def _detect_fps(root: Path) -> float:
"""从 meta/info.json 取 fps;拿不到就用默认 30。"""
import json
for relative in ("meta/info.json", "info.json"):
candidate = root / relative
if not candidate.is_file():
continue
try:
data = json.loads(candidate.read_text(encoding="utf-8"))
value = float(data.get("fps") or 0)
if 0 < value < 1000:
return value
except Exception:
continue
return _DEFAULT_FPS
def hint_values(hint: str | Iterable[str] | None) -> list[str]:
"""把 hint 归一成字符串列表 —— 调用方有单数(旧调用)也有复数(工作流连线)。"""
if hint is None:
return []
if isinstance(hint, str):
return [hint] if hint.strip() else []
try:
return [str(item) for item in hint]
except TypeError:
return [str(hint)]
def _infer_modalities(channels: set[str]) -> list[str]:
"""只看数据特征能推断出什么。
★ 永远给不出 ``stereo_rgb`` / ``stereo_rgbd_camera`` —— "立体"不是列名里
能看见的特征。这两个模态**只能**由工作流连线给出。所以它们是
``_detect_modalities`` 取并集、而不是二选一的主要理由。
"""
if "slam" in channels or "gripper_state" in channels:
return ["gripper_device"]
if "tactile" in channels:
return ["glove_sensor"]
if "depth" in channels:
return ["rgbd_camera"]
if "rgb" in channels:
return ["mono_rgb"]
return []
def _detect_modalities(channels: set[str],
hint: str | Iterable[str] | None) -> tuple[str, ...]:
"""这台设备是什么 —— hint(工作流连线)与数据特征取**并集**。
两个来源各有各的盲区,缺一个都会让检查静默不跑:
只有 hint → 按数据模态声明的检查项不跑(将来接 ``depth.*`` 时,
D435 数据在但模态被写成 stereo_rgb 就查不到)
只有推断 → 工作流里那张卡片的 tab 键查不到,**用户在设置面板里配的
阈值被静默忽略、一律走默认值**(推断不出 stereo_*)
并集对"配置查找"和"检查项筛除"都更宽 —— 而本项目反复踩的坑恰恰是
"检查项静默不跑",所以宁可宽。
hint 在前:``engine.resolve_config`` 按这个顺序合并各设备卡片的配置,
"先出现的优先",工作流连线的顺序才是用户预期的顺序。
"""
from app.processing.cleaning.contract import DEVICE_MODALITIES
ordered: dict[str, None] = {}
for raw in hint_values(hint):
canonical = normalize_device_modality(raw)
if canonical in DEVICE_MODALITIES:
ordered.setdefault(canonical, None)
for modality in _infer_modalities(channels):
ordered.setdefault(modality, None)
return tuple(ordered)
def channels_from_modalities(modalities) -> tuple[str, ...]:
"""设备模态 → 该设备可能提供的信道(转调 contract,供 engine 用)。"""
return channels_for(modalities)
|