DataFlowing / processor /app /artifact_resolver.py
quyu666's picture
sync processor from GitLab ac3323f: 深度格式兼容(legacy_mm10+逐批次覆盖)+ UMI Gripper 模块与测试
af470e1
Raw History Blame Contribute Delete
7.42 kB
"""Filesystem artifact discovery shared by review APIs.
The active application reads only the canonical project-level
``data/meta/videos`` layout. Old sidecar directories are intentionally not
resolved by the runtime.
"""
from __future__ import annotations
import re
from pathlib import Path
_SOURCE_SUFFIXES = ("_depth_rgb", "_depth", "_rgb")
def _episode_index_for_id(session_dir: Path, episode_id: str) -> int | None:
try:
from app.project_dataset import episode_row
row = episode_row(Path(session_dir), str(episode_id))
return int(row.get("episode_index")) if row else None
except (TypeError, ValueError, OSError):
return None
def _safe(value: str) -> str:
return str(value or "").replace("/", "_").replace("\\", "_").strip()
def _source_variants(value: str) -> set[str]:
raw = _safe(value).lower()
if not raw:
return set()
variants = {raw, re.sub(r"[^a-z0-9_.-]+", "_", raw)}
changed = True
while changed:
changed = False
for item in tuple(variants):
for suffix in _SOURCE_SUFFIXES:
if item.endswith(suffix) and len(item) > len(suffix):
shortened = item[: -len(suffix)].rstrip("_")
if shortened not in variants:
variants.add(shortened)
changed = True
# Collectors have used both ``rgb`` and ``head_rgb`` for the
# same head-facing colour stream. Keep this alias limited to
# RGB keys; depth keys must never become interchangeable with an
# RGB stream merely because they share a device prefix.
if item == "head_rgb":
variants.add("rgb")
elif item == "rgb":
variants.add("head_rgb")
elif item.endswith("_head_rgb"):
variants.add(item[:-len("_head_rgb")] + "_rgb")
elif item.endswith("_rgb") and not item.endswith("_depth_rgb"):
variants.add(item[:-len("_rgb")] + "_head_rgb")
return variants
def source_matches(value: str | None, camera: str) -> bool:
"""Whether a manifest/file source refers to *camera*.
``D405`` and ``D405_depth_rgb`` intentionally match: the former is the
keypoint artifact stem while the latter is the uploaded video source key.
"""
return bool(_source_variants(str(value or "")) & _source_variants(camera))
def _unique(paths: list[Path]) -> list[Path]:
seen: set[str] = set()
result: list[Path] = []
for path in paths:
key = str(path)
if key not in seen:
seen.add(key)
result.append(path)
return result
def find_hand_keypoint_candidates(session_dir: Path, camera: str,
episode_id: str | None = None) -> list[Path]:
"""Find MediaPipe 2D parquet files for a camera."""
result: list[Path] = []
# Current runs merge the detector columns into the canonical episode
# parquet. Returning that file keeps the review API independent of a
# per-run output directory.
canonical = _canonical_episode_data(session_dir, episode_id, "2d")
if canonical is not None:
result.append(canonical)
return _unique(result)
def find_hand3d_candidates(session_dir: Path, camera: str,
episode_id: str | None = None) -> list[Path]:
"""Find 3D artifacts whose manifest/source maps to a camera."""
result: list[Path] = []
canonical = _canonical_episode_data(session_dir, episode_id, "3d")
if canonical is not None:
result.append(canonical)
return _unique(result)
def _canonical_episode_data(session_dir: Path, episode_id: str | None,
dimension: str) -> Path | None:
"""Return the canonical episode parquet when it contains merged results."""
if not episode_id:
return None
try:
from app.project_dataset import episode_row, episode_chunk_for_index, is_project_dataset
root = Path(session_dir)
if not is_project_dataset(root):
return None
row = episode_row(root, str(episode_id))
if row is None:
return None
index = int(row.get("episode_index", 0))
path = (root / "data" / f"chunk-{episode_chunk_for_index(index):03d}"
/ f"episode_{index:06d}.parquet")
if not path.is_file():
return None
try:
import pyarrow.parquet as pq
# Use logical top-level names; physical schema names flatten nested
# landmark lists to ``element`` and make valid 3D artifacts look
# empty.
columns = {str(name) for name in
pq.ParquetFile(path).schema_arrow.names}
except Exception:
import pandas as pd
columns = {str(name) for name in
pd.read_parquet(path, columns=[], engine="pyarrow").columns}
if dimension == "3d":
marker = any(
"landmarks_3d" in name or "world_position" in name
or "hand_3d" in name
or name.endswith("hand_left_3d")
or name.endswith("hand_right_3d")
for name in columns)
else:
marker = any("keypoints" in name or "2d_present" in name
or "hand_0_present" in name for name in columns)
return path if marker else None
except Exception:
return None
def find_depth_video(session_dir: Path, source: str,
episode_id: str | None = None) -> Path | None:
# LeRobot v2.1 is authoritative. Active project datasets must resolve
# depth only from their canonical metric video; stale PNG/raw-depth
# sidecars are deliberately not allowed to become a preview source.
try:
from app.lerobot_v21 import is_depth_source, iter_video_streams
episode_index = _episode_index_for_id(Path(session_dir), str(episode_id))
for source_key, path in iter_video_streams(Path(session_dir) / "videos"):
if (is_depth_source(source_key)
and source_matches(source_key, source)
and (episode_index is None
or f"episode_{episode_index:06d}" in path.stem)):
return path
except Exception:
pass
return None
def has_depth_source(session_dir: Path, episode_id: str | None = None) -> bool:
"""Whether an episode has a usable canonical depth video.
PNG sequences can still be found by migration utilities, but they do not
qualify as an active project's depth input or world-coordinate preview.
"""
try:
from app.lerobot_v21 import is_depth_source, iter_video_streams
from app.project_dataset import episode_row
wanted_index = None
if episode_id and (Path(session_dir) / "meta" / "episodes").is_dir():
row = episode_row(Path(session_dir), str(episode_id))
wanted_index = int(row["episode_index"]) if row else None
for source, path in iter_video_streams(Path(session_dir) / "videos"):
if not is_depth_source(source):
continue
if wanted_index is None or path.stem == f"episode_{wanted_index:06d}":
return True
except (TypeError, ValueError, OSError):
pass
return False