File size: 22,373 Bytes
319eb16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
AgiBotWorld dataset loader for the generic dataset converter for Robometer model training.
This module contains AgiBotWorld-specific logic for loading and processing data using
HuggingFace streaming to efficiently handle large datasets.
"""

import json
import os
from functools import partial
from multiprocessing import Pool, cpu_count
from pathlib import Path
from typing import Any

import h5py
import numpy as np
from dataset_upload.helpers import (
    create_hf_trajectory,
    create_trajectory_video_optimized,
    load_sentence_transformer_model,
    generate_unique_id,
)
from dataset_upload.video_helpers import load_video_frames
from tqdm import tqdm

import datasets as hfds
from datasets import Dataset, load_dataset

# Episode/task helpers built earlier
from dataset_upload.data_scripts.agibot.agibot_helper import get_episode_record

# ------------------------------
# Small utilities
# ------------------------------

CAMERA_KEYS = {
    "head_color",
    "head_left_fisheye_color",
    "head_right_fisheye_color",
    "head_center_fisheye_color",
}


def _stable_shard_for_episode(episode_id: str, shard_modulus: int = 1000) -> str:
    """Return a stable top-level shard name based on episode id.

    Keeps at most ~shard_modulus episode directories per shard.
    """

    try:
        idx = int(episode_id)
    except Exception:
        idx = abs(hash(episode_id))
    shard_index = idx // shard_modulus
    return f"shard_{shard_index:04d}"


def _parse_episode_and_camera(key: str) -> tuple[str, str | None]:
    """Parse __key__ like '678985/videos/head_color' -> ('678985', 'head_color')."""
    parts = key.split("/")
    if len(parts) < 3:
        return parts[0], None
    return parts[0], parts[2]


def _build_video_paths(
    output_dir: str,
    dataset_name: str,
    episode_id: str,
    subtask_idx: int,
    camera: str,
) -> tuple[str, str]:
    """Return (full_path, relative_path) using a two-level shard + per-episode layout.

    Layout:
      <output>/<dataset>/<shard_X>/<episode_id>/clip_<k>@<camera>.mp4
    This avoids >1k files per directory while keeping resume-friendly structure.
    """
    shard_dir = _stable_shard_for_episode(episode_id)
    episode_dir = os.path.join(output_dir, dataset_name.lower(), shard_dir, f"episode_{episode_id}")
    os.makedirs(episode_dir, exist_ok=True)
    filename = f"clip_{subtask_idx}@{camera}.mp4"
    full_path = os.path.join(episode_dir, filename)
    rel_path = os.path.join(dataset_name.lower(), shard_dir, f"episode_{episode_id}", filename)
    return full_path, rel_path


def _collect_unique_texts_for_batch(records: list[tuple[str, dict]]) -> list[str]:
    """Collect unique instruction texts from a list of (episode_id, record) pairs."""
    texts: list[str] = []
    seen: set = set()
    for _episode_id, rec in records:
        # Full trajectory instruction
        full_text = rec.get("task_name") or rec.get("task_description") or ""
        if full_text and full_text not in seen:
            seen.add(full_text)
            texts.append(full_text)

        # Subtasks
        actions = rec.get("label_info", {}).get("action_config", [])
        for a in actions:
            t = (a or {}).get("action_text", "").strip()
            if t and t not in seen:
                seen.add(t)
                texts.append(t)
    return texts


def _encode_texts(texts: list[str], model) -> dict[str, Any]:
    """Encode a list of texts to vectors using a preloaded model, with caching."""
    if not texts:
        return {}
    vectors = model.encode(texts)
    return dict(zip(texts, vectors, strict=False))


def _frames_for_subrange(frames: np.ndarray, start: int, end: int) -> np.ndarray:
    """Return frames[start:end] with guardrails; [start, end) semantics."""
    start = max(int(start), 0)
    end = min(int(end), len(frames))
    if start >= end:
        return np.empty((0,), dtype=object)
    return frames[start:end]


def _process_single_stream_sample(
    sample: dict[str, Any],
    embeddings: dict[str, Any],
    output_dir: str,
    dataset_name: str,
    max_frames: int,
    fps: int,
) -> list[dict]:
    """Process one streaming sample: returns zero or more HF entries.

    This function does not load any language model; it expects embeddings for
    the relevant instruction texts to be provided.
    """

    result_entries: list[dict] = []

    # Extract key and keep only camera samples we care about
    key = sample.get("__key__") or ""
    episode_id, camera = _parse_episode_and_camera(key)
    if not camera or camera not in CAMERA_KEYS:
        return result_entries

    # Load associated episode record for task/subtasks
    try:
        _json_path, rec = get_episode_record(episode_id)
    except Exception:
        return result_entries

    # Get video bytes (dataset exposes only 'mp4', '__key__', '__url__')
    video_bytes = sample.get("mp4")
    if not video_bytes:
        return result_entries

    # Decode the video to frames once
    try:
        frames = load_video_frames(video_bytes)
    except Exception:
        return result_entries

    if frames is None or len(frames) == 0:
        return result_entries

    # Build entries: full + subtasks
    # Full trajectory
    full_text = rec.get("task_name") or rec.get("task_description") or ""
    if full_text:
        subtask_idx = 0
        full_out_path, rel_path = _build_video_paths(output_dir, dataset_name, episode_id, subtask_idx, camera)
        # Create video if missing
        if not os.path.exists(full_out_path):
            _ = create_trajectory_video_optimized(frames, full_out_path, max_frames=max_frames, fps=fps)

        lang_vec = embeddings.get(full_text)
        if lang_vec is None:
            # As a fallback, keep empty vector to avoid crashing
            lang_vec = np.zeros((384,), dtype=np.float32)

        traj_dict = {
            "id": generate_unique_id(),
            "frames": frames,  # Not used by create_hf_trajectory now since we already wrote, but pass for API
            "task": full_text,
            "is_robot": True,
            "quality_label": "successful",
            "preference_group_id": None,
            "preference_rank": None,
        }
        entry = create_hf_trajectory(
            traj_dict=traj_dict,
            video_path=full_out_path,
            lang_vector=lang_vec,
            max_frames=max_frames,
            dataset_name=dataset_name,
            use_video=True,
            fps=fps,
        )
        if entry:
            entry["frames"] = rel_path
            result_entries.append(entry)

    # Subtasks
    actions = rec.get("label_info", {}).get("action_config", [])
    for i, a in enumerate(actions, start=1):
        if not isinstance(a, dict):
            continue
        text = (a.get("action_text") or "").strip()
        if not text:
            continue
        start = a.get("start_frame", 0)
        end = a.get("end_frame", len(frames))
        sub_frames = _frames_for_subrange(frames, start, end)
        if sub_frames.size == 0:
            continue

        out_path, rel_path = _build_video_paths(output_dir, dataset_name, episode_id, i, camera)
        if not os.path.exists(out_path):
            _ = create_trajectory_video_optimized(sub_frames, out_path, max_frames=max_frames, fps=fps)

        lang_vec = embeddings.get(text)
        if lang_vec is None:
            lang_vec = np.zeros((384,), dtype=np.float32)

        traj_dict = {
            "id": generate_unique_id(),
            "frames": sub_frames,
            "task": text,
            "is_robot": True,
            "quality_label": "successful",
            "preference_group_id": None,
            "preference_rank": None,
        }
        entry = create_hf_trajectory(
            traj_dict=traj_dict,
            video_path=out_path,
            lang_vector=lang_vec,
            max_frames=max_frames,
            dataset_name=dataset_name,
            use_video=True,
            fps=fps,
        )
        if entry:
            entry["frames"] = rel_path
            result_entries.append(entry)

    return result_entries


def convert_agibotworld_streaming_to_hf(
    dataset_name: str,
    output_dir: str,
    dataset_label: str = "agibotworld",
    max_trajectories: int | None = None,
    max_frames: int = 64,
    fps: int = 10,
    num_workers: int = -1,
) -> Dataset:
    """Stream AgiBotWorld, extract camera videos, and write HF entries.

    Returns a datasets.Dataset built from the collected entries. All videos are
    saved to disk under output_dir.
    """

    # Load streaming dataset
    ds = load_dataset(dataset_name, streaming=True, split="train")
    # Some shards expose PNG frames instead of MP4. Widen features so casting
    # does not fail during iteration; we'll simply skip non-MP4 samples.
    widened = hfds.Features({
        "__key__": hfds.Value("string"),
        "__url__": hfds.Value("string"),
        "mp4": hfds.Value("binary"),
        "png": hfds.Value("binary"),
    })
    try:
        ds = ds.cast(widened)
    except Exception:
        pass

    # Determine workers
    if num_workers == -1:
        num_workers = max(1, min(cpu_count(), 8))
    elif num_workers == 0:
        num_workers = 1

    # Language model for batch embedding
    lang_model = load_sentence_transformer_model()

    entries: list[dict] = []
    processed = 0  # number of streaming samples actually flushed/processed
    default_batch_size = 64
    batch_size = default_batch_size if (max_trajectories is None) else min(default_batch_size, max_trajectories)
    batch_samples: list[dict[str, Any]] = []
    batch_records: list[tuple[str, dict]] = []

    # Simple live stats
    seen_samples = 0
    skipped_camera = 0
    skipped_no_record = 0
    skipped_no_mp4 = 0

    def flush_batch():
        nonlocal entries, processed, batch_samples, batch_records
        if not batch_samples:
            return

        # Collect unique texts and encode once
        unique_texts = _collect_unique_texts_for_batch(batch_records)
        emb_map = _encode_texts(unique_texts, lang_model)

        if num_workers == 1:
            for sample in tqdm(batch_samples, desc="Batch (seq)", leave=False):
                res = _process_single_stream_sample(
                    sample=sample,
                    embeddings=emb_map,
                    output_dir=output_dir,
                    dataset_name=dataset_label,
                    max_frames=max_frames,
                    fps=fps,
                )
                # res is a list; extend and update decode_fail if nothing produced due to decode error
                entries.extend(res)
        else:
            with Pool(processes=num_workers) as pool:
                worker = partial(
                    _process_single_stream_sample,
                    embeddings=emb_map,
                    output_dir=output_dir,
                    dataset_name=dataset_label,
                    max_frames=max_frames,
                    fps=fps,
                )
                for res in tqdm(
                    pool.imap_unordered(worker, batch_samples),
                    total=len(batch_samples),
                    desc=f"Batch (workers={num_workers})",
                    leave=False,
                ):
                    entries.extend(res)

        processed += len(batch_samples)
        batch_samples = []
        batch_records = []

    print(f"Streaming {dataset_name}; workers={num_workers}, batch_size={batch_size}")
    stream_pbar = tqdm(desc="Streaming samples", unit="sample", dynamic_ncols=True)

    for sample in ds:
        if max_trajectories is not None and processed >= max_trajectories:
            break

        key = sample.get("__key__", "")
        episode_id, camera = _parse_episode_and_camera(key)
        seen_samples += 1
        stream_pbar.update(1)
        if not camera or camera not in CAMERA_KEYS:
            skipped_camera += 1
            continue

        # Ensure episode record exists; gather for embedding planning
        try:
            _json_path, rec = get_episode_record(episode_id)
        except Exception:
            skipped_no_record += 1
            continue

        # Require mp4 content; if absent (e.g., png-only shard), skip early
        if not sample.get("mp4"):
            skipped_no_mp4 += 1
            continue

        batch_samples.append(sample)
        batch_records.append((episode_id, rec))

        if len(batch_samples) >= batch_size:
            flush_batch()

        # If user asked for a very small number, don't wait for another full batch
        if max_trajectories is not None and (processed + len(batch_samples)) >= max_trajectories:
            flush_batch()
            break

    # Final flush
    flush_batch()
    stream_pbar.close()

    # Build HF dataset from entries
    if not entries:
        return Dataset.from_dict({
            "id": [],
            "task": [],
            "lang_vector": [],
            "data_source": [],
            "frames": [],
            "is_robot": [],
            "quality_label": [],
            "preference_group_id": [],
            "preference_rank": [],
        })

    # datasets can infer features; rely on default
    print(
        f"Done. seen={seen_samples}, entries={len(entries)}, "
        f"skipped_camera={skipped_camera}, skipped_no_record={skipped_no_record}, "
        f"skipped_no_mp4={skipped_no_mp4}"
    )
    return Dataset.from_list(entries)


def load_agibotworld_dataset(dataset_name_or_path: str, max_trajectories: int = 100) -> dict[str, list[dict]]:
    """Load AgiBotWorld dataset using HuggingFace streaming and extract head_color.mp4 files.

    Args:
        dataset_name_or_path: HuggingFace dataset name (e.g. "agibot-world/AgiBotWorld-Alpha")
                             or local path to the dataset

    Returns:
        Dictionary mapping task names to lists of trajectory dictionaries
    """

    print(f"Loading AgiBotWorld dataset from: {dataset_name_or_path}")
    print("=" * 100)
    print("LOADING AGIBOTWORLD DATASET")
    print("=" * 100)

    task_data = {}

    # Check if it's a local path or HuggingFace dataset name
    if os.path.exists(dataset_name_or_path):
        # Local dataset
        task_data = _load_local_agibotworld(dataset_name_or_path, max_trajectories)
    else:
        # HuggingFace dataset - use streaming
        task_data = _load_streaming_agibotworld(dataset_name_or_path, max_trajectories)

    print(
        f"Loaded {sum(len(trajectories) for trajectories in task_data.values())} trajectories from {len(task_data)} tasks"
    )
    return task_data


# NOTE: As the dataset is too large, we did not test this function extensively and it may be out of date.
def _load_local_agibotworld(base_path: str, max_trajectories: int = 100, max_frames: int = 32) -> dict[str, list[dict]]:
    """Load AgiBotWorld dataset from local files, starting with task_info JSON files."""
    base_path = Path(base_path)
    task_data = {}

    # Define required directories
    observations_dir = base_path / "observations"
    task_info_dir = base_path / "task_info"
    proprio_stats_dir = base_path / "proprio_stats"

    if not observations_dir.exists():
        raise FileNotFoundError(f"Observations directory not found: {observations_dir}")
    if not task_info_dir.exists():
        raise FileNotFoundError(f"Task info directory not found: {task_info_dir}")

    # Start by iterating over task_info JSON files to get proper task names
    task_info_files = list(task_info_dir.glob("*.json"))

    if not task_info_files:
        raise FileNotFoundError(f"No task info JSON files found in: {task_info_dir}")

    print(f"Found {len(task_info_files)} task info files")

    total_trajectories = 0

    for task_info_file in tqdm(task_info_files, desc="Processing tasks"):
        if total_trajectories >= max_trajectories:
            print(f"Reached max_trajectories limit ({max_trajectories}), stopping...")
            break

        # Extract task ID from filename (e.g., "task_392.json" -> "392")
        task_id = task_info_file.stem.replace("task_", "")

        # Load task information from JSON
        task_info = _load_task_info(task_info_file)

        if not task_info:
            print(f"Skipping task {task_id} - no valid task info")
            continue

        # Extract proper task name from first episode (they should all have the same task)
        if task_info and len(task_info) > 0:
            first_episode = task_info[0]
            task_name = first_episode.get("task_name", f"Task {task_id}")
            first_episode.get("task_description", f"AgiBotWorld Task {task_id}")
        else:
            task_name = f"Task {task_id}"

        print(f"Processing task {task_id}: '{task_name}'")

        # Get the corresponding task directory
        task_dir = observations_dir / task_id
        if not task_dir.exists():
            print(f"Task directory not found: {task_dir}, skipping...")
            continue

        trajectories = []

        # Process episodes based on the information in task_info JSON
        for episode_info in task_info:
            if total_trajectories >= max_trajectories:
                break

            episode_id = str(episode_info.get("episode_id", ""))
            if not episode_id:
                continue

            # Check if episode directory exists
            episode_dir = task_dir / episode_id
            if not episode_dir.exists():
                print(f"Episode directory not found: {episode_dir}, skipping episode {episode_id}")
                continue

            # Look for head_color.mp4 file
            videos_dir = episode_dir / "videos"
            head_color_video = videos_dir / "head_color.mp4"

            if head_color_video.exists():
                # Load proprioceptive data
                proprio_file = proprio_stats_dir / task_id / episode_id / "proprio_stats.h5"
                actions = _load_actions_from_h5(proprio_file)

                # Process video: resize to 256x256 and downsample frames
                try:
                    processed_frames = load_video_frames(head_color_video)

                    trajectory = {
                        "frames": processed_frames,  # Processed video frames
                        "actions": actions,
                        "is_robot": True,  # AgiBotWorld is robot data
                        "task": task_name,  # Use the descriptive task name from JSON
                        "optimal": "optimal",  # Assume all AgiBotWorld trajectories are optimal
                    }
                except Exception as e:
                    print(f"  ❌ Failed to process video {head_color_video}: {e}")
                    continue

                trajectories.append(trajectory)
                total_trajectories += 1

                print(f"  ✅ Loaded episode {episode_id} ({total_trajectories}/{max_trajectories})")
            else:
                print(f"  ❌ head_color.mp4 not found for episode {episode_id}")

        if trajectories:
            # Use proper task name from JSON instead of generic "task_{id}"
            task_data[task_name] = trajectories
            print(f"Added {len(trajectories)} trajectories for task '{task_name}'")

    print(f"Loaded {total_trajectories} total trajectories from {len(task_data)} tasks")
    return task_data


def _load_streaming_agibotworld(dataset_name: str, max_trajectories: int = 100) -> dict[str, list[dict]]:
    """Legacy helper no longer used. Kept for compatibility."""
    raise NotImplementedError("Use convert_agibotworld_streaming_to_hf() for streaming conversion.")


def _load_task_info(task_info_file: Path) -> list[dict]:
    """Load task information from JSON file."""
    if not task_info_file.exists():
        print(f"Task info file not found: {task_info_file}")
        return []

    try:
        with open(task_info_file) as f:
            task_info = json.load(f)
        return task_info if isinstance(task_info, list) else [task_info]
    except Exception as e:
        print(f"Error loading task info from {task_info_file}: {e}")
        return []


def _load_actions_from_h5(proprio_file: Path) -> np.ndarray:
    """Load actions from proprioceptive H5 file."""
    if not proprio_file.exists():
        print(f"Proprioceptive file not found: {proprio_file}")
        return np.array([])

    try:
        with h5py.File(proprio_file, "r") as f:
            # According to AgiBotWorld docs, actions are stored under /action
            if "action" in f:
                action_group = f["action"]

                # Try to extract joint actions (most common for manipulation)
                if "joint" in action_group and "position" in action_group["joint"]:
                    actions = action_group["joint"]["position"][:]
                elif "end" in action_group and "position" in action_group["end"]:
                    # Use end-effector positions if joint positions not available
                    end_positions = action_group["end"]["position"][:]
                    end_orientations = (
                        action_group["end"]["orientation"][:] if "orientation" in action_group["end"] else None
                    )

                    if end_orientations is not None:
                        # Concatenate position and orientation for full 6-DOF actions
                        # Reshape orientations from [N, 2, 4] to [N, 8] (both arms)
                        end_orientations_flat = end_orientations.reshape(end_orientations.shape[0], -1)
                        # Reshape positions from [N, 2, 3] to [N, 6]
                        end_positions_flat = end_positions.reshape(end_positions.shape[0], -1)
                        actions = np.concatenate([end_positions_flat, end_orientations_flat], axis=1)
                    else:
                        actions = end_positions.reshape(end_positions.shape[0], -1)
                else:
                    print(f"No recognizable action data found in {proprio_file}")
                    return np.array([])

                return actions
            else:
                print(f"No action group found in {proprio_file}")
                return np.array([])

    except Exception as e:
        print(f"Error loading actions from {proprio_file}: {e}")
        return np.array([])