Spaces:
Running
Running
File size: 60,019 Bytes
d646dd7 95f5290 d646dd7 af470e1 d646dd7 af470e1 d646dd7 af470e1 d646dd7 95f5290 d646dd7 af470e1 d646dd7 95f5290 d646dd7 95f5290 d646dd7 95f5290 d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 | """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,
}
|