File size: 32,559 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
d646dd7
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
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
"""HDF5 dataset export — 与 LeRobot 导出同源数据,写成单一 .h5 文件。

组织(与 LeRobot observation 语义对齐,一集一个 group):

    dataset.h5
    ├─ /meta/info                      attrs: format/version/fps/features
    ├─ /episode_000000
    │  ├─ /observation
    │  │  ├─ /images/<source_key>      (T, H, W, 3) uint8   视频抽帧(RGB)
    │  │  ├─ /state/hand_left_world    (T, 21, 3) float32   米制 3D 骨骼
    │  │  ├─ /state/hand_right_world   (T, 21, 3) float32
    │  │  ├─ /tactile/left             (T, 16, 16) float32  SenseGlove 压力阵列
    │  │  ├─ /tactile/right            (T, 16, 16) float32
    │  │  ├─ /hand_pose/left           (T, 63) float32      采集端姿态参数
    │  │  ├─ /hand_pose/right          (T, 63) float32
    │  │  ├─ /imu                      (T, 6) float32
    │  │  ├─ /hand_0_gesture ...       (T,) 变长字符串      手指伸展标签(角度判定)
    │  │  └─ /annotation ...           (T,) 变长字符串列
    │  ├─ /action                      (T, D) float32
    │  ├─ /videos/hand_skeleton        (nbytes,) uint8    手部骨骼渲染 MP4 原始字节
    │  └─ /meta                        attrs: episode_id/task/split/annotations

数值列统一 float32(缺帧 NaN 填充),字符串列变长 utf8;
视频逐帧流式写入(内存安全),gzip 压缩 + 每帧一个 chunk。
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from app.localstore import get_episode
from app.lerobot_export import (
    _annotation_maps,
    _calibration_docs,
    _depth_video_paths,
    _hand_columns,
    _infer_hand_3d_unit,
    _key_matches_any,
    _prepare_hand_export_inputs,
    _probe_video,
    _read_hand_rows,
    _read_hand_3d_rows_by_source,
    _device_hand_features,
    _device_metadata,
    _read_sensor_rows,
    _source_path,
)

# 采集端姿态参数(63)与压力阵列(256)的语义路径
_TACTILE_KEYS = ("observation.tactile.left", "observation.tactile.right")
_POSE_KEYS = ("observation.left_hand_pose", "observation.right_hand_pose")


def _read_timestamps(session_dir: Path):
    """读取采集端 timestamps.json(帧级时间戳,每帧 2 条 = 左右目各 1 条)。

    返回 (M, 4) float64 数组 [frame_index, timestamp(秒), wall_time(Unix 秒),
    hardware_ns], 保持原始顺序; 文件缺失/解析失败 → None(不影响导出)。
    """
    import numpy as np
    path = Path(session_dir) / "timestamps.json"
    if not path.exists():
        return None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        rows = data.get("timestamps") or []
        if not rows:
            return None
        return np.asarray(
            [[float(r.get("frame_index", 0)), float(r.get("timestamp", 0)),
              float(r.get("wall_time", 0)), float(r.get("hardware_ns", 0))]
             for r in rows], dtype=np.float64)
    except Exception:
        return None


def _find_hand_render_video(hand_3d_paths: list[str], session_dir: Path,
                            hand_keypoints_paths: list[str] | None = None) -> Path | None:
    """No rendered hand video is exported; previews are browser overlays."""
    return None


def _collect(columns: dict[str, dict[int, Any]], str_columns: set[str],
             key: str, value: Any, frame_index: int) -> None:
    """按 key + 帧号收集(字典天然对齐,缺帧在写入时 NaN/空串填充)。"""
    if isinstance(value, (str, bytes)):
        str_columns.add(key)
    columns.setdefault(key, {})[frame_index] = value


def _write_columns(obs_group: Any, columns: dict[str, dict[int, Any]],
                   str_columns: set[str], T: int) -> dict[str, dict]:
    """把收集好的列写成 h5py dataset,返回 {feature key: 描述}。"""
    import h5py
    import numpy as np
    features: dict[str, dict] = {}

    for key, by_frame in columns.items():
        if not by_frame:
            continue
        path = key[len("observation."):].replace(".", "/") \
            if key.startswith("observation.") else key

        # 字符串列:变长 utf8
        if key in str_columns:
            ds = obs_group.create_dataset(
                path, shape=(T,), dtype=h5py.string_dtype(encoding="utf-8"),
                compression="gzip")
            for i in range(T):
                ds[i] = str(by_frame.get(i, ""))
            features[key if key.startswith("observation.") else f"observation.{key}"] = {
                "dtype": "string", "shape": [1]}
            continue

        # 数值列:首值定 shape,缺帧 NaN
        first = next(value for value in by_frame.values() if value is not None)
        base_shape = np.asarray(first).shape
        is_tactile = key in _TACTILE_KEYS
        if is_tactile:
            base_shape = (16, 16)
        # 手部骨骼列与 LeRobot 一致走 63 位扁平存储,这里恢复 (21,3)
        is_hand_coordinate = any(
            key == f"observation.state.hand_{side}_{coord}{suffix}"
            for side in ("left", "right")
            for coord in ("world", "3d", "2d")
            for suffix in ("", "_rcam")
        ) or ("observation.state.devices." in key and any(
            f"hand_{side}_{coord}" in key
            for side in ("left", "right")
            for coord in ("world", "3d", "2d")
        ))
        if is_hand_coordinate:
            base_shape = (21, 3)
        # 跨帧形状不一致 = 变长聚合(如 IMU 每帧样本数不定)→ 平铺 + 帧索引,
        # 不丢数据;固定形状走常规 (T, ...) NaN 填充
        shapes = {np.asarray(v).shape for v in by_frame.values() if v is not None}
        if len(shapes) > 1:
            # dtype 保持源语义:整型(如 imu_ts_ns 的 ns 时间戳)用 int64,
            # 否则 float32 —— float32 只有 7 位有效数字,会丢 ns 精度
            kinds = {np.asarray(v).dtype.kind for v in by_frame.values() if v is not None}
            out_dtype = np.int64 if kinds <= {"i", "u"} else np.float32
            arrays = [np.asarray(v, dtype=out_dtype)
                      for v in by_frame.values() if v is not None]
            # 1D 变长列(如 imu_ts_ns)→ 列向量 (N,1) 再按帧拼接;
            # 2D+ 列(如 imu (N,6))保持原样(axis=0 按帧堆叠)
            arrays = [a if a.ndim >= 2 else a.reshape(-1, 1) for a in arrays]
            dim = arrays[0].shape[-1]
            flat = np.concatenate(arrays, axis=0)
            frame_ids = np.concatenate([
                np.full(len(a), fi, dtype=np.int32)
                for fi, a in zip(by_frame.keys(), arrays)])
            ds = obs_group.create_dataset(path, data=flat, compression="gzip")
            ds.attrs["note"] = (
                "变长聚合:每帧样本数不定(如 IMU 按帧聚合,200Hz/25fps≈8样本/帧);"
                f"按 {path}_frame_index 切片还原每帧样本")
            obs_group.create_dataset(path + "_frame_index", data=frame_ids,
                                     compression="gzip")
            features[key if key.startswith("observation.") else f"observation.{key}"] = {
                "dtype": str(np.dtype(out_dtype)), "shape": [dim],
                "note": "变长聚合(每帧样本数不定),配 {path}_frame_index 列切片还原",
            }
            continue
        stacked = np.full((T,) + base_shape, np.nan, dtype=np.float64)
        for i, value in by_frame.items():
            arr = np.asarray(value)
            if is_tactile:
                arr = arr.reshape(16, 16)
            elif is_hand_coordinate:
                arr = arr.reshape(21, 3)
            stacked[i] = arr
        ds = obs_group.create_dataset(path, data=stacked.astype(np.float32),
                                      compression="gzip")
        features[key if key.startswith("observation.") else f"observation.{key}"] = {
            "dtype": "float32",
            "shape": list(stacked.shape[1:]) or [1],
        }
    return features


def build_hdf5_dataset(dataset_name: str, episode_ids: list[str], output_dir: Path,
                       split_ratio: float = 0.9,
                       include_video_keys: list[str] | None = None,
                       hand_keypoints_paths: list[str] | None = None,
                       hand_3d_paths: list[str] | None = None,
                       hand_3d_unit: str | None = None,
                       compression: str = "gzip", level: int = 4) -> Path:
    """把批次构建为单一 .h5 数据集(与 LeRobot 导出同源数据)。

    ``include_video_keys``: 只抽帧这些 source_key 对应的视频(工作流连接
    驱动);None = 全量;空列表 = 不抽帧。

    ``hand_keypoints_paths`` / ``hand_3d_paths``: 手部骨骼/深度 3D 产物。
    RGB_TO_3D 的 hand_3d 仅用于前端预览,导出时自动读取其中的 2D 关键点;
    只有真实深度产物才写入 HDF5 的 world 3D 字段。
    """
    try:
        import h5py
    except ImportError as exc:
        raise RuntimeError("h5py is required for HDF5 export") from exc
    import cv2
    import numpy as np

    episodes = [get_episode(str(episode_id)) for episode_id in episode_ids]
    episodes = [episode for episode in episodes if episode]
    if not episodes:
        raise RuntimeError("No valid episodes selected for export")

    output_dir.mkdir(parents=True, exist_ok=True)
    out_path = output_dir / f"{dataset_name}.h5"

    (hand_keypoints_paths, hand_3d_paths, _hand_3d_right_paths,
     hand_3d_unit) = _prepare_hand_export_inputs(
         hand_keypoints_paths, hand_3d_paths, None, hand_3d_unit)
    hand_rows = _read_hand_rows(hand_keypoints_paths, hand_3d_paths)
    device_hand_rows = _read_hand_3d_rows_by_source(hand_3d_paths)
    hand_3d_unit = hand_3d_unit or _infer_hand_3d_unit(hand_3d_paths)
    coordinate_key = (
        "world" if hand_3d_unit in {"camera_meters", "meter", "meters"} else
        "3d" if hand_3d_paths else "2d"
    )
    is_3d = any(
        entry and entry.get("is_3d")
        for hand in hand_rows.values()
        for entry in hand.values()
    ) if hand_rows else False

    total_frames = 0
    features: dict[str, dict] = {}
    fps_first = float(episodes[0].get("fps") or 30)

    with h5py.File(str(out_path), "w") as f:
        meta = f.create_group("meta/info")

        for out_episode_index, episode in enumerate(episodes):
            episode_id = str(episode["id"])
            session_dir = Path(episode["path"])
            streams = episode.get("camera_streams") or {}
            sensor_rows = _read_sensor_rows(session_dir)
            ts_arr = _read_timestamps(session_dir)
            annotation_map, annotation_defs, _skipped_candidates = _annotation_maps(episode_id)
            episode_fps = float(episode.get("fps") or 30)

            # 连接驱动:只抽帧连到导出节点的视频源
            video_meta: list[tuple[str, Path, int, float, int, int]] = []
            for source_key, stream in sorted(streams.items()):
                if include_video_keys is not None and not _key_matches_any(source_key, include_video_keys):
                    continue
                source = _source_path(stream.get("path"))
                if source is None or not source.exists():
                    continue
                count, vfps, width, height = _probe_video(source)
                video_meta.append((source_key, source, count or 0, vfps or episode_fps,
                                   width, height))

            sensor_max = max(sensor_rows.keys(), default=-1) + 1
            hand_max = max(hand_rows.keys(), default=-1) + 1
            T = max([meta[2] for meta in video_meta] + [sensor_max, hand_max, 0])
            total_frames += T

            ep = f.create_group(f"episode_{out_episode_index:06d}")
            obs = ep.create_group("observation")

            # 1) 视频 → 逐帧抽帧写 uint8 数组(gzip + 每帧 chunk,流式省内存)
            for source_key, source, count, vfps, width, height in video_meta:
                if width <= 0 or height <= 0 or T <= 0:
                    continue
                ds = obs.create_dataset(
                    f"images/{source_key}", shape=(T, height, width, 3),
                    dtype="uint8", chunks=(1, height, width, 3),
                    compression=compression, compression_opts=level)
                ds.attrs["fps"] = vfps
                ds.attrs["source_key"] = source_key
                ds.attrs["source_episode"] = episode_id
                cap = cv2.VideoCapture(str(source))
                try:
                    i = 0
                    while i < T:
                        ok, frame = cap.read()
                        if not ok:
                            break
                        ds[i] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
                        i += 1
                finally:
                    cap.release()
                features[f"observation.images.{source_key}"] = {
                    "dtype": "uint8", "shape": [T, height, width, 3],
                    "video_info": {"video.fps": vfps, "video.is_depth_map": False},
                }

            # 1b) 深度流 → 逐帧 uint16 毫米(单独一组,不和 RGB 混在 images/ 下)
            #
            # 深度**不在** camera_streams 里 —— 它单独放在 depth/<source>/,
            # 所以上面的 RGB 循环根本看不到它,必须用 lerobot_export 的发现函数。
            #
            # 解码必须走 DepthVideoReader(ffmpeg 解 gray12le):OpenCV 会把
            # 12-bit 深度当普通显示图转成 8-bit,真实毫米值全丢。
            #
            # **0 表示"没测到深度"的无效像素,不是 0 毫米** —— 与采集端语义一致,
            # 原样保留,不要当数值去算均值。
            for source_key, depth_path in _depth_video_paths(session_dir, episode_id):
                if include_video_keys is not None and not _key_matches_any(
                        source_key, include_video_keys):
                    continue
                reader = None
                try:
                    from app.lerobot_v21 import DEPTH_VIDEO_ENCODING, DepthVideoReader
                    reader = DepthVideoReader(depth_path)
                    dheight, dwidth = reader.height, reader.width
                    if dheight <= 0 or dwidth <= 0 or T <= 0:
                        continue
                    dfps = float(reader.fps or episode_fps)
                    ds = obs.create_dataset(
                        f"depth/{source_key}", shape=(T, dheight, dwidth),
                        dtype="uint16", chunks=(1, dheight, dwidth),
                        compression=compression, compression_opts=level)
                    ds.attrs["unit"] = "millimetre"
                    ds.attrs["invalid_value"] = 0
                    ds.attrs["fps"] = dfps
                    ds.attrs["source_key"] = source_key
                    ds.attrs["source_episode"] = episode_id
                    ds.attrs["encoding"] = DEPTH_VIDEO_ENCODING
                    i = 0
                    while i < T:
                        frame = reader.read_mm()
                        if frame is None:
                            break
                        ds[i] = frame
                        i += 1
                    features[f"observation.depth.{source_key}"] = {
                        "dtype": "uint16", "shape": [T, dheight, dwidth],
                        "unit": "millimetre",
                        "note": "0 = 无效像素(该点未测到深度),不是 0 毫米",
                        "video_info": {
                            "video.fps": dfps,
                            "video.is_depth_map": True,
                            "video.height": dheight,
                            "video.width": dwidth,
                        },
                    }
                except Exception as exc:
                    print(f"[hdf5_export] depth stream {source_key} "
                          f"skipped: {exc}")
                finally:
                    if reader is not None:
                        try:
                            reader.close()
                        except Exception:
                            pass

            # 2) 表列:传感器 + 手部骨骼 + 标注(与 LeRobot 列同构,按帧号对齐)
            columns: dict[str, dict[int, Any]] = {}
            str_columns: set[str] = set()
            for frame_index in range(T):
                row: dict[str, Any] = {}
                row.update(sensor_rows.get(frame_index, {}))
                hand = hand_rows.get(frame_index)
                if hand:
                    row.update(_hand_columns(hand, coordinate_key=coordinate_key))
                for device_source, rows_by_frame in device_hand_rows.items():
                    device_hand = rows_by_frame.get(frame_index)
                    if device_hand:
                        row.update(_hand_columns(
                            device_hand, coordinate_key=coordinate_key,
                            device_namespace=device_source))
                anno, anno_index, _scope = annotation_map.get(
                    frame_index, (None, -1, ["episode"]))
                row["annotation"] = anno or ""
                row["annotation_index"] = anno_index
                for key, value in row.items():
                    _collect(columns, str_columns, key, value, frame_index)

            # 3) 动作列(独立 dataset,非 observation 子项)
            if "action" in columns and "action" not in str_columns:
                import numpy as np
                by_frame = columns["action"]
                first = next(value for value in by_frame.values()
                             if value is not None)
                act = np.full((T,) + np.asarray(first).shape, np.nan)
                for i, value in by_frame.items():
                    act[i] = np.asarray(value)
                if act.ndim == 1:
                    act = act.reshape(-1, 1)
                ep.create_dataset("action", data=act.astype(np.float32),
                                  compression="gzip")
                features["action"] = {"dtype": "float32",
                                      "shape": list(act.shape[1:]) or [1]}
                del columns["action"]

            col_features = _write_columns(obs, columns, str_columns, T)
            features.update(col_features)
            if device_hand_rows:
                device_unit = hand_3d_unit or (
                    "mediapipe_world_relative" if coordinate_key == "3d"
                    else "normalized_image_coords")
                device_note = (
                    "深度图抬升相机系米制 3D(每个物理设备独立坐标系,米)"
                    if coordinate_key == "world" else
                    "MediaPipe world_landmarks 相对 3D(设备独立)"
                    if coordinate_key == "3d" else
                    "MediaPipe 21 关键点归一化图像坐标(设备独立)")
                for device_source in device_hand_rows:
                    features.update(_device_hand_features(
                        device_source, coordinate_key, device_unit, device_note))

            # 2b) 采集端帧级时间戳(timestamps.json 原样保留):
            # 每帧 2 条 = 左右目各 1 条; timestamp(秒) / wall_time(Unix 秒) /
            # hardware_ns 三路时钟,客户可做帧级时间对齐分析。
            if ts_arr is not None and len(ts_arr):
                tds = obs.create_dataset("timestamps", data=ts_arr,
                                         compression="gzip")
                tds.attrs["note"] = (
                    "采集端帧时间戳(每帧2条=左右目各1条),列:"
                    "[frame_index, timestamp(秒), wall_time(Unix秒), hardware_ns];"
                    "timestamp/wall_time 交叉验证文件帧率与真实录制时长一致")
                features["observation.timestamps"] = {
                    "dtype": "float64", "shape": list(ts_arr.shape),
                    "note": "采集端帧时间戳,每帧2条(左右目),"
                            "列=[frame_index, timestamp, wall_time, hardware_ns]",
                }

            # 4) 手部骨骼渲染视频(MP4 原始字节)→ /videos/hand_skeleton。
            # 与 hand_3d parquet 同源交付;attrs 记录格式信息,客户可提取为
            # 独立 MP4 直接用播放器打开(无需 h5py 逐帧还原)。只存原始字节,
            # 体积小(约 10 MB),不重复抽帧。
            hand_video = _find_hand_render_video(
                hand_3d_paths or [], session_dir, hand_keypoints_paths)
            if hand_video is not None:
                vcount, vfps, vwidth, vheight = _probe_video(hand_video)
                mp4_bytes = np.frombuffer(hand_video.read_bytes(), dtype="uint8")
                vds = ep.create_group("videos").create_dataset(
                    "hand_skeleton", data=mp4_bytes)
                vds.attrs["format"] = "mp4"
                vds.attrs["fps"] = vfps
                vds.attrs["width"] = vwidth
                vds.attrs["height"] = vheight
                vds.attrs["frames"] = vcount
                vds.attrs["source"] = hand_video.name
                # 单目(mediapipe_hand 的 skeleton/ 产物)与双目(三角化
                # hand_3d/ 产物)渲染视频语义不同, description 区分开。
                mono = hand_video.parent.name == "skeleton"
                vds.attrs["description"] = (
                    "MediaPipe hand-skeleton render video "
                    "(single camera, skeleton overlay)" if mono
                    else "Stereo-triangulated hand-skeleton render video "
                         "(left/right side by side, skeleton overlay)")
                features["videos.hand_skeleton"] = {
                    "dtype": "uint8", "format": "mp4", "fps": vfps,
                    "width": vwidth, "height": vheight, "frames": vcount,
                    "note": "MP4 原始字节,提取后为独立 .mp4 文件",
                }

            # 5) episode meta
            ep_meta = ep.create_group("meta")
            ep_meta.attrs["episode_id"] = episode_id
            ep_meta.attrs["task_description"] = str(episode.get("project") or "")
            ep_meta.attrs["fps"] = episode_fps
            ep_meta.attrs["length"] = T
            ep_meta.attrs["devices"] = json.dumps(
                _device_metadata(session_dir), ensure_ascii=False)
            ep_meta.attrs["split"] = (
                "train" if out_episode_index < max(1, int(len(episodes) * split_ratio))
                else "val")
            if annotation_defs:
                ep_meta.attrs["annotations"] = json.dumps(
                    annotation_defs, ensure_ascii=False)

            # 6) 标定 → /meta/calibration/<episode>/<name> attrs(JSON 字符串)
            # 每集一份,所以把 episode_id 一起传进去 —— 批次内各集标定可以不同,
            # 不传就只会看到批次级的 calibration/ 目录。
            # 缺失/全零时的默认标定回退统一在 lerobot_export._calibration_docs。
            cal_group = meta.create_group(f"calibration/{episode_id}")
            cal_docs = _calibration_docs(session_dir, episode_id)
            for name, value in cal_docs.items():
                cal_group.attrs[name] = json.dumps(value, ensure_ascii=False)

        # 手部骨骼 features 补充(unit 语义)
        if hand_rows:
            for side in ("left", "right"):
                key = f"observation.state.hand_{side}_{coordinate_key}"
                if key in features:
                    features[key].update({
                        "unit": hand_3d_unit or (
                            "mediapipe_world_relative" if coordinate_key == "3d"
                            else "normalized_image_coords"),
                        "note": (
                            "深度图抬升相机系 3D 世界坐标(米)"
                            if coordinate_key == "world" else
                            "MediaPipe 21 关键点相对 3D"
                            if coordinate_key == "3d" else
                            "MediaPipe 21 关键点归一化图像坐标"),
                    })

        meta.attrs["format"] = "egodata-hdf5"
        meta.attrs["version"] = "1.0"
        meta.attrs["total_episodes"] = len(episodes)
        meta.attrs["total_frames"] = total_frames
        meta.attrs["fps"] = fps_first
        meta.attrs["features"] = json.dumps(features, ensure_ascii=False)
        meta.attrs["calibration_root"] = "calibration/"

        # 内嵌说明文档(结构树/内容表/读取示例)—— 随 h5 交付,
        # 客户无需额外文档即可理解数据。在 f 关闭前生成(需要实际形状)。
        f.create_dataset("meta/README", data=_build_readme(dataset_name, f))

    return out_path


def _tree_lines(entries: list[tuple[list[str], str]], prefix: str = "") -> list[str]:
    """把 (路径段, 描述文本) 渲染为 ASCII 树行(递归,按段分组)。"""
    groups: dict[str, list[tuple[list[str], str]]] = {}
    for parts, note in entries:
        groups.setdefault(parts[0], []).append((parts[1:], note))

    lines: list[str] = []
    keys = sorted(groups)
    for i, key in enumerate(keys):
        sub = groups[key]
        is_last = i == len(keys) - 1
        branch = "└─ " if is_last else "├─ "
        if sub and sub[0][0]:  # 有子项 → 目录
            lines.append(f"{prefix}{branch}{key}/")
            lines.extend(_tree_lines(sub, prefix + ("   " if is_last else "│  ")))
        else:
            lines.append(f"{prefix}{branch}{key:<34} {sub[0][1]}")
    return lines


def _build_readme(dataset_name: str, h5: Any) -> str:
    """从 h5 文件实际结构生成内嵌说明(中文):结构树/内容表/读取示例。

    直接遍历 dataset(真实 shape),不用 features 语义表(那里 shape 是
    LeRobot 语义,如 [1] 表示每帧 1 值,对客户不直观)。
    """
    import h5py as _h5py

    # —— 结构树(遍历实际 dataset,跳过 meta/ 与 README 自身)——
    entries: list[tuple[list[str], str]] = []

    def visit(name: str, obj: Any) -> None:
        if not isinstance(obj, _h5py.Dataset):
            return
        parts = name.split("/")
        if parts[0] == "meta":
            return
        shape = "(" + ", ".join(str(s) for s in obj.shape) + ")"
        note = f"{shape:<28} {str(obj.dtype)}"
        attrs = dict(obj.attrs)
        if attrs.get("format") == "mp4":
            note += f" · MP4({attrs.get('frames')}帧,可提取为视频)"
        entries.append((parts, note))

    h5.visititems(visit)
    root = "episode_000000"
    root_items = [e for e in entries if e[0][0] == root]
    others = [e for e in entries if e[0][0] != root]
    tree: list[str] = [f"{root}/"]
    tree.extend(_tree_lines([(parts[1:], note) for parts, note in root_items], ""))
    for parts, note in others:
        tree.append(f"{'/'.join(parts)}    {note}")
    tree_str = "\n".join(tree)

    # —— 内容表(按实际结构判断)——
    all_names = {"/".join(e[0]) for e in entries}
    rows: list[str] = []
    img_keys = sorted(n for n in all_names
                      if n.startswith(f"{root}/observation/images/"))
    if img_keys:
        # 不写"全量" —— 视频可能被工作流的 include_video_keys 过滤过,而且
        # 深度流走 depth_sources、不经 camera_streams,本文件**从来不导**。
        # 声称"全量"会让客户以为拿到的是全部视频。
        rows.append(f"| **原视频** | {len(img_keys)} 路原始帧(逐帧 uint8 RGB)"
                    " | ✅ 已导出,清单见上方结构树 |")
    depth_keys = sorted(n for n in all_names
                        if n.startswith(f"{root}/observation/depth/"))
    if depth_keys:
        rows.append(f"| **深度图** | {len(depth_keys)} 路,uint16 **毫米**"
                    " | ✅ 0 = 无效像素(未测到),不是 0 毫米 |")
    hand_coordinate = next(
        (coord for coord in ("world", "3d", "2d")
         if f"{root}/observation/state/hand_left_{coord}" in all_names),
        None,
    )
    if hand_coordinate:
        description = {
            "world": "21 个关键点 × X/Y/Z 深度相机米制坐标",
            "3d": "21 个关键点 × X/Y/Z MediaPipe 相对 3D 坐标",
            "2d": "21 个关键点 × X/Y/Z 归一化图像坐标",
        }[hand_coordinate]
        rows.append(
            f"| **手部 {hand_coordinate.upper()} 骨骼** | {description}"
            " | ✅ 见有效帧标记列 |")
    skel = f"{root}/videos/hand_skeleton"
    if skel in all_names:
        skel_attrs = h5[skel].attrs
        rows.append(
            f"| **手部骨骼渲染视频** | `videos/hand_skeleton`:MP4 原始字节"
            f"({skel_attrs['width']}×{skel_attrs['height']} 左右目并排,骨架叠加) | "
            f"✅ {skel_attrs['frames']} 帧,{skel_attrs['fps']} fps |")
    if any(n.startswith(f"{root}/observation/tactile/") for n in all_names):
        rows.append("| **手套压力** | 16×16 压力阵列(SenseGlove) | ✅ 全帧 |")
    if f"{root}/observation/imu" in all_names:
        rows.append("| **IMU** | 6 轴惯性数据,与视频帧对齐 | ✅ 变长聚合,"
                    "配 `imu_frame_index` 切片还原 |")
    if f"{root}/observation/timestamps" in all_names:
        rows.append("| **帧时间戳** | 采集端每帧 2 条(左右目):"
                    "`timestamp` / `wall_time` / `hardware_ns` 三路时钟 |"
                    "✅ 用于帧级时间对齐分析 |")
    content_table = "\n".join(rows)

    # 提取视频章节仅在文件确实含 videos/hand_skeleton 时输出
    video_extract = ""
    if skel in all_names:
        video_extract = """
