File size: 5,268 Bytes
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
"""Camera/video grouping — 恢复双目(左右目)语义。

上传的 zip 里一台双目相机产生多个独立 mp4(videos/stereo_left/…、
videos/stereo_right/…),扁平 camera 列表会丢失"组"的语义,前端只能
把它们当成互不相关的视频。本模块按命名模式把扁平 camera 聚合为:

- 双目组(stereo):left + right 配对,``*_aux`` 为辅助流
- 单目组(single):无配对的独立 camera(如 head_left_rgb)

新旧数据兼容:新上传数据在 ``meta.camera_groups`` 里携带分组结果,
旧数据没有该字段,由 media-groups API 用本模块实时推导。
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

# 后缀从长到短,避免 stereo_left_aux 被误配为 ("stereo_left", "aux")
_ROLE_SUFFIXES = (
    ("_left_aux", "left_aux"),
    ("_right_aux", "right_aux"),
    ("_aux_left", "left_aux"),
    ("_aux_right", "right_aux"),
    ("_left", "left"),
    ("_right", "right"),
    ("_aux", "aux"),
)


def _role_of(name: str) -> tuple[str, str]:
    """提取 (前缀, 角色): stereo_left_aux → ("stereo", "left_aux")"""
    normalized = str(name).strip().lower()
    for suffix, role in _ROLE_SUFFIXES:
        if normalized.endswith(suffix):
            return normalized[: -len(suffix)].rstrip("_"), role
    return normalized, "primary"


def _member(name: str, role: str, stream: dict) -> dict:
    # 注意:role 必须最后赋值 —— stream 里可能带上游 _camera_role 的粗糙
    # role(如 stereo_left_aux → "left"),解包会错误覆盖推导出的 left_aux。
    return {"source_key": name, **(stream or {}), "role": role}


def group_camera_streams(camera_streams: dict[str, Any]) -> dict[str, Any]:
    """把扁平 ``{camera: stream}`` 聚合为 groups + singles。

    输入例如::

        {"stereo_left": {"path": "...", "frame_count": 258, "fps": 25},
         "stereo_right": {...}, "stereo_left_aux": {...}, "stereo_right_aux": {...}}

    返回::

        {"groups": [{"id": "stereo", "type": "stereo", "label": "stereo 双目",
                     "members": [left, right], "aux": [left_aux, right_aux]}],
         "singles": [{"source_key": "head_left_rgb", "role": "primary", ...}]}
    """
    parsed: dict[str, list[tuple[str, str, dict]]] = {}
    for name, stream in (camera_streams or {}).items():
        prefix, role = _role_of(name)
        parsed.setdefault(prefix, []).append((name, role, stream or {}))

    groups: list[dict] = []
    singles: list[dict] = []
    for prefix, members in parsed.items():
        roles = {role for _, role, _ in members}
        has_left = "left" in roles or "left_aux" in roles
        has_right = "right" in roles or "right_aux" in roles
        if has_left and has_right:
            group: dict[str, Any] = {
                "id": prefix,
                "type": "stereo",
                "label": prefix,
                "members": [],
                "aux": [],
            }
            for name, role, stream in members:
                entry = _member(name, role, stream)
                if role in ("left", "right"):
                    group["members"].append(entry)
                else:
                    group["aux"].append(entry)
            groups.append(group)
        else:
            for name, role, stream in members:
                singles.append(_member(name, role, stream))

    return {"groups": groups, "singles": singles}


def stereo_device_label(session_dir: Path | None, fallback: str,
                        episode_id: str | None = None) -> str:
    """从 ``calibration/*.json`` 找双目设备名作为组标签。

    ``head_stereo.json`` (type=stereo_rgbd_camera) → "head_stereo 双目",
    找不到则回退为前缀命名。
    """
    if session_dir is not None:
        calib_dir = Path(session_dir) / "calibration"
        if episode_id:
            namespaced = Path(session_dir) / "meta" / "calibration" / str(episode_id)
            if namespaced.is_dir():
                calib_dir = namespaced
        if not calib_dir.is_dir():
            calib_dir = Path(session_dir) / "meta" / "calibration"
        if calib_dir.is_dir():
            for f in sorted(calib_dir.glob("*.json")):
                try:
                    data = json.loads(f.read_text(encoding="utf-8"))
                except Exception:
                    continue
                if str(data.get("type", "")).startswith("stereo"):
                    name = data.get("name") or f.stem
                    if name:
                        return name
    return fallback


def has_skeleton_video(session_dir: Path | None, camera: str,
                       episode_id: str | None = None) -> bool:
    """探测批次内是否已有骨骼叠加视频(worker 输出)。

    手部叠加由前端将 parquet 关键点绘制到原始 RGB 视频上,因此这里不再
    探测或保留第二份骨骼视频。
    """
    if session_dir is None:
        return False
    return False


def video_dir_has_skeleton(video_root: Path) -> bool:
    """``videos/<camera>/`` 目录下是否有骨骼叠加视频。"""
    if not video_root.is_dir():
        return False
    return any("_skeleton" in p.name for p in video_root.glob("*_skeleton*.mp4"))