Spaces:
Running
Running
Download processor/app/lerobot_v21.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 60 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/lerobot_v21.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/lerobot_v21.py
-
curl -L -o lerobot_v21.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/lerobot_v21.py
60 kB
| """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/<project>`` | |
| 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.<source>/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="<u2") | |
| if valid.any(): | |
| codes[valid] = np.clip( | |
| np.rint((np.log(mm[valid]) - _LOG_LO) * _LOG_STEP), | |
| 0, DEPTH_QMAX, | |
| ).astype("<u2") | |
| return codes | |
| def dequantize_depth(codes): | |
| """Convert canonical 12-bit log codes to rounded uint16 millimetres.""" | |
| import numpy as np | |
| c = np.asarray(codes, dtype=np.float64) | |
| return np.rint(np.exp(_LOG_LO + c / _LOG_STEP)).astype("<u2") | |
| def codes_to_heatmap_bgr(codes): | |
| """Convert canonical codes to the exact OpenCV JET BGR display image.""" | |
| import cv2 | |
| import numpy as np | |
| c8 = ((np.clip(codes, 0, DEPTH_QMAX).astype(np.int32) * 255) | |
| // DEPTH_QMAX).astype(np.uint8) | |
| return cv2.applyColorMap(c8, cv2.COLORMAP_JET) | |
| def depth_to_heatmap_bgr(depth_mm): | |
| """Convert millimetres to the canonical preview-only OpenCV JET image.""" | |
| return codes_to_heatmap_bgr(quantize_depth(depth_mm)) | |
| # Turbo colormap (Google) polynomial coefficients — matches Rerun's default | |
| # depth visualization (blue → cyan → green → yellow → red continuous ramp). | |
| _TURBO_POLY = { | |
| "r": [0.13572138, 4.61539260, -42.66032258, 132.13108234, | |
| -152.94239396, 59.28637943], | |
| "g": [0.09140261, 2.19418839, 4.84296658, -14.18503333, | |
| 4.27729857, 2.82956604], | |
| "b": [0.10667330, 12.64194608, -60.58204836, 110.36276771, | |
| -89.90310912, 27.34824973], | |
| } | |
| _TURBO_LUT: list[list[int]] | None = None | |
| def turbo_lut(size: int = 256) -> 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="<u2").reshape( | |
| self.height, self.width, | |
| ).copy() | |
| def read_mm(self): | |
| """Read one frame as uint16 millimetres for metric processing.""" | |
| import numpy as np | |
| codes = self.read_codes() | |
| if codes is None: | |
| return None | |
| return self._codes_to_mm(codes) | |
| def _codes_to_mm(self, codes): | |
| import numpy as np | |
| if self.mode == "log": | |
| depth = dequantize_depth(codes) | |
| # The canonical display maps code 0 to JET(0), but metric | |
| # processing must retain its invalid-pixel semantics. | |
| depth[codes == 0] = 0 | |
| return depth | |
| if self.mode == "legacy_mm10": | |
| # 8 月老采集端把毫米深度按 0.1 系数存储(码值 = mm/10), | |
| # ×10 还原毫米;0 仍保持无效像素语义。 | |
| depth = codes.astype(np.float64) * 10.0 | |
| depth[codes == 0] = 0.0 | |
| return np.rint(np.clip(depth, 0, 65535)).astype("<u2") | |
| if self.mode == "legacy_log": | |
| low = float(self.video_info["video.depth_min"]) | |
| high = float(self.video_info["video.depth_max"]) | |
| shift = float(self.video_info.get("video.shift") or 0.0) | |
| log_low = math.log(low + shift) | |
| log_span = math.log(high + shift) - log_low | |
| depth_m = np.exp(log_low + codes.astype(np.float64) / DEPTH_QMAX * log_span) - shift | |
| depth_m[codes == 0] = 0.0 | |
| return np.rint(np.clip(depth_m * 1000.0, 0, 65535)).astype("<u2") | |
| return codes | |
| def read_canonical_codes(self): | |
| """Read a frame as canonical codes without creating a color image. | |
| New streams already contain canonical codes. Known legacy direct-mm | |
| and legacy-log streams are converted in memory for compatibility; no | |
| converted frame is written back to the dataset. | |
| """ | |
| codes = self.read_codes() | |
| if codes is None: | |
| return None | |
| if self.mode == "log": | |
| import numpy as np | |
| return np.clip(codes, 0, DEPTH_QMAX).astype("<u2", copy=False) | |
| return quantize_depth(self._codes_to_mm(codes)) | |
| def read(self): | |
| """Backward-compatible metric read used by 3D processing modules.""" | |
| return self.read_mm() | |
| def close(self) -> 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="<u2").reshape( | |
| self.height, self.width, | |
| ).copy() | |
| def close(self) -> 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="<u2").tobytes()) | |
| written += 1 | |
| 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 written <= 0 or not temporary.is_file(): | |
| detail = (stderr.decode(errors="replace") | |
| or "ffmpeg returned no output").strip() | |
| raise RuntimeError(f"Depth video canonicalization failed: {detail[-1000:]}") | |
| probe = _probe_video(temporary, float(fps or reader.fps or 30.0)) | |
| if probe.get("codec") not in {"hevc", "h265"} \ | |
| or probe.get("pix_fmt") != "gray12le": | |
| raise RuntimeError( | |
| "Depth video canonicalization verification failed: " | |
| f"codec={probe.get('codec')!r}, pix_fmt={probe.get('pix_fmt')!r}" | |
| ) | |
| temporary.replace(destination) | |
| probe.update({ | |
| "frames": int(probe.get("frames") or written), | |
| "fps": float(probe.get("fps") or fps or reader.fps or 30.0), | |
| "depth_encoding": DEPTH_VIDEO_ENCODING, | |
| "depth_min_mm": DEPTH_MIN_MM, | |
| "depth_max_mm": DEPTH_MAX_MM, | |
| "depth_qmax": DEPTH_QMAX, | |
| "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 canonicalization failed: {exc}") from exc | |
| finally: | |
| reader.close() | |
| if process is not None and process.poll() is None: | |
| process.kill() | |
| process.wait() | |
| temporary.unlink(missing_ok=True) | |
| def _source_feature(info: dict, source: str, probe: dict, metric_depth: bool) -> 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/<source>/*.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/<video_key>/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.<source>/. | |
| 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, | |
| } | |