"""LeRobot v2.1 storage helpers used by the upload pipeline. The collector currently sends a small v3-style archive for one episode. The server normalizes each upload into the project-level ``sessions/`` dataset and appends it to the v2.1 layout: data/chunk-000/episode_XXXXXX.parquet meta/{info.json,stats.json,tasks.json,episodes/chunk-*/episode_*.parquet,...} videos/observation.images./chunk-000/episode_XXXXXX.mp4 Workflow results are merged into the corresponding episode parquet files under the canonical ``data/`` and ``meta/`` directories. Preview videos are rendered in the browser and are never stored in the dataset. """ from __future__ import annotations import json import math import re import shutil import subprocess from uuid import uuid4 from pathlib import Path from typing import Any, Iterable VIDEO_SUFFIXES = {".mp4", ".mkv", ".mov", ".avi", ".m4v", ".webm"} DEPTH_PNG_SUFFIX = ".png" # Canonical depth-video contract. The stored gray12le samples are logarithmic # depth codes, never JET/RGB pixels and never direct millimetres. DEPTH_MIN_MM = 100.0 DEPTH_MAX_MM = 5000.0 DEPTH_QMAX = 4095 DEPTH_QP = 6 DEPTH_VIDEO_ENCODING = "depth_mm_log_to_gray12le" # Kept as aliases for callers that imported the old names. DEPTH_VIDEO_MAX_MM = DEPTH_QMAX DEPTH_PREVIEW_MIN_MM = DEPTH_MIN_MM DEPTH_PREVIEW_MAX_MM = DEPTH_MAX_MM EPISODES_METADATA_CHUNK_SIZE = 1000 _LOG_LO = math.log(DEPTH_MIN_MM) _LOG_SPAN = math.log(DEPTH_MAX_MM) - _LOG_LO _LOG_STEP = DEPTH_QMAX / _LOG_SPAN # Historical collectors used ``D435_depth_rgb`` for the colour stream of a # D435. It is not a depth stream (``is_depth_source`` already excludes it), # but keeping that name in the canonical dataset makes the RGB/depth split # ambiguous. New uploads and legacy-path discovery use the clear key below. SOURCE_ALIASES = { "d435_depth_rgb": "D435_rgb", "d435_head_rgb": "D435_rgb", "d435_head_depth": "D435_depth", } DEVICE_NAME_ALIASES = { "d435_depth": "D435", "d435_head": "D435", } def depth_video_encoder_args() -> list[str]: """Return the exact FFmpeg arguments for canonical depth video output. keyint=2:官方 pyav 解码按"最近关键帧向后 seek"取帧,大 GOP(默认 250)时 seek 会越过目标帧落在下一个关键帧 → FrameTimestampError (实测 870 帧视频仅 4 个关键帧,viz/训练在关键帧前 1-2 帧崩溃)。 官方录制管线 GOP=2,这里对齐。 """ return [ "-pix_fmt", "gray12le", "-tag:v", "hvc1", "-x265-params", f"qp={DEPTH_QP}:range=full:keyint=2:min-keyint=2", ] def quantize_depth(depth_mm): """Convert uint16 millimetres to the canonical 12-bit log depth code.""" import numpy as np mm = np.asarray(depth_mm, dtype=np.float64) valid = mm > 0 codes = np.zeros(mm.shape, dtype=" list[list[int]]: """N-entry Google Turbo colormap (RGB), generated once per process. The frontend uses 256 entries; the preview transcode uses a 4096-entry cube so the range mapping keeps 12-bit precision (a 256-entry LUT in the 8-bit domain caused visible banding on smooth depth gradients). """ global _TURBO_LUT if size == 256 and _TURBO_LUT is not None: return _TURBO_LUT lut = [] for i in range(size): x = i / max(1, size - 1) rgb = [] for channel in ("r", "g", "b"): c = _TURBO_POLY[channel] v = c[0] + x * (c[1] + x * (c[2] + x * (c[3] + x * (c[4] + x * c[5])))) rgb.append(int(max(0, min(255, round(v * 255))))) lut.append(rgb) if size == 256: _TURBO_LUT = lut return lut def turbo_lut_256() -> list[list[int]]: """256-entry Google Turbo colormap (RGB), generated once per process.""" return turbo_lut(256) def _depth_video_info_from_path(path: Path) -> dict[str, Any]: """Read depth metadata beside a canonical video when available.""" path = Path(path) source = next( (part[len("observation.images."):] for part in path.parts if part.startswith("observation.images.")), path.parent.name, ) for parent in (path.parent, *path.parents): info_path = parent / "meta" / "info.json" if not info_path.is_file(): continue info = _read_json(info_path) feature = (info.get("features") or {}).get( f"observation.images.{source}", {}) video_info = dict(feature.get("video_info")) if isinstance(feature, dict) and isinstance(feature.get("video_info"), dict) else {} # 逐批次覆盖:episodes 索引行可声明 video.depth_encoding, # 用于"同一会话混合新旧格式"的历史数据(如 9/4 对数批次与 # 8 月 mm/10 批次并存于一个会话)。 try: import pyarrow.parquet as pq idx = info_path.parent / "episodes" / "chunk-000" / f"{path.stem}.parquet" if idx.is_file(): row = pq.read_table(idx).to_pandas().iloc[0] override = row.get("video.depth_encoding") if override and str(override).strip(): video_info["video.depth_encoding"] = str(override).strip() except Exception: pass return video_info return {} def _depth_video_mode(video_info: dict[str, Any]) -> str: """Return ``log``, ``legacy_log``, ``legacy_mm10`` or ``direct_mm`` for a stored stream.""" if not isinstance(video_info, dict): video_info = {} encoding = str(video_info.get("video.depth_encoding") or "").lower() # 8 月老采集端深度按 0.1 系数存储(毫米/10),显式声明为 legacy_mm10。 # 该检查必须在 uint16_mm 检查之前,否则会被当成直接毫米读出 10 倍偏小。 if "mm10" in encoding or "mm/10" in encoding: return "legacy_mm10" # Older uploads explicitly identify the gray12le samples as direct # millimetres. This check must happen before the generic gray12le # fallback below; otherwise a direct-mm stream is interpreted as a log # code stream and values such as 500 mm are decoded to the wrong depth. if "uint16_mm" in encoding and "gray12le" in encoding: return "direct_mm" if encoding == DEPTH_VIDEO_ENCODING or "log_to_gray12le" in encoding: return "log" # The current collector contract stores the logarithmic 12-bit code # directly in gray12le. Some older ``meta/info.json`` files describe the # transport as ``uint16_mm_to_gray12le_clipped_4095mm`` even though the # pixels are already codes, not millimetres. A pure depth gray12le # stream is therefore log-coded by default; otherwise values such as # code=1511 are incorrectly treated as 1511 mm instead of ~420 mm. if (video_info.get("video.is_depth_map") and "gray12le" in encoding): return "log" if video_info.get("video.use_log") and ( video_info.get("video.depth_min") is not None and video_info.get("video.depth_max") is not None): return "legacy_log" # Existing project videos were written as direct millimetres. Keeping # this fallback prevents an old recording from being silently interpreted # as a new logarithmic code stream before it is explicitly migrated. return "direct_mm" def _read_json(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): return {} return value if isinstance(value, dict) else {} def _write_json(path: Path, value: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") def _safe_source_key(value: str) -> str: value = str(value or "").strip() if value.startswith("observation.images."): value = value[len("observation.images."):] value = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") return value or "camera" def canonical_source_key(value: str) -> str: """Return the stable source key used by the v2.1 dataset. This is deliberately a narrow compatibility map. Generic ``*_rgb`` and ``*_depth`` names must remain distinct because they describe different physical streams and are used by workflow matching. """ safe = _safe_source_key(value) return SOURCE_ALIASES.get(safe.lower(), safe) def canonical_device_name(value: str, kind: str = "", key: str = "") -> str: """Return a stable hardware name without changing stream identifiers.""" text = str(value or "") if (text.strip().lower() in DEVICE_NAME_ALIASES and "d435" in f"{kind} {key}".lower()): return DEVICE_NAME_ALIASES[text.strip().lower()] return text def normalize_metadata_sources(document: Any) -> dict[str, Any]: """Normalize source aliases and duplicate physical-device records. This runs on each newly uploaded episode before it is merged into the project dataset, so an old collector cannot reintroduce ``D435_head_*`` keys or a second device entry after a one-time migration. """ if not isinstance(document, dict): return document result = dict(document) for field in ("features", "cameras", "device_names", "video_extensions"): value = result.get(field) if not isinstance(value, dict): continue mapped: dict[str, Any] = {} for raw_key, child in value.items(): key = str(raw_key) prefix = "observation.images." source = key[len(prefix):] if field == "features" and key.startswith(prefix) else key canonical = canonical_source_key(source) new_key = prefix + canonical if field == "features" and key.startswith(prefix) else canonical # A canonical entry is authoritative if both old and new names # exist in one metadata document. if new_key not in mapped or source == canonical: mapped[new_key] = child result[field] = mapped devices = result.get("devices") if isinstance(devices, list): merged: list[dict[str, Any]] = [] positions: dict[str, int] = {} for raw in devices: if not isinstance(raw, dict): continue item = dict(raw) kind = str(item.get("kind") or "") device_key = str(item.get("key") or item.get("id") or "") if "name" in item: item["name"] = canonical_device_name(item["name"], kind, device_key) slots = [canonical_source_key(slot) for slot in item.get("slots") or [] if str(slot).strip()] item["slots"] = list(dict.fromkeys(slots)) for field in ("resolution", "fps"): mapping = item.get(field) if isinstance(mapping, dict): item[field] = { canonical_source_key(k): v for k, v in mapping.items() } if device_key and device_key in positions: current = merged[positions[device_key]] current["slots"] = list(dict.fromkeys( [*(current.get("slots") or []), *(item.get("slots") or [])] )) for field in ("resolution", "fps"): current.setdefault(field, {}) current[field].update(item.get(field) or {}) for field, value in item.items(): if field not in {"slots", "resolution", "fps"} and not current.get(field): current[field] = value continue if device_key: positions[device_key] = len(merged) merged.append(item) result["devices"] = merged device_names = result.get("device_names") if isinstance(device_names, dict): result["device_names"] = { canonical_source_key(stream): canonical_device_name( display, "d435", str(stream), ) for stream, display in device_names.items() } cameras = result.get("cameras") if isinstance(cameras, dict): normalized_cameras = {} for stream, value in cameras.items(): if isinstance(value, dict): value = dict(value) for field in ("device", "device_name"): if field in value: value[field] = canonical_device_name( value[field], str(value.get("type") or "d435"), str(stream), ) normalized_cameras[canonical_source_key(stream)] = value result["cameras"] = normalized_cameras return result def source_key_from_video(path: Path, videos_root: Path | None = None) -> str: """Extract the source key from legacy, v2.1 and collector-v3 paths.""" path = Path(path) if videos_root is None: parts = path.parts try: index = max(i for i, value in enumerate(parts) if value == "videos") videos_root = Path(*parts[:index + 1]) except ValueError: videos_root = path.parent try: relative = path.relative_to(videos_root) parts = relative.parts except ValueError: parts = path.parts # v2.1: videos/observation.images.left/chunk-000/episode_000000.mp4 # v3.0: videos/chunk-000/observation.images.left/file-000.mp4 for part in parts[:-1]: if part.startswith("observation.images."): return canonical_source_key(part) # Collector source directories may omit the observation.images prefix. # Keep parsing them for normalization, but all newly written output uses # one of the two canonical layouts above. if len(parts) >= 2: candidate = parts[-2] if candidate.startswith("chunk-") or candidate.startswith("chunk_"): candidate = parts[-3] if len(parts) >= 3 else path.stem return canonical_source_key(candidate) return canonical_source_key(path.stem) def is_depth_source(source_key: str | None) -> bool: """Return true only for a pure depth stream, not ``*_depth_rgb``.""" value = str(source_key or "").lower().strip() if not value or "depth" not in value: return False return not any(token in value for token in ("rgb", "color", "video")) def iter_video_streams(videos_root: Path) -> list[tuple[str, Path]]: """Return one stable ``(source_key, path)`` entry per video stream. The normalizer writes one file per source for a single uploaded batch. If a malformed archive contains multiple files for one source, the caller can detect the duplicate instead of silently mixing episodes. """ if not videos_root.is_dir(): return [] result: list[tuple[str, Path]] = [] for path in sorted(videos_root.rglob("*")): if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES: result.append((source_key_from_video(path, videos_root), path)) return result def _episode_index_from_name(batch_name: str) -> int | None: match = re.search(r"_(\d{6})$", str(batch_name)) return int(match.group(1)) if match else None def _data_parquets(root: Path) -> list[Path]: data_root = root / "data" if not data_root.is_dir(): return [] return sorted( path for path in data_root.rglob("*.parquet") if path.is_file() and "meta" not in path.parts ) def _load_frame_table(paths: list[Path]): try: import pandas as pd except ImportError as exc: raise RuntimeError("pandas is required for LeRobot v2.1 normalization") from exc candidates = [] for path in paths: try: table = pd.read_parquet(path) except Exception as exc: print(f"[LeRobot v2.1] skip unreadable parquet {path.name}: {exc}") continue if "frame_index" in table.columns: candidates.append((len(table), path, table)) if not candidates: raise RuntimeError("No data parquet containing frame_index was found") # The main frame table is normally the largest one. Additional parquet # files (for example a glove sensor stream) are merged by frame_index. candidates.sort(key=lambda item: (item[0], str(item[1])), reverse=True) _, primary_path, frame_table = candidates[0] for _, extra_path, extra in reversed(candidates[1:]): if extra_path == primary_path: continue columns = [c for c in extra.columns if c != "frame_index" and c not in frame_table.columns] if not columns: continue frame_table = frame_table.merge( extra[["frame_index", *columns]], on="frame_index", how="left", ) return frame_table def _legacy_frame_table(root: Path, info: dict[str, Any], batch_name: str): """Build a truthful frame table for older collector archives. Some early RGB-only uploads contain ``timestamps.json`` and episode summary parquet files, but no frame-level ``data`` parquet. We preserve the available timeline instead of treating a processed hand-keypoint parquet as source data. Missing action/IMU values are deliberately not fabricated. """ import pandas as pd timestamp_paths = [root / "timestamps.json", root / "meta" / "collector" / "timestamps.json"] timestamp_rows: list[dict[str, Any]] = [] for path in timestamp_paths: if not path.is_file(): continue try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): continue rows = payload.get("timestamps") if isinstance(payload, dict) else payload if isinstance(rows, list): timestamp_rows = [row for row in rows if isinstance(row, dict)] if timestamp_rows: break episode_index = _episode_index_from_name(batch_name) length = 0 for path in sorted((root / "meta").rglob("*.parquet")) if (root / "meta").is_dir() else []: try: table = pd.read_parquet(path) except Exception: continue if "length" not in table.columns or table.empty: continue row = table.iloc[0] try: length = int(row.get("length") or 0) except (TypeError, ValueError): length = 0 if episode_index is None: try: value = int(row.get("episode_index")) if value >= 0: episode_index = value except (TypeError, ValueError): pass if length: break if timestamp_rows: frame_table = pd.DataFrame(timestamp_rows) else: if length <= 0: # Last-resort count from the actual source video. This is still # source-derived and does not invent any sensor or pose values. length = sum(_probe_video(path, float(info.get("fps") or 30)).get("frames", 0) for _source, path in iter_video_streams(root / "videos")) if length <= 0: raise RuntimeError("No frame-level data or timestamps found") fps = float(info.get("fps") or 30.0) frame_table = pd.DataFrame({ "frame_index": list(range(length)), "timestamp": [index / fps for index in range(length)], }) if "frame_index" not in frame_table.columns: frame_table.insert(0, "frame_index", range(len(frame_table))) if episode_index is None: episode_index = 0 frame_table["episode_index"] = int(episode_index) return frame_table def _infer_episode_index(root: Path, batch_name: str, frame_table) -> int: if "episode_index" in frame_table.columns and len(frame_table): try: value = int(frame_table["episode_index"].iloc[0]) if value >= 0: return value except (TypeError, ValueError, IndexError): pass episode_index = _episode_index_from_name(batch_name) return episode_index if episode_index is not None else 0 def _probe_video(path: Path, fallback_fps: float = 30.0) -> dict[str, Any]: result: dict[str, Any] = { "frames": 0, "fps": float(fallback_fps or 30.0), "width": 0, "height": 0, "codec": "", "pix_fmt": "", } try: import cv2 cap = cv2.VideoCapture(str(path)) result.update({ "frames": int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0), "fps": float(cap.get(cv2.CAP_PROP_FPS) or fallback_fps or 30.0), "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0), "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0), }) cap.release() except Exception: pass try: completed = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,pix_fmt,width,height", "-of", "json", str(path)], capture_output=True, text=True, timeout=15, check=False, ) streams = json.loads(completed.stdout or "{}").get("streams") or [] if streams: stream = streams[0] result["codec"] = str(stream.get("codec_name") or "") result["pix_fmt"] = str(stream.get("pix_fmt") or "") result["width"] = int(stream.get("width") or result["width"] or 0) result["height"] = int(stream.get("height") or result["height"] or 0) except (OSError, ValueError, TypeError, subprocess.TimeoutExpired): pass return result def depth_png_directories(root: Path) -> list[tuple[str, Path, list[Path]]]: """Find numeric depth-frame directories in a legacy upload tree.""" root = Path(root) if not root.is_dir(): return [] result: list[tuple[str, Path, list[Path]]] = [] seen: set[str] = set() for directory in sorted(path for path in root.rglob("*") if path.is_dir()): frames = sorted( path for path in directory.iterdir() if path.is_file() and path.suffix.lower() == DEPTH_PNG_SUFFIX and path.stem.isdigit() ) if not frames: continue source = _safe_source_key(directory.name) if source in seen: raise RuntimeError( f"Multiple depth frame directories use the same source key: {source}" ) numbers = [int(path.stem) for path in frames] expected = list(range(numbers[0], numbers[0] + len(numbers))) if numbers != expected: raise RuntimeError( f"Depth frame sequence has gaps for {source}: " f"expected consecutive files, got {frames[0].name}..{frames[-1].name}" ) seen.add(source) result.append((source, directory, frames)) return result def encode_depth_png_sequence(depth_dir: Path, frames: list[Path], destination: Path, fps: float = 30.0) -> dict[str, Any]: """Encode uint16 millimetre PNGs using the native depth-video contract. The generated stream contains 12-bit logarithmic depth codes, not a colourized preview. Quantization is explicit so FFmpeg cannot silently rescale uint16 PNG values. CQP qp=6 and full range are part of the interchange contract; CRF and lossless mode are intentionally avoided. """ import cv2 import numpy as np if not frames: raise RuntimeError(f"No depth frames found in {depth_dir}") ffmpeg = shutil.which("ffmpeg") if not ffmpeg: raise RuntimeError("ffmpeg is required to convert depth PNGs to video") destination = Path(destination) destination.parent.mkdir(parents=True, exist_ok=True) first_number = int(frames[0].stem) sample = cv2.imread(str(frames[0]), cv2.IMREAD_UNCHANGED) if sample is None or sample.ndim != 2 or sample.dtype != np.uint16: raise RuntimeError( f"Depth frame must be a single-channel uint16 PNG: {frames[0]}" ) height, width = sample.shape temporary = destination.with_name( f".{destination.stem}.tmp-{uuid4().hex}{destination.suffix or '.mp4'}" ) process = None try: process = subprocess.Popen( [ffmpeg, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "gray12le", "-s", f"{width}x{height}", "-r", str(float(fps or 30.0)), "-i", "pipe:0", "-frames:v", str(len(frames)), "-an", "-c:v", "libx265", *depth_video_encoder_args(), "-movflags", "+faststart", str(temporary)], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) assert process.stdin is not None for frame_path in frames: frame = cv2.imread(str(frame_path), cv2.IMREAD_UNCHANGED) if frame is None or frame.ndim != 2 or frame.dtype != np.uint16: raise RuntimeError( f"Depth frame must be a single-channel uint16 PNG: {frame_path}" ) if frame.shape != sample.shape: raise RuntimeError( f"Depth frame resolution mismatch: {frame_path} " f"has {frame.shape}, expected {sample.shape}" ) # Explicitly provide canonical 12-bit log samples. Passing the # PNG pattern to FFmpeg would silently rescale uint16 values. codes = quantize_depth(frame) process.stdin.write(np.ascontiguousarray(codes).tobytes()) process.stdin.close() stderr = process.stderr.read() if process.stderr is not None else b"" return_code = process.wait(timeout=1800) if return_code != 0 or not temporary.is_file() \ or temporary.stat().st_size <= 0: detail = (stderr.decode(errors="replace") or "ffmpeg returned no output").strip() raise RuntimeError(f"Depth video encoding failed: {detail[-1000:]}") probe = _probe_video(temporary, float(fps or 30.0)) if probe.get("codec") not in {"hevc", "h265"} \ or probe.get("pix_fmt") != "gray12le": raise RuntimeError( "Depth video verification failed: " f"codec={probe.get('codec')!r}, pix_fmt={probe.get('pix_fmt')!r}" ) if int(probe.get("width") or 0) <= 0 or int(probe.get("height") or 0) <= 0: raise RuntimeError("Depth video verification failed: invalid dimensions") temporary.replace(destination) probe["frames"] = int(probe.get("frames") or len(frames)) probe["fps"] = float(probe.get("fps") or fps or 30.0) probe["source_frame_start"] = first_number probe["source_frame_count"] = len(frames) probe["depth_encoding"] = DEPTH_VIDEO_ENCODING probe["depth_min_mm"] = DEPTH_MIN_MM probe["depth_max_mm"] = DEPTH_MAX_MM probe["depth_qmax"] = DEPTH_QMAX probe["depth_qp"] = DEPTH_QP return probe except (BrokenPipeError, OSError, subprocess.TimeoutExpired) as exc: if process is not None and process.poll() is None: process.kill() process.wait() raise RuntimeError(f"Depth video encoding failed: {exc}") from exc finally: if process is not None and process.poll() is None: process.kill() process.wait() temporary.unlink(missing_ok=True) class DepthVideoReader: """Sequentially decode a depth stream as raw codes or metric millimetres.""" def __init__(self, path: Path): self.path = Path(path) ffmpeg = shutil.which("ffmpeg") if not ffmpeg: raise RuntimeError("ffmpeg is required to read metric depth video") probe = _probe_video(self.path) pix_fmt = str(probe.get("pix_fmt") or "") if pix_fmt not in {"gray12le", "gray16le"}: raise RuntimeError( f"Metric depth video must use gray12le/gray16le, got {pix_fmt!r}" ) self.width = int(probe.get("width") or 0) self.height = int(probe.get("height") or 0) if self.width <= 0 or self.height <= 0: raise RuntimeError(f"Metric depth video has invalid dimensions: {self.path}") self.frame_count = int(probe.get("frames") or 0) self.fps = float(probe.get("fps") or 30.0) self.pix_fmt = pix_fmt self.video_info = _depth_video_info_from_path(self.path) self.mode = _depth_video_mode(self.video_info) self._frame_bytes = self.width * self.height * 2 self._process = subprocess.Popen( [ffmpeg, "-v", "error", "-i", str(self.path), "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:1"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) def read_codes(self): """Read one stored little-endian uint16 frame without colorization.""" import numpy as np if self._process.stdout is None: return None chunks: list[bytes] = [] remaining = self._frame_bytes while remaining: data = self._process.stdout.read(remaining) if not data: return None chunks.append(data) remaining -= len(data) return np.frombuffer(b"".join(chunks), dtype=" None: process = self._process if process.poll() is None: # The decoder usually sits blocked on a full stdout pipe once the # caller stops reading. SIGTERM makes FFmpeg try a graceful # flush that never completes in that state (measured 5s timeouts # per close), so give it a very short grace window and then kill. process.terminate() try: process.wait(timeout=0.3) except subprocess.TimeoutExpired: process.kill() try: process.wait(timeout=5) except subprocess.TimeoutExpired: pass if process.stdout: process.stdout.close() if process.stderr: process.stderr.close() def __enter__(self): return self def __exit__(self, _exc_type, _exc, _tb): self.close() class DepthCodeSeekReader: """Sequential code reader positioned at an exact frame via FFmpeg seek. The canonical depth contract encodes keyint=2 (an IDR every two frames), so input seeking lands one frame before the target at most. FFmpeg's accurate seek drops the lead-in frames itself (verified: ``-ss`` at an exact frame timestamp yields that frame as the first output), which turns a scrub jump from an O(N) sequential decode into a constant-time seek. The pooled window path replaces its sequential reader with one of these after a jump, so forward playback continues from the new position without re-seeking per window. """ def __init__(self, path: Path, start_frame: int, fps: float = 30.0, pix_fmt: str = "gray12le"): import numpy as np self.path = Path(path) ffmpeg = shutil.which("ffmpeg") if not ffmpeg: raise RuntimeError("ffmpeg is required to seek depth video") probe = _probe_video(self.path) self.width = int(probe.get("width") or 0) self.height = int(probe.get("height") or 0) if self.width <= 0 or self.height <= 0: raise RuntimeError(f"Depth video has invalid dimensions: {self.path}") self._frame_bytes = self.width * self.height * 2 # Keep the stream identity so a later scrub jump on the same pool # entry re-derives the seek timestamp from the real fps/pix_fmt # instead of falling back to defaults (wrong seek → frame desync). self.fps = float(fps or 30.0) self.pix_fmt = pix_fmt seek_time = max(0, int(start_frame)) / self.fps # Truncate to milliseconds (never round up): ffmpeg's accurate seek # returns the first frame with ts >= seek_time, so a rounded-up value # such as 36.6667 lands one frame PAST the target (36.66667 -> frame # 1101 instead of 1100). A 1ms truncation is always below the # target frame ts and at least ~32ms above the previous frame's. seek_time = math.floor(seek_time * 1000) / 1000 self._process = subprocess.Popen( [ffmpeg, "-v", "error", "-ss", f"{seek_time:.3f}", "-i", str(self.path), "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:1"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) def read_codes(self): """Read one stored little-endian uint16 frame without colorization.""" import numpy as np if self._process.stdout is None: return None chunks: list[bytes] = [] remaining = self._frame_bytes while remaining: data = self._process.stdout.read(remaining) if not data: return None chunks.append(data) remaining -= len(data) return np.frombuffer(b"".join(chunks), dtype=" None: process = self._process if process.poll() is None: # Same blocked-pipe situation as DepthVideoReader.close(); a # graceful SIGTERM flush hangs for seconds on a full stdout pipe. process.terminate() try: process.wait(timeout=0.3) except subprocess.TimeoutExpired: process.kill() try: process.wait(timeout=5) except subprocess.TimeoutExpired: pass if process.stdout: process.stdout.close() if process.stderr: process.stderr.close() def _to_mp4(source: Path, destination: Path) -> Path: """Put a video in the v2.1 MP4 path, preserving codec when possible.""" destination.parent.mkdir(parents=True, exist_ok=True) if source.resolve() == destination.resolve(): return source if source.suffix.lower() == ".mp4": shutil.copy2(source, destination) return destination try: completed = subprocess.run( ["ffmpeg", "-y", "-loglevel", "error", "-i", str(source), "-map", "0:v:0", "-c", "copy", "-movflags", "+faststart", str(destination)], capture_output=True, timeout=1800, check=False, ) if completed.returncode == 0 and destination.is_file(): return destination except (OSError, subprocess.TimeoutExpired): pass # Keep the original codec/container rather than discarding a depth stream. fallback = destination.with_suffix(source.suffix.lower()) shutil.copy2(source, fallback) return fallback def _canonicalize_depth_video(source: Path, destination: Path, fps: float) -> dict[str, Any]: """Transcode a legacy gray depth video to canonical log depth codes.""" import numpy as np ffmpeg = shutil.which("ffmpeg") if not ffmpeg: raise RuntimeError("ffmpeg is required to canonicalize depth video") destination = Path(destination) destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name( f".{destination.stem}.tmp-{uuid4().hex}{destination.suffix or '.mp4'}" ) reader = DepthVideoReader(source) process = None written = 0 try: command = [ ffmpeg, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "gray12le", "-s", f"{reader.width}x{reader.height}", "-r", str(float(fps or reader.fps or 30.0)), "-i", "pipe:0", "-an", "-c:v", "libx265", *depth_video_encoder_args(), "-movflags", "+faststart", str(temporary), ] process = subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) assert process.stdin is not None while True: codes = reader.read_canonical_codes() if codes is None: break process.stdin.write(np.ascontiguousarray(codes, dtype=" dict: key = f"observation.images.{source}" features = info.get("features") if isinstance(info.get("features"), dict) else {} original = features.get(key) if isinstance(features.get(key), dict) else {} original_video = original.get("video_info") video_info = dict(original_video) if isinstance(original_video, dict) else {} is_depth = bool(video_info.get("video.is_depth_map")) or metric_depth video_info.update({ "video.fps": probe["fps"], "video.height": probe["height"], "video.width": probe["width"], "video.channels": 1 if is_depth else 3, "video.codec": probe.get("codec") or video_info.get("video.codec") or "", "video.pix_fmt": probe.get("pix_fmt") or video_info.get("video.pix_fmt") or "", "video.is_depth_map": is_depth, "video.is_depth_visualization": bool(is_depth is False and is_depth_source(source)), "has_audio": False, }) if metric_depth: video_info.update({ "video.depth_min_mm": DEPTH_MIN_MM, "video.depth_max_mm": DEPTH_MAX_MM, "video.depth_qmax": DEPTH_QMAX, "video.depth_qp": DEPTH_QP, "video.depth_quantization": "log", "video.depth_encoding": probe.get("depth_encoding") or DEPTH_VIDEO_ENCODING, }) feature = dict(original) feature.update({ "dtype": "video", "shape": [probe["height"], probe["width"], video_info["video.channels"]], "names": ["height", "width", "channel"], "video_info": video_info, }) return feature def _read_task_rows(meta_root: Path, fallback: str) -> tuple[dict[int, str], str]: tasks: dict[int, str] = {} json_path = meta_root / "tasks.json" json_value = _read_json(json_path) if isinstance(json_value, dict): json_value = json_value.get("tasks", json_value) if isinstance(json_value, list): for default_index, row in enumerate(json_value): if isinstance(row, str): index, text = default_index, row.strip() elif isinstance(row, dict): try: index = int(row.get("task_index", row.get("task_id", default_index))) except (TypeError, ValueError): index = default_index text = str(row.get("task") or row.get("description") or "").strip() else: continue if text: tasks[index] = text for filename in ("tasks.jsonl",): if tasks: break path = meta_root / filename if not path.is_file(): continue for line in path.read_text(encoding="utf-8").splitlines(): try: row = json.loads(line) except (ValueError, TypeError): continue if not isinstance(row, dict): continue try: index = int(row.get("task_index", row.get("task_id", 0))) except (TypeError, ValueError): index = 0 text = str(row.get("task") or row.get("description") or "").strip() if text: tasks[index] = text if not tasks: tasks[0] = fallback or "default_recording" return tasks, tasks[min(tasks)] def _episode_stats(frame_table) -> dict[str, dict[str, Any]]: stats: dict[str, dict[str, Any]] = {} for column in frame_table.columns: try: series = frame_table[column] if not getattr(series.dtype, "kind", "") in "biuf": continue values = series.to_numpy(dtype=float) if not len(values): continue import numpy as np finite = values[np.isfinite(values)] if not len(finite): continue stats[column] = { "min": float(finite.min()), "max": float(finite.max()), "mean": float(finite.mean()), "std": float(finite.std()), "count": [int(len(finite))], } except Exception: continue return stats def _write_episode_index(meta_root: Path, rows: Iterable[dict[str, Any]]) -> None: """Write the canonical sharded Parquet episode index for one upload.""" import pyarrow as pa import pyarrow.parquet as pq episodes_root = Path(meta_root) / "episodes" if episodes_root.is_dir(): shutil.rmtree(episodes_root) normalized = [dict(row) for row in rows] normalized.sort(key=lambda row: int(row.get("episode_index", 10**9))) for row in normalized: episode_index = int(row.get("episode_index", 0)) chunk = episode_index // EPISODES_METADATA_CHUNK_SIZE target = (episodes_root / f"chunk-{chunk:03d}" / f"episode_{episode_index:06d}.parquet") target.parent.mkdir(parents=True, exist_ok=True) pq.write_table( pa.Table.from_pylist([row]), target, ) def normalize_extracted_dataset(root: Path, batch_name: str) -> dict[str, Any]: """Normalize one extracted collector archive to LeRobot v2.1 in-place.""" root = Path(root) meta_root = root / "meta" meta_root.mkdir(parents=True, exist_ok=True) info = normalize_metadata_sources(_read_json(meta_root / "info.json")) fallback_task = str(info.get("task_name") or batch_name) data_paths = _data_parquets(root) try: frame_table = _load_frame_table(data_paths) except RuntimeError: frame_table = _legacy_frame_table(root, info, batch_name) episode_index = _infer_episode_index(root, batch_name, frame_table) frame_table = frame_table.copy() frame_table["episode_index"] = episode_index if "frame_index" in frame_table.columns: frame_table = frame_table.sort_values("frame_index", kind="stable") frame_count = int(len(frame_table)) fps = float(info.get("fps") or 30.0) # Replace all source data parquet files with one v2.1 episode parquet. data_target = root / "data" / "chunk-000" / f"episode_{episode_index:06d}.parquet" data_target.parent.mkdir(parents=True, exist_ok=True) frame_table.to_parquet(data_target, index=False) for path in data_paths: if path.resolve() != data_target.resolve(): path.unlink(missing_ok=True) for directory in sorted((root / "data").glob("**/*"), reverse=True): if directory.is_dir() and directory != data_target.parent: try: directory.rmdir() except OSError: pass # Normalize one video per source to the v2.1 path template. source_videos = iter_video_streams(root / "videos") normalized_video_sources = list(source_videos) grouped: dict[str, list[Path]] = {} for source, path in source_videos: grouped.setdefault(source, []).append(path) video_features: dict[str, dict] = {} video_extensions: dict[str, str] = {} video_meta: dict[str, dict[str, Any]] = {} videos_root = root / "videos" for source, paths in sorted(grouped.items()): if len(paths) > 1: raise RuntimeError( f"Multiple video files found for source {source}; " "one uploaded batch must contain one file per source" ) source_path = paths[0] probe = _probe_video(source_path, fps) # A pure depth stream is metric only when its metadata/pixel format # proves it. A normal yuv420p/colourized depth preview is not 3D data. old_feature = (info.get("features") or {}).get( f"observation.images.{source}", {}) old_video = old_feature.get("video_info") if isinstance(old_feature, dict) else {} metric_depth = bool(isinstance(old_video, dict) and old_video.get("video.is_depth_map")) metric_depth = metric_depth or ( is_depth_source(source) and probe.get("pix_fmt") in {"gray12le", "gray16le", "gray10le"} ) destination = (videos_root / f"observation.images.{_safe_source_key(source)}" / "chunk-000" / f"episode_{episode_index:06d}.mp4") if metric_depth and _depth_video_mode(old_video) != "log": # Legacy gray16/gray12 streams may carry direct millimetres. Do # not relabel them as log codes without converting the samples. probe = _canonicalize_depth_video(source_path, destination, fps) final_path = destination else: final_path = _to_mp4(source_path, destination) video_extensions[source] = final_path.suffix.lstrip(".") video_meta[source] = { "frames": probe["frames"] or frame_count, "fps": probe["fps"], "metric_depth": metric_depth, "source": source, } video_features[f"observation.images.{source}"] = _source_feature( info, source, probe, metric_depth, ) # Legacy collectors may upload metric depth as depth//*.png. # Convert it before the legacy tree is removed. New projects therefore # keep only one independent depth video beside RGB streams, while the # resolver continues to read old meta/depth PNGs from already-migrated # projects. depth_root = root / "depth" depth_directories = depth_png_directories(depth_root) for source, depth_dir, frames in depth_directories: if source in video_meta: # An explicitly supplied depth video is authoritative. The PNG # copy is redundant and is removed only after the whole batch has # passed the output verification below. continue destination = (videos_root / f"observation.images.{_safe_source_key(source)}" / "chunk-000" / f"episode_{episode_index:06d}.mp4") depth_probe = encode_depth_png_sequence( depth_dir, frames, destination, fps=float(info.get("fps") or fps or 30.0), ) depth_scale = 0.001 depth_feature = _source_feature(info, source, depth_probe, True) depth_video_info = depth_feature.setdefault("video_info", {}) depth_video_info.update({ "video.channels": 1, "video.is_depth_map": True, "video.is_depth_visualization": False, "video.depth_scale": depth_scale, "video.depth_encoding": DEPTH_VIDEO_ENCODING, "video.depth_min_mm": DEPTH_MIN_MM, "video.depth_max_mm": DEPTH_MAX_MM, "video.depth_qmax": DEPTH_QMAX, "video.depth_qp": DEPTH_QP, "video.depth_quantization": "log", "has_audio": False, }) depth_feature["shape"] = [ int(depth_probe.get("height") or 0), int(depth_probe.get("width") or 0), 1, ] video_features[f"observation.images.{source}"] = depth_feature video_extensions[source] = "mp4" video_meta[source] = { "frames": int(depth_probe.get("frames") or len(frames)), "fps": float(depth_probe.get("fps") or fps or 30.0), "metric_depth": True, "source": source, "depth_scale": depth_scale, } normalized_video_sources.append((source, destination)) # Do not retain raw depth PNGs in a newly normalized batch. This is # reached only after every sequence has produced and verified its video; # if conversion fails, normalization aborts and the upload staging tree # remains available for retry/diagnosis. if depth_root.is_dir(): shutil.rmtree(depth_root) # Remove any source/legacy video tree only after all files have been # copied. The canonical v2.1 layout is videos//chunk-000/. for child in list(videos_root.iterdir()) if videos_root.is_dir() else []: if not child.name.startswith("observation.images."): if child.is_dir(): shutil.rmtree(child, ignore_errors=True) else: child.unlink(missing_ok=True) continue for nested in list(child.iterdir()) if child.is_dir() else []: if nested.name != "chunk-000": if nested.is_dir(): shutil.rmtree(nested, ignore_errors=True) else: nested.unlink(missing_ok=True) tasks, default_task = _read_task_rows(meta_root, fallback_task) # Keep an explicit task file, but remove v3-only pooled episode metadata. for stale in (meta_root / "tasks.parquet",): stale.unlink(missing_ok=True) episode_row: dict[str, Any] = { "episode_index": episode_index, "tasks": [default_task], "task_index": min(tasks), "length": frame_count, "data/chunk_index": 0, "data/file_index": episode_index, "dataset_from_index": 0, "dataset_to_index": frame_count, } for source, values in sorted(video_meta.items()): key = f"observation.images.{source}" count = int(values.get("frames") or frame_count) stream_fps = float(values.get("fps") or fps) episode_row[f"videos/{key}/chunk_index"] = 0 episode_row[f"videos/{key}/file_index"] = episode_index episode_row[f"videos/{key}/from_timestamp"] = 0.0 episode_row[f"videos/{key}/to_timestamp"] = count / stream_fps if stream_fps else 0.0 episode_stats = _episode_stats(frame_table) episode_row["stats"] = episode_stats _write_episode_index(meta_root, [episode_row]) (meta_root / "episodes_stats.jsonl").unlink(missing_ok=True) tasks_path = meta_root / "tasks.json" tasks_path.write_text( json.dumps([{"task_index": index, "task": text} for index, text in sorted(tasks.items())], ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) (meta_root / "tasks.jsonl").unlink(missing_ok=True) # Move collector-only metadata under meta/, keeping the dataset root at # exactly data/meta/videos. Calibration is metadata, not a fourth root. calibration_root = root / "calibration" if calibration_root.is_dir(): target = meta_root / "calibration" target.mkdir(parents=True, exist_ok=True) for item in calibration_root.iterdir(): shutil.move(str(item), str(target / item.name)) calibration_root.rmdir() for filename in ("metadata.json", "timestamps.json"): source = root / filename if source.is_file(): target = meta_root / "collector" / filename target.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(source), str(target)) # Keep the dataset root limited to data/meta/videos without hiding raw # depth frames from the processing pipeline. PNG depth is auxiliary # source material, so it lives below meta/depth while the synchronized # video stream remains under videos/observation.images./. for item in list(root.iterdir()): if item.name in {"data", "meta", "videos"}: continue if item.name == "depth": target = meta_root / "depth" else: target = meta_root / "collector" / item.name target.parent.mkdir(parents=True, exist_ok=True) if target.exists(): raise RuntimeError(f"Metadata destination already exists: {target}") shutil.move(str(item), str(target)) # Collector metadata is part of the dataset contract. Normalize it too, # otherwise a historical upload could reintroduce old source/device names # after the active videos were already written with canonical keys. for metadata_path in meta_root.rglob("*.json"): metadata = _read_json(metadata_path) if not metadata: continue normalized_metadata = normalize_metadata_sources(metadata) _write_json(metadata_path, normalized_metadata) info = normalize_metadata_sources(info) features = dict(info.get("features") or {}) if isinstance(info.get("features"), dict) else {} features.update(video_features) normalized = dict(info) normalized.update({ "format": "lerobot_v2.1", "codebase_version": "v2.1", "total_episodes": 1, "total_frames": frame_count, "total_tasks": len(tasks), "chunks_size": 1000, "fps": fps, "data_path": "data/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.parquet", "video_path": "videos/{video_key}/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.{ext}", "features": features, "video_extensions": video_extensions, "extensions": { **(info.get("extensions") or {}), "episodes_file": "meta/episodes/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.parquet", "stats_file": "meta/stats.json", "tasks_file": "meta/tasks.json", }, }) _write_json(meta_root / "info.json", normalized) # A standalone upload_manifest is useful for diagnostics but belongs in # meta/ so it does not create a fourth dataset-root directory. return { "episode_index": episode_index, "frame_count": frame_count, "fps": fps, "video_sources": sorted(source for source, _ in normalized_video_sources), "camera_sources": sorted(source for source, _ in normalized_video_sources if not is_depth_source(source)), "depth_sources": sorted(source for source, _ in normalized_video_sources if is_depth_source(source)), "video_meta": video_meta, "info": normalized, }