### 提取手部骨骼渲染视频(MP4)

`videos/hand_skeleton` 存的是 MP4 文件的**原始字节**,提取后即为标准 MP4 文件:

```python
import h5py

with h5py.File("dataset.h5", "r") as h5:
    mp4_bytes = h5["episode_000000/videos/hand_skeleton"][...].tobytes()

with open("hand_skeleton.mp4", "wb") as f:
    f.write(mp4_bytes)
```
"""

    # 读取示例:只给文件里**确实存在**的路径。原来是左目写死 stereo_left、
    # 手部无数据时仍回落到 hand_right_3d —— 客户照抄都是 KeyError;而且
    # `import h5py` 在 0 列、`with` 却缩进了 4 格,示例本身就不是合法 Python。
    first_img = img_keys[0].split("/")[-1] if img_keys else None
    example = ["import h5py", "", 'with h5py.File("dataset.h5", "r") as h5:']
    if first_img:
        example.append(f"    # 读一帧图像({first_img})")
        example.append(f'    img = h5["{root}/observation/images/{first_img}"][0]')
    if depth_keys:
        first_depth = depth_keys[0].split("/")[-1]
        example.append(f"    # 读一帧深度({first_depth};uint16 毫米,0 = 无效像素)")
        example.append(f'    depth = h5["{root}/observation/depth/{first_depth}"][0]')
    if hand_coordinate:
        example.append("    # 读第一帧右手骨骼(字段可能是 world / 3d / 2d)")
        example.append(f'    hand3d = h5["{root}/observation/state/'
                       f'hand_right_{hand_coordinate}"][0]')
    if f"{root}/observation/annotation" in all_names:
        example.append("    # 读全部标注")
        example.append(f'    labels = h5["{root}/observation/annotation"][:]')
    read_example = "\n".join(example)

    readme = f"""# {dataset_name} 数据文件说明(HDF5 格式)

> 本文件由 EgoData 数据采集系统工作流自动生成
> 帧数/fps 与内容以 meta/info attrs(features)为准

---

## 一、文件是什么

本文件是 **HDF5 格式(Hierarchical Data Format 5)** 的单个数据容器——
一个文件内以"文件夹树"结构同时存放视频、传感器数据、手部 3D 骨骼、
骨骼渲染视频、标注等多种数据,便于传输、归档和程序化读取。

一个批次(一次数据采集片段)= 文件中的一个 episode。

## 二、文件内部结构

```
{tree_str}
```

## 三、核心内容一览

| 内容 | 说明 | 状态 |
|---|---|---|
{content_table}

## 四、如何读取

```python
{read_example}
```
{video_extract}
其他工具:HDFView、Panoply、MATLAB(h5read)等支持 HDF5 的软件均可打开。

---

*本说明随数据一同交付。*
"""
    return readme