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.
- dexverse/__init__.py +54 -0
- dexverse/cli.py +422 -0
- dexverse/client.py +128 -0
- dexverse/config.py +85 -0
- dexverse/convert/__init__.py +38 -0
- dexverse/convert/hdf5_to_raw.py +498 -0
- dexverse/convert/image_video_encode.py +449 -0
- dexverse/downloader/__init__.py +26 -0
- dexverse/downloader/base.py +101 -0
- dexverse/downloader/http_backend.py +508 -0
- dexverse/downloader/manager.py +365 -0
- dexverse/downloader/python_s3_backend.py +137 -0
- dexverse/downloader/s5cmd_backend.py +170 -0
- dexverse/downloader/state.py +263 -0
- dexverse/downloader/verify.py +114 -0
- dexverse/manifest.py +284 -0
- dexverse/mock/__init__.py +2 -0
- dexverse/mock/mock_tasks.json +22 -0
- dexverse/mock/server.py +211 -0
- dexverse_cli-0.0.2.dist-info/METADATA +198 -0
- dexverse_cli-0.0.2.dist-info/RECORD +23 -0
- dexverse_cli-0.0.2.dist-info/WHEEL +4 -0
- dexverse_cli-0.0.2.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
]
|