dexverse-cli 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ """HDF5 → 原始录制数据 还原子包。
2
+
3
+ 把后端打包的 HDF5(多路相机视频 + joints + 辅助文件)还原为可训练的原始
4
+ 目录结构(逐帧 JPEG + pose_record_*.json + metadata.jsonl)。
5
+
6
+ 库用法:
7
+
8
+ from dexverse.convert import convert_batch, convert_hdf5
9
+ convert_hdf5(Path("xxx.hdf5"), Path("out/xxx/"))
10
+ convert_batch(Path("data/hdf5/"), Path("data/restored/"))
11
+
12
+ CLI 入口见 ``dexverse-dl convert``。
13
+ """
14
+ from .hdf5_to_raw import (
15
+ AlignedFrame,
16
+ AlignmentStrategy,
17
+ DefaultAlignmentStrategy,
18
+ HDF5ToRawConverter,
19
+ convert_batch,
20
+ convert_hdf5,
21
+ find_hdf5_files,
22
+ )
23
+ from .image_video_encode import (
24
+ decode_mp4_bytes_to_rgb_images,
25
+ encode_rgb_images_as_mp4_bytes,
26
+ )
27
+
28
+ __all__ = [
29
+ "AlignedFrame",
30
+ "AlignmentStrategy",
31
+ "DefaultAlignmentStrategy",
32
+ "HDF5ToRawConverter",
33
+ "convert_batch",
34
+ "convert_hdf5",
35
+ "find_hdf5_files",
36
+ "decode_mp4_bytes_to_rgb_images",
37
+ "encode_rgb_images_as_mp4_bytes",
38
+ ]
@@ -0,0 +1,498 @@
1
+ # ----------------------------------------------------------------------------
2
+ # Copyright (c) 2021-2025 DexForce Technology Co., Ltd.
3
+ #
4
+ # All rights reserved.
5
+ # ----------------------------------------------------------------------------
6
+ """hdf5_to_raw.py - 将 HDF5 文件还原为原始录制数据结构(图片 + pose_record JSON)。
7
+
8
+ 设计原则:
9
+ - 对齐策略可替换(AlignmentStrategy 基类 + 具体实现)
10
+ - 编解码逻辑从 image_video_encode 复用
11
+ - 输出目录结构与原始录制格式一致
12
+
13
+ 库用法:
14
+
15
+ from dexverse.convert import convert_batch, convert_hdf5
16
+ convert_hdf5(Path("xxx.hdf5"), Path("out/xxx/"))
17
+ convert_batch(Path("data/hdf5/"), Path("data/restored/"))
18
+
19
+ CLI 入口见 ``dexverse.cli`` 的 ``convert`` 命令。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from abc import ABC, abstractmethod
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional, Tuple
29
+
30
+ import h5py
31
+ import numpy as np
32
+
33
+ from ..downloader import state as download_state
34
+ # 复用同包子模块的解码能力
35
+ from .image_video_encode import decode_mp4_bytes_to_rgb_images
36
+
37
+
38
+ TASK_INSTRUCTION_FILENAME = "task_instruction.json"
39
+
40
+
41
+ def _find_download_root(hdf5_path: Path) -> Optional[Path]:
42
+ """Find the nearest ancestor containing downloader metadata."""
43
+ resolved = Path(hdf5_path).expanduser().resolve()
44
+ for parent in resolved.parents:
45
+ if download_state.dexverse_root(parent).is_dir():
46
+ return parent
47
+ return None
48
+
49
+
50
+ def _sync_restored_task_instruction(
51
+ hdf5_path: Path,
52
+ output_dir: Path,
53
+ ) -> None:
54
+ """Copy a downloaded file's hidden instruction into its restored session."""
55
+ download_root = _find_download_root(hdf5_path)
56
+ if download_root is None:
57
+ return
58
+
59
+ resolved_hdf5 = Path(hdf5_path).expanduser().resolve()
60
+ relpath = resolved_hdf5.relative_to(download_root).as_posix()
61
+ source = download_state.task_instruction_path(download_root, relpath)
62
+ destination = Path(output_dir) / TASK_INSTRUCTION_FILENAME
63
+
64
+ if source.is_file():
65
+ instruction = json.loads(source.read_text(encoding="utf-8"))
66
+ if not isinstance(instruction, dict):
67
+ raise ValueError(f"task instruction must be a JSON object: {source}")
68
+ destination.write_text(
69
+ json.dumps(instruction, ensure_ascii=False, indent=2) + "\n",
70
+ encoding="utf-8",
71
+ )
72
+ else:
73
+ destination.unlink(missing_ok=True)
74
+
75
+
76
+ # ── 对齐策略(可替换/扩展) ──────────────────────────────────────
77
+
78
+ @dataclass
79
+ class AlignedFrame:
80
+ """一帧对齐后的数据。"""
81
+ frame_index: int
82
+ timestamp_ns: int
83
+ images: Dict[str, Any] # camera_name -> PIL Image
84
+ joints: Dict[str, float] # joint_name -> value
85
+
86
+
87
+ class AlignmentStrategy(ABC):
88
+ """对齐策略基类。子类实现具体的对齐/还原方式。
89
+
90
+ 当前的 HDF5 是转换器对齐+插值后的产物,joints timestamps 和 camera timestamps
91
+ 已统一(同一帧共用一个时间戳)。未来如果转换器改为保留原始数据,可新增策略。
92
+ """
93
+
94
+ @abstractmethod
95
+ def align(
96
+ self,
97
+ h5_file: h5py.File,
98
+ ) -> List[AlignedFrame]:
99
+ """从 HDF5 文件中提取对齐后的帧列表。"""
100
+ ...
101
+
102
+
103
+ class DefaultAlignmentStrategy(AlignmentStrategy):
104
+ """默认对齐策略:直接按帧序号对齐(HDF5 已对齐过)。
105
+
106
+ HDF5 中各 group 的 frames 属性一致,timestamps 也一致,
107
+ 直接按帧索引取数据即可。
108
+ """
109
+
110
+ def align(
111
+ self,
112
+ h5_file: h5py.File,
113
+ ) -> List[AlignedFrame]:
114
+ """从 HDF5 文件中提取对齐后的帧列表。
115
+
116
+ 注意:此方法会一次性加载所有帧到内存,大文件可能 OOM。
117
+ 对于大文件建议用 HDF5ToRawConverter.convert() 直接逐帧写入。
118
+ """
119
+ converter = HDF5ToRawConverter()
120
+ return converter._extract_frames(h5_file)
121
+
122
+
123
+ # ── HDF5 反转器 ──────────────────────────────────────────────────
124
+
125
+ class HDF5ToRawConverter:
126
+ """将 HDF5 文件还原为原始录制数据结构。"""
127
+
128
+ # HDF5 camera group name -> 原始目录路径映射
129
+ CAMERA_DIR_MAP = {
130
+ "camera_head_left": "head/left",
131
+ "camera_head_right": "head/right",
132
+ "camera_hand_left": "hand/left",
133
+ "camera_hand_right": "hand/right",
134
+ }
135
+
136
+ def __init__(self, strategy: Optional[AlignmentStrategy] = None) -> None:
137
+ self._strategy = strategy or DefaultAlignmentStrategy()
138
+
139
+ def convert(self, hdf5_path: Path, output_dir: Path) -> int:
140
+ """转换单个 HDF5 文件到输出目录(逐相机逐帧写入,避免 OOM)。
141
+
142
+ Args:
143
+ hdf5_path: HDF5 文件路径
144
+ output_dir: 输出目录(session 目录)
145
+
146
+ Returns:
147
+ 还原的帧数
148
+ """
149
+ output_dir.mkdir(parents=True, exist_ok=True)
150
+
151
+ with h5py.File(hdf5_path, "r") as f:
152
+ frames_count = int(f.attrs.get("frames", 0))
153
+ if frames_count == 0:
154
+ if "joints" in f and "data" in f["joints"]:
155
+ frames_count = f["joints"]["data"].shape[0]
156
+ else:
157
+ for name in f.keys():
158
+ g = f[name]
159
+ if isinstance(g, h5py.Group) and "timestamps" in g:
160
+ frames_count = g["timestamps"].shape[0]
161
+ break
162
+ if frames_count == 0:
163
+ print(f" [WARN] 无有效帧: {hdf5_path}")
164
+ return 0
165
+
166
+ # 收集 camera groups 信息
167
+ camera_groups: Dict[str, h5py.Group] = {}
168
+ for name in f.keys():
169
+ g = f[name]
170
+ if isinstance(g, h5py.Group):
171
+ encoding = g.attrs.get("encoding", "")
172
+ if encoding in ("mp4", "jpeg"):
173
+ camera_groups[name] = g
174
+
175
+ # 创建相机目录(从 metadata.jsonl 读取所有相机路径,包括无数据的)
176
+ cam_dirs: Dict[str, Path] = {}
177
+ for cam_name in camera_groups:
178
+ dir_name = self.CAMERA_DIR_MAP.get(cam_name, cam_name)
179
+ cam_dir = output_dir / dir_name
180
+ cam_dir.mkdir(parents=True, exist_ok=True)
181
+ cam_dirs[cam_name] = cam_dir
182
+
183
+ # 从 metadata.jsonl 补充创建所有相机的目录(原始数据可能有空目录)
184
+ if "metadata_jsonl" in f:
185
+ meta_bytes = bytes(f["metadata_jsonl"][()])
186
+ if meta_bytes:
187
+ seen_dirs = set()
188
+ for line in meta_bytes.decode("utf-8", errors="ignore").splitlines():
189
+ try:
190
+ entry = json.loads(line)
191
+ img_path = entry.get("image_path", "")
192
+ if img_path:
193
+ cam_dir_name = str(Path(img_path).parent)
194
+ if cam_dir_name not in seen_dirs:
195
+ seen_dirs.add(cam_dir_name)
196
+ (output_dir / cam_dir_name).mkdir(parents=True, exist_ok=True)
197
+ except (json.JSONDecodeError, KeyError):
198
+ pass
199
+
200
+ # 从 subdirs attrs 创建所有原始子目录(包括空目录如 hand/left)
201
+ subdirs_attr = f.attrs.get("subdirs", "[]")
202
+ if isinstance(subdirs_attr, bytes):
203
+ subdirs_attr = subdirs_attr.decode()
204
+ for subdir in json.loads(subdirs_attr):
205
+ (output_dir / subdir).mkdir(parents=True, exist_ok=True)
206
+
207
+ # 先收集 joints 数据和基准时间戳(不依赖图片解码)
208
+ joints_data: Optional[np.ndarray] = None
209
+ joints_columns: List[str] = []
210
+ if "joints" in f and "data" in f["joints"]:
211
+ joints_group = f["joints"]
212
+ joints_data = joints_group["data"][:]
213
+ cols = joints_group.attrs.get("columns", "[]")
214
+ if isinstance(cols, bytes):
215
+ cols = cols.decode()
216
+ joints_columns = json.loads(cols)
217
+
218
+ # 取基准时间戳
219
+ ref_ts = None
220
+ for cam_group in camera_groups.values():
221
+ ref_ts = cam_group["timestamps"][:]
222
+ break
223
+ if ref_ts is None and "joints" in f and "timestamps" in f["joints"]:
224
+ ref_ts = f["joints"]["timestamps"][:]
225
+
226
+ # 先写辅助文件(不依赖图片解码,避免 OOM 时也能还原)
227
+ self._write_pose_record(f, frames_count, joints_data, joints_columns, ref_ts, hdf5_path, output_dir)
228
+ self._write_raw_files(f, output_dir)
229
+
230
+ # 逐相机解码视频并写入图片(避免一次性全部解码到内存)
231
+ for cam_name, cam_group in camera_groups.items():
232
+ data_ds = cam_group["data"]
233
+ encoding = cam_group.attrs.get("encoding", "")
234
+ cam_dir = cam_dirs[cam_name]
235
+
236
+ if encoding == "mp4":
237
+ # 视频模式:解码整个 MP4,逐帧保存
238
+ mp4_bytes = bytes(data_ds[()])
239
+ images = decode_mp4_bytes_to_rgb_images(mp4_bytes)
240
+ for i, img in enumerate(images):
241
+ img.save(cam_dir / f"{i:06d}.jpg", "JPEG", quality=95)
242
+ del images # 释放内存
243
+ elif encoding == "jpeg":
244
+ # JPEG 模式:逐帧解码
245
+ from PIL import Image
246
+ import io as _io
247
+ for i in range(data_ds.shape[0]):
248
+ jpg_bytes = bytes(data_ds[i])
249
+ img = Image.open(_io.BytesIO(jpg_bytes)).convert("RGB")
250
+ img.save(cam_dir / f"{i:06d}.jpg", "JPEG", quality=95)
251
+
252
+ _sync_restored_task_instruction(hdf5_path, output_dir)
253
+ return frames_count
254
+
255
+ def _extract_frames(self, h5_file: h5py.File) -> List[AlignedFrame]:
256
+ """从 HDF5 提取帧列表(内部方法,strategy 调用)。"""
257
+ frames_count = int(h5_file.attrs.get("frames", 0))
258
+ if frames_count == 0:
259
+ if "joints" in h5_file and "data" in h5_file["joints"]:
260
+ frames_count = h5_file["joints"]["data"].shape[0]
261
+ else:
262
+ for name in h5_file.keys():
263
+ g = h5_file[name]
264
+ if isinstance(g, h5py.Group) and "timestamps" in g:
265
+ frames_count = g["timestamps"].shape[0]
266
+ break
267
+ if frames_count == 0:
268
+ return []
269
+
270
+ # 收集 camera groups
271
+ camera_groups: Dict[str, h5py.Group] = {}
272
+ for name in h5_file.keys():
273
+ g = h5_file[name]
274
+ if isinstance(g, h5py.Group):
275
+ encoding = g.attrs.get("encoding", "")
276
+ if encoding in ("mp4", "jpeg"):
277
+ camera_groups[name] = g
278
+
279
+ # 解码视频
280
+ camera_images: Dict[str, List] = {}
281
+ for cam_name, cam_group in camera_groups.items():
282
+ data_ds = cam_group["data"]
283
+ encoding = cam_group.attrs.get("encoding", "")
284
+ if encoding == "mp4":
285
+ mp4_bytes = bytes(data_ds[()])
286
+ camera_images[cam_name] = decode_mp4_bytes_to_rgb_images(mp4_bytes)
287
+ elif encoding == "jpeg":
288
+ from PIL import Image
289
+ import io as _io
290
+ imgs = []
291
+ for i in range(data_ds.shape[0]):
292
+ jpg_bytes = bytes(data_ds[i])
293
+ imgs.append(Image.open(_io.BytesIO(jpg_bytes)).convert("RGB"))
294
+ camera_images[cam_name] = imgs
295
+ else:
296
+ camera_images[cam_name] = []
297
+
298
+ # joints
299
+ joints_data: Optional[np.ndarray] = None
300
+ joints_columns: List[str] = []
301
+ if "joints" in h5_file and "data" in h5_file["joints"]:
302
+ joints_group = h5_file["joints"]
303
+ joints_data = joints_group["data"][:]
304
+ cols = joints_group.attrs.get("columns", "[]")
305
+ if isinstance(cols, bytes):
306
+ cols = cols.decode()
307
+ joints_columns = json.loads(cols)
308
+
309
+ # 基准时间戳
310
+ ref_ts = None
311
+ for cam_group in camera_groups.values():
312
+ ref_ts = cam_group["timestamps"][:]
313
+ break
314
+ if ref_ts is None and "joints" in h5_file and "timestamps" in h5_file["joints"]:
315
+ ref_ts = h5_file["joints"]["timestamps"][:]
316
+
317
+ result: List[AlignedFrame] = []
318
+ for i in range(frames_count):
319
+ ts_ns = int(ref_ts[i]) if ref_ts is not None and i < len(ref_ts) else 0
320
+ images: Dict[str, Any] = {}
321
+ for cam_name, images_list in camera_images.items():
322
+ if i < len(images_list):
323
+ images[cam_name] = images_list[i]
324
+ joints: Dict[str, float] = {}
325
+ if joints_data is not None and i < len(joints_data):
326
+ for j, col in enumerate(joints_columns):
327
+ joints[col] = float(joints_data[i][j])
328
+ result.append(AlignedFrame(frame_index=i, timestamp_ns=ts_ns, images=images, joints=joints))
329
+ return result
330
+
331
+ def _write_pose_record(
332
+ self,
333
+ h5_file: h5py.File,
334
+ frames_count: int,
335
+ joints_data: Optional[np.ndarray],
336
+ joints_columns: List[str],
337
+ ref_ts: Optional[np.ndarray],
338
+ hdf5_path: Path,
339
+ output_dir: Path,
340
+ ) -> None:
341
+ """写 pose_record JSON 文件,尽量还原原始格式。"""
342
+ session_id = hdf5_path.stem
343
+
344
+ # 构造 pose_record frames
345
+ pose_frames = []
346
+ for i in range(frames_count):
347
+ ts_sec = float(ref_ts[i]) / 1e9 if ref_ts is not None and i < len(ref_ts) else 0.0
348
+ joints: Dict[str, float] = {}
349
+ if joints_data is not None and i < len(joints_data):
350
+ for j, col in enumerate(joints_columns):
351
+ joints[col] = float(joints_data[i][j])
352
+ pose_frames.append({
353
+ "frame_id": i,
354
+ "timestamp": ts_sec,
355
+ "data": joints,
356
+ })
357
+
358
+ if pose_frames:
359
+ start_time = pose_frames[0]["timestamp"]
360
+ end_time = pose_frames[-1]["timestamp"]
361
+ duration = end_time - start_time
362
+ else:
363
+ start_time = 0.0
364
+ end_time = 0.0
365
+ duration = 0.0
366
+
367
+ record = {
368
+ "session_id": session_id,
369
+ "start_time": start_time,
370
+ "end_time": end_time,
371
+ "duration": duration,
372
+ "frame_count": len(pose_frames),
373
+ "metadata": {},
374
+ "frames": pose_frames,
375
+ }
376
+
377
+ # 恢复额外 attrs
378
+ if "language" in h5_file.attrs:
379
+ lang = h5_file.attrs["language"]
380
+ if isinstance(lang, bytes):
381
+ lang = lang.decode()
382
+ record["language"] = lang
383
+ if "robot_type" in h5_file.attrs:
384
+ robot_type = h5_file.attrs["robot_type"]
385
+ if isinstance(robot_type, bytes):
386
+ robot_type = robot_type.decode()
387
+ record["robot_type"] = robot_type
388
+
389
+ pose_path = output_dir / f"pose_record_{session_id}.json"
390
+ with open(pose_path, "w", encoding="utf-8") as f:
391
+ json.dump(record, f, ensure_ascii=False, indent=2)
392
+
393
+ def _write_raw_files(self, h5_file: h5py.File, output_dir: Path) -> None:
394
+ """还原原始辅助文件(metadata.jsonl、tactile.jsonl)。"""
395
+ # metadata.jsonl
396
+ if "metadata_jsonl" in h5_file:
397
+ data = bytes(h5_file["metadata_jsonl"][()])
398
+ with open(output_dir / "metadata.jsonl", "wb") as f:
399
+ f.write(data)
400
+
401
+ # tactile.jsonl(可能不存在)
402
+ if "tactile_jsonl" in h5_file:
403
+ data = bytes(h5_file["tactile_jsonl"][()])
404
+ with open(output_dir / "tactile.jsonl", "wb") as f:
405
+ f.write(data)
406
+
407
+
408
+ # ── 便捷函数 ────────────────────────────────────────────────────
409
+
410
+ def find_hdf5_files(input_path: Path) -> List[Path]:
411
+ """查找 HDF5 文件:单个文件或目录下所有 .hdf5。"""
412
+ if input_path.is_file() and input_path.suffix in (".hdf5", ".h5"):
413
+ return [input_path]
414
+ if input_path.is_dir():
415
+ return sorted(input_path.rglob("*.hdf5"))
416
+ return []
417
+
418
+
419
+ def convert_hdf5(
420
+ hdf5_path: Path,
421
+ output_dir: Path,
422
+ strategy: Optional[AlignmentStrategy] = None,
423
+ ) -> int:
424
+ """转换单个 HDF5 文件到输出目录。
425
+
426
+ Args:
427
+ hdf5_path: HDF5 文件路径
428
+ output_dir: 输出目录(session 目录)
429
+ strategy: 对齐策略(None 用默认)
430
+
431
+ Returns:
432
+ 还原的帧数
433
+ """
434
+ converter = HDF5ToRawConverter(strategy=strategy)
435
+ return converter.convert(hdf5_path, output_dir)
436
+
437
+
438
+ # ── 批量处理 ──────────────────────────────────────────────────────
439
+
440
+ def convert_batch(
441
+ input_path: Path,
442
+ output_dir: Path,
443
+ strategy: Optional[AlignmentStrategy] = None,
444
+ ) -> Tuple[int, int]:
445
+ """批量转换 HDF5 文件。
446
+
447
+ Args:
448
+ input_path: 单个 HDF5 文件或包含 HDF5 的目录
449
+ output_dir: 输出根目录
450
+ strategy: 对齐策略(None 用默认)
451
+
452
+ Returns:
453
+ (成功数, 总帧数)
454
+ """
455
+ converter = HDF5ToRawConverter(strategy=strategy)
456
+ hdf5_files = find_hdf5_files(input_path)
457
+
458
+ if not hdf5_files:
459
+ print(f"未找到 HDF5 文件: {input_path}")
460
+ return 0, 0
461
+
462
+ print(f"找到 {len(hdf5_files)} 个 HDF5 文件")
463
+
464
+ success_count = 0
465
+ total_frames = 0
466
+
467
+ for hdf5_file in hdf5_files:
468
+ print(f"\n转换: {hdf5_file}")
469
+ # 保持目录结构
470
+ if input_path.is_dir():
471
+ rel = hdf5_file.relative_to(input_path)
472
+ # 去掉 .hdf5 后缀作为 session 目录名
473
+ out = output_dir / rel.with_suffix("")
474
+ else:
475
+ # 单文件:用文件名(去后缀)作为输出目录名
476
+ out = output_dir / hdf5_file.stem
477
+
478
+ try:
479
+ n = converter.convert(hdf5_file, out)
480
+ print(f" 还原 {n} 帧到 {out}")
481
+ success_count += 1
482
+ total_frames += n
483
+ except Exception as e:
484
+ print(f" [ERROR] {e}")
485
+
486
+ print(f"\n完成: {success_count}/{len(hdf5_files)} 文件, 共 {total_frames} 帧")
487
+ return success_count, total_frames
488
+
489
+
490
+ __all__ = [
491
+ "AlignedFrame",
492
+ "AlignmentStrategy",
493
+ "DefaultAlignmentStrategy",
494
+ "HDF5ToRawConverter",
495
+ "find_hdf5_files",
496
+ "convert_hdf5",
497
+ "convert_batch",
498
+ ]