quyu666 commited on
Commit
af470e1
·
1 Parent(s): af37f5b

sync processor from GitLab ac3323f: 深度格式兼容(legacy_mm10+逐批次覆盖)+ UMI Gripper 模块与测试

Browse files
Files changed (30) hide show
  1. processor/app/api/projects.py +35 -0
  2. processor/app/artifact_resolver.py +11 -2
  3. processor/app/device_naming.py +9 -0
  4. processor/app/lerobot_v21.py +26 -3
  5. processor/app/main.py +1 -0
  6. processor/app/processing/batch.py +13 -2
  7. processor/app/processing/modules/gripper_device.py +191 -0
  8. processor/app/project_dataset.py +120 -4
  9. processor/app/routes/ingestion.py +16 -0
  10. processor/app/routes/session.py +57 -12
  11. processor/app/routes/video.py +686 -14
  12. processor/app/workflow_dispatch.py +9 -6
  13. processor/app/workflow_instantiate.py +21 -4
  14. processor/app/workflow_types.py +44 -0
  15. processor/scripts/ensure_stack_linux.sh +5 -2
  16. processor/tests/test_gripper_device.py +109 -0
  17. processor/tests/test_project_dataset.py +93 -0
  18. processor/tests/test_tactile_decode.py +27 -0
  19. processor/web/static/js/hand-overlay.js +23 -0
  20. processor/web/static/js/player.js +1286 -70
  21. processor/web/static/workflow-studio/.vite/manifest.json +1 -1
  22. processor/web/static/workflow-studio/assets/{index-KixdmCX9.js → index-CcWpS4e6.js} +0 -0
  23. processor/web/static/workflow-studio/index.html +1 -1
  24. processor/web/templates/index.html +2 -2
  25. processor/web/workflow-studio/src/components/WorkflowCanvas/NodePalette.tsx +1 -1
  26. processor/web/workflow-studio/src/components/WorkflowCanvas/WorkflowCanvas.tsx +7 -1
  27. processor/web/workflow-studio/src/components/WorkflowCanvas/nodes/WorkflowNode.tsx +9 -3
  28. processor/web/workflow-studio/src/components/WorkflowCanvas/nodes/registry.ts +21 -0
  29. processor/web/workflow-studio/src/store/workflowStore.ts +32 -7
  30. processor/web/workflow-studio/src/types/workflow.ts +1 -1
processor/app/api/projects.py CHANGED
@@ -186,6 +186,24 @@ def _device_input_sources(episodes: list[dict]) -> list[dict]:
186
  continue
187
  low = [key.lower() for key in keys]
188
  kind = f"{item.get('kind') or ''} {item.get('name') or ''}".lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  left_right = (any(key.endswith("_left") or "_left_" in key for key in low)
190
  and any(key.endswith("_right") or "_right_" in key for key in low))
191
  is_stereo = len(keys) >= 2 and (left_right or "stereo" in kind)
@@ -244,6 +262,23 @@ def _online_input_sources(devices: list[dict]) -> list[dict]:
244
  if str(key).strip()]
245
  stereo = len(keys) >= 2 and (left_right or "stereo" in kind)
246
  has_depth = bool(depth_keys) or "depth" in kind or "rgbd" in kind
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  result.append({
248
  "id": str(device.get("device_id") or name), "name": name,
249
  "kind": device.get("kind") or "camera",
 
186
  continue
187
  low = [key.lower() for key in keys]
188
  kind = f"{item.get('kind') or ''} {item.get('name') or ''}".lower()
189
+ if "gripper" in kind or "umi" in kind:
190
+ # RGB/stereo/force channels published by UMI are one physical
191
+ # acquisition device. Keep video source keys for compatibility,
192
+ # but expose one composite workflow input card.
193
+ rgb_key = next(
194
+ (key for key in keys
195
+ if "rgb" in key.lower()
196
+ and "stereo_left" not in key.lower()
197
+ and "stereo_right" not in key.lower()),
198
+ keys[0],
199
+ )
200
+ item["source_keys"] = keys
201
+ item["slots"] = slots
202
+ item["input_type"] = "gripper_device"
203
+ item["source_key"] = rgb_key
204
+ item["label"] = f"{item['name']} · UMI Gripper"
205
+ result.append(item)
206
+ continue
207
  left_right = (any(key.endswith("_left") or "_left_" in key for key in low)
208
  and any(key.endswith("_right") or "_right_" in key for key in low))
209
  is_stereo = len(keys) >= 2 and (left_right or "stereo" in kind)
 
262
  if str(key).strip()]
263
  stereo = len(keys) >= 2 and (left_right or "stereo" in kind)
264
  has_depth = bool(depth_keys) or "depth" in kind or "rgbd" in kind
265
+ if "gripper" in kind or "umi" in kind:
266
+ result.append({
267
+ "id": str(device.get("device_id") or name), "name": name,
268
+ "kind": device.get("kind") or "gripper",
269
+ "input_type": "gripper_device",
270
+ "source_key": next(
271
+ (key for key in keys
272
+ if "rgb" in key.lower()
273
+ and "stereo_left" not in key.lower()
274
+ and "stereo_right" not in key.lower()),
275
+ keys[0],
276
+ ),
277
+ "source_keys": keys,
278
+ "depth_keys": depth_keys,
279
+ "label": f"{name} · UMI Gripper",
280
+ })
281
+ continue
282
  result.append({
283
  "id": str(device.get("device_id") or name), "name": name,
284
  "kind": device.get("kind") or "camera",
processor/app/artifact_resolver.py CHANGED
@@ -121,8 +121,17 @@ def _canonical_episode_data(session_dir: Path, episode_id: str | None,
121
  / f"episode_{index:06d}.parquet")
122
  if not path.is_file():
123
  return None
124
- import pandas as pd
125
- columns = {str(name) for name in pd.read_parquet(path, engine="pyarrow").columns}
 
 
 
 
 
 
 
 
 
126
  if dimension == "3d":
127
  marker = any(
128
  "landmarks_3d" in name or "world_position" in name
 
121
  / f"episode_{index:06d}.parquet")
122
  if not path.is_file():
123
  return None
124
+ try:
125
+ import pyarrow.parquet as pq
126
+ # Use logical top-level names; physical schema names flatten nested
127
+ # landmark lists to ``element`` and make valid 3D artifacts look
128
+ # empty.
129
+ columns = {str(name) for name in
130
+ pq.ParquetFile(path).schema_arrow.names}
131
+ except Exception:
132
+ import pandas as pd
133
+ columns = {str(name) for name in
134
+ pd.read_parquet(path, columns=[], engine="pyarrow").columns}
135
  if dimension == "3d":
136
  marker = any(
137
  "landmarks_3d" in name or "world_position" in name
processor/app/device_naming.py CHANGED
@@ -18,6 +18,7 @@ DEVICE_LABELS = {
18
  "mono_rgb": "RGB Camera",
19
  "stereo_rgb": "Stereo RGB Camera",
20
  "glove_sensor": "Glove Sensor",
 
21
  }
22
 
23
 
@@ -59,6 +60,12 @@ def camera_profile(kind: object = "", name: object = "",
59
  slot_keys = _keys(slots)
60
  text = " ".join([kind_text, name_text, *[key.lower() for key in slot_keys]])
61
 
 
 
 
 
 
 
62
  # Collectors do not always spell RGB slots with ``rgb``/``color``;
63
  # ``stereo_left`` and ``stereo_right`` are also color video streams. Any
64
  # non-depth-only slot is therefore a color/video slot unless metadata
@@ -105,6 +112,8 @@ def decorate_device_sources(sources: list[dict]) -> list[dict]:
105
  input_type = str(source.get("input_type") or "")
106
  if input_type == "glove_sensor":
107
  profile, lens = "glove_sensor", None
 
 
108
  else:
109
  profile, lens = camera_profile(
110
  source.get("kind"), source.get("name"),
 
18
  "mono_rgb": "RGB Camera",
19
  "stereo_rgb": "Stereo RGB Camera",
20
  "glove_sensor": "Glove Sensor",
21
+ "gripper_device": "UMI Gripper",
22
  }
23
 
24
 
 
60
  slot_keys = _keys(slots)
61
  text = " ".join([kind_text, name_text, *[key.lower() for key in slot_keys]])
62
 
63
+ # UMI publishes several internal channels (RGB, stereo and tactile data)
64
+ # but it is one physical workflow input. Do this before stereo detection
65
+ # so its internal left/right camera slots are not exposed as a camera card.
66
+ if "gripper" in kind_text or "umi" in name_text:
67
+ return "gripper_device", None
68
+
69
  # Collectors do not always spell RGB slots with ``rgb``/``color``;
70
  # ``stereo_left`` and ``stereo_right`` are also color video streams. Any
71
  # non-depth-only slot is therefore a color/video slot unless metadata
 
112
  input_type = str(source.get("input_type") or "")
113
  if input_type == "glove_sensor":
114
  profile, lens = "glove_sensor", None
115
+ elif input_type == "gripper_device":
116
+ profile, lens = "gripper_device", None
117
  else:
118
  profile, lens = camera_profile(
119
  source.get("kind"), source.get("name"),
processor/app/lerobot_v21.py CHANGED
@@ -170,16 +170,33 @@ def _depth_video_info_from_path(path: Path) -> dict[str, Any]:
170
  info = _read_json(info_path)
171
  feature = (info.get("features") or {}).get(
172
  f"observation.images.{source}", {})
173
- video_info = feature.get("video_info") if isinstance(feature, dict) else None
174
- return dict(video_info) if isinstance(video_info, dict) else {}
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  return {}
176
 
177
 
178
  def _depth_video_mode(video_info: dict[str, Any]) -> str:
179
- """Return ``log``, ``legacy_log`` or ``direct_mm`` for a stored stream."""
180
  if not isinstance(video_info, dict):
181
  video_info = {}
182
  encoding = str(video_info.get("video.depth_encoding") or "").lower()
 
 
 
 
183
  # Older uploads explicitly identify the gray12le samples as direct
184
  # millimetres. This check must happen before the generic gray12le
185
  # fallback below; otherwise a direct-mm stream is interpreted as a log
@@ -765,6 +782,12 @@ class DepthVideoReader:
765
  # processing must retain its invalid-pixel semantics.
766
  depth[codes == 0] = 0
767
  return depth
 
 
 
 
 
 
768
  if self.mode == "legacy_log":
769
  low = float(self.video_info["video.depth_min"])
770
  high = float(self.video_info["video.depth_max"])
 
170
  info = _read_json(info_path)
171
  feature = (info.get("features") or {}).get(
172
  f"observation.images.{source}", {})
173
+ video_info = dict(feature.get("video_info")) if isinstance(feature, dict) and isinstance(feature.get("video_info"), dict) else {}
174
+ # 逐批次覆盖:episodes 索引行可声明 video.depth_encoding,
175
+ # 用于"同一会话混合新旧格式"的历史数据(如 9/4 对数批次与
176
+ # 8 月 mm/10 批次并存于一个会话)。
177
+ try:
178
+ import pyarrow.parquet as pq
179
+ idx = info_path.parent / "episodes" / "chunk-000" / f"{path.stem}.parquet"
180
+ if idx.is_file():
181
+ row = pq.read_table(idx).to_pandas().iloc[0]
182
+ override = row.get("video.depth_encoding")
183
+ if override and str(override).strip():
184
+ video_info["video.depth_encoding"] = str(override).strip()
185
+ except Exception:
186
+ pass
187
+ return video_info
188
  return {}
189
 
190
 
191
  def _depth_video_mode(video_info: dict[str, Any]) -> str:
192
+ """Return ``log``, ``legacy_log``, ``legacy_mm10`` or ``direct_mm`` for a stored stream."""
193
  if not isinstance(video_info, dict):
194
  video_info = {}
195
  encoding = str(video_info.get("video.depth_encoding") or "").lower()
196
+ # 8 月老采集端深度按 0.1 系数存储(毫米/10),显式声明为 legacy_mm10。
197
+ # 该检查必须在 uint16_mm 检查之前,否则会被当成直接毫米读出 10 倍偏小。
198
+ if "mm10" in encoding or "mm/10" in encoding:
199
+ return "legacy_mm10"
200
  # Older uploads explicitly identify the gray12le samples as direct
201
  # millimetres. This check must happen before the generic gray12le
202
  # fallback below; otherwise a direct-mm stream is interpreted as a log
 
782
  # processing must retain its invalid-pixel semantics.
783
  depth[codes == 0] = 0
784
  return depth
785
+ if self.mode == "legacy_mm10":
786
+ # 8 月老采集端把毫米深度按 0.1 系数存储(码值 = mm/10),
787
+ # ×10 还原毫米;0 仍保持无效像素语义。
788
+ depth = codes.astype(np.float64) * 10.0
789
+ depth[codes == 0] = 0.0
790
+ return np.rint(np.clip(depth, 0, 65535)).astype("<u2")
791
  if self.mode == "legacy_log":
792
  low = float(self.video_info["video.depth_min"])
793
  high = float(self.video_info["video.depth_max"])
processor/app/main.py CHANGED
@@ -80,6 +80,7 @@ app.add_middleware(AuthMiddleware)
80
  # 像卡死。这些路径直接透传原始字节(去掉 accept-encoding 头即可)。
81
  _NO_COMPRESS_PATH_HINTS = (
82
  "download", "stream", "depth-codes", "depth-preview", "skeleton", "hdf5",
 
83
  )
84
 
85
 
 
80
  # 像卡死。这些路径直接透传原始字节(去掉 accept-encoding 头即可)。
81
  _NO_COMPRESS_PATH_HINTS = (
82
  "download", "stream", "depth-codes", "depth-preview", "skeleton", "hdf5",
83
+ "tactile-matrix-data",
84
  )
85
 
86
 
processor/app/processing/batch.py CHANGED
@@ -40,7 +40,7 @@ _CAMERA_TYPE_RULES: dict[str, tuple[str, ...]] = {
40
  }
41
 
42
  # 匹配优先级(显式顺序,不依赖注册序):双目先认领,单目兜底
43
- _MATCH_PRIORITY = ["stereo_rgbd_camera", "stereo_camera", "rgbd_camera", "mono_camera"]
44
 
45
  # 传感器类输入源关键词:mono 兜底认领时跳过(不抢手套/IMU/深度等)
46
  _SENSOR_NAME_KEYWORDS = ("glove", "imu", "action", "tactile", "sensor",
@@ -89,7 +89,18 @@ def match_input_modules(available: set[str]) -> list[dict]:
89
  break
90
  # 2) 类型兜底
91
  if not hits:
92
- if node_type == "mono_camera":
 
 
 
 
 
 
 
 
 
 
 
93
  # 单目 = 兜底认领剩余相机名(跳过传感器/深度类名称)
94
  for a in sorted(available):
95
  if a in used:
 
40
  }
41
 
42
  # 匹配优先级(显式顺序,不依赖注册序):双目先认领,单目兜底
43
+ _MATCH_PRIORITY = ["gripper_device", "stereo_rgbd_camera", "stereo_camera", "rgbd_camera", "mono_camera"]
44
 
45
  # 传感器类输入源关键词:mono 兜底认领时跳过(不抢手套/IMU/深度等)
46
  _SENSOR_NAME_KEYWORDS = ("glove", "imu", "action", "tactile", "sensor",
 
89
  break
90
  # 2) 类型兜底
91
  if not hits:
92
+ if node_type == "gripper_device":
93
+ # A UMI gripper publishes several internal channel names but
94
+ # represents one physical acquisition source.
95
+ gripper_hits = []
96
+ for a in sorted(available):
97
+ if a in used:
98
+ continue
99
+ if "gripper" in a or "umi" in a:
100
+ gripper_hits.append(a)
101
+ used.add(a)
102
+ hits.extend(gripper_hits)
103
+ elif node_type == "mono_camera":
104
  # 单目 = 兜底认领剩余相机名(跳过传感器/深度类名称)
105
  for a in sorted(available):
106
  if a in used:
processor/app/processing/modules/gripper_device.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UMI Gripper device input.
2
+
3
+ This is a composite workflow *source* node. The acquisition client uploads
4
+ one gripper episode, while the workflow receives its RGB video, ESP32 grip
5
+ state, SLAM trajectory and the two calibrated tactile force matrices through
6
+ typed output ports. The node does not control hardware or recapture data.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from app.processing import ArtifactRef, JobContext, ProcessingModule, field
15
+ from app.processing.registry import register
16
+ from app.processing.theme import INPUT_COLOR
17
+
18
+
19
+ _VIDEO_OUTPUT = "rgb_video"
20
+ _PARQUET_COLUMNS = {
21
+ "gripper_state": "observation.gripper_state",
22
+ "slam_trajectory": "observation.slam_pose",
23
+ }
24
+ _TACTILE_COLUMNS = {
25
+ "left": "observation.gripper_left_force_matrix",
26
+ "right": "observation.gripper_right_force_matrix",
27
+ }
28
+ _ALL_PARQUET_COLUMNS = set(_PARQUET_COLUMNS.values()) | set(_TACTILE_COLUMNS.values())
29
+
30
+
31
+ def _read_info(root: Path) -> dict:
32
+ path = root / "meta" / "info.json"
33
+ try:
34
+ value = json.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, ValueError, TypeError):
36
+ return {}
37
+ return value if isinstance(value, dict) else {}
38
+
39
+
40
+ def _values(value: object) -> list[str]:
41
+ if isinstance(value, str):
42
+ return [item.strip() for item in value.split(",") if item.strip()]
43
+ if isinstance(value, (list, tuple, set)):
44
+ return [str(item).strip() for item in value if str(item).strip()]
45
+ return []
46
+
47
+
48
+ def _selected_gripper(info: dict, config: dict) -> dict:
49
+ """Return the selected physical gripper declaration, if any."""
50
+ wanted = {item.casefold() for item in (
51
+ _values(config.get("source_keys"))
52
+ + _values(config.get("source_key"))
53
+ + _values(config.get("device_id"))
54
+ + _values(config.get("device_name"))
55
+ )}
56
+ candidates = []
57
+ for item in info.get("devices") or []:
58
+ if not isinstance(item, dict):
59
+ continue
60
+ kind = str(item.get("kind") or "").casefold()
61
+ name = str(item.get("name") or "")
62
+ key = str(item.get("key") or "")
63
+ slots = _values(item.get("slots"))
64
+ haystack = {value.casefold() for value in (name, key, *slots) if value}
65
+ if "gripper" in kind or "gripper" in name.casefold() or "umi" in name.casefold():
66
+ candidates.append((bool(wanted & haystack), item))
67
+ if candidates:
68
+ candidates.sort(key=lambda pair: (not pair[0], str(pair[1].get("key") or pair[1].get("name") or "")))
69
+ return candidates[0][1]
70
+ return {}
71
+
72
+
73
+ def _video_entries(root: Path) -> list[tuple[str, Path]]:
74
+ from app.lerobot_v21 import iter_video_streams
75
+ videos_root = root / "videos"
76
+ if not videos_root.is_dir():
77
+ return []
78
+ return [(str(source), path) for source, path in iter_video_streams(videos_root)]
79
+
80
+
81
+ def _find_rgb_video(root: Path, info: dict, config: dict) -> tuple[str, Path] | None:
82
+ entries = _video_entries(root)
83
+ if not entries:
84
+ return None
85
+ device = _selected_gripper(info, config)
86
+ slots = {value.casefold() for value in _values(device.get("slots"))}
87
+ configured = {value.casefold() for value in (
88
+ _values(config.get("source_keys")) + _values(config.get("source_key"))
89
+ )}
90
+
91
+ def is_primary(source: str) -> bool:
92
+ low = source.casefold()
93
+ return (low in slots or low in configured
94
+ or "gripper_rgb" in low or ("gripper" in low and "rgb" in low)) and not (
95
+ "stereo_left" in low or "stereo_right" in low
96
+ or low.endswith("_left") or low.endswith("_right"))
97
+
98
+ for source, path in entries:
99
+ if is_primary(source):
100
+ return source, path
101
+ # A valid device declaration may use a non-standard RGB slot name. If
102
+ # the selected gripper has exactly one non-stereo video, use that slot.
103
+ non_stereo = [item for item in entries if not any(
104
+ token in item[0].casefold() for token in ("stereo_left", "stereo_right"))]
105
+ if len(non_stereo) == 1 and (device or configured):
106
+ return non_stereo[0]
107
+ return None
108
+
109
+
110
+ def _parquet_columns(root: Path) -> tuple[Path, set[str]] | None:
111
+ try:
112
+ import pyarrow.parquet as pq
113
+ except ImportError:
114
+ return None
115
+ candidates: list[tuple[Path, set[str]]] = []
116
+ for path in sorted(root.rglob("*.parquet")):
117
+ if "/meta/" in path.as_posix().casefold():
118
+ continue
119
+ try:
120
+ names = set(str(name) for name in pq.read_schema(path).names)
121
+ except (OSError, ValueError):
122
+ continue
123
+ if names & _ALL_PARQUET_COLUMNS:
124
+ candidates.append((path, names))
125
+ if not candidates:
126
+ return None
127
+ candidates.sort(key=lambda item: (-len(item[1] & _ALL_PARQUET_COLUMNS), item[0].as_posix()))
128
+ return candidates[0]
129
+
130
+
131
+ @register
132
+ class GripperDeviceModule(ProcessingModule):
133
+ slug = "gripper_device"
134
+ version = "1.1"
135
+ category = "input"
136
+ label = "UMI Gripper"
137
+ icon = "ant-design:robot-outlined"
138
+ color = INPUT_COLOR
139
+ outputs = (
140
+ {"key": "rgb_video", "label": "RGB Video"},
141
+ {"key": "gripper_state", "label": "ESP Gripper State"},
142
+ {"key": "slam_trajectory", "label": "SLAM Trajectory"},
143
+ {"key": "tactile_force_matrices", "label": "Left and Right Force Matrices"},
144
+ )
145
+ default_config = {"source_key": "", "fps": 30}
146
+ config_schema = (
147
+ field("source_key", "string", "Gripper device / source key", ""),
148
+ )
149
+ execution_target = "server"
150
+ capabilities = ("composite_device", "video_input", "gripper", "slam", "tactile")
151
+
152
+ def run(self, ctx: JobContext) -> dict[str, ArtifactRef] | None:
153
+ info = _read_info(ctx.input_root)
154
+ outputs: dict[str, ArtifactRef] = {}
155
+ rgb = _find_rgb_video(ctx.input_root, info, ctx.config)
156
+ if rgb is not None:
157
+ source, path = rgb
158
+ outputs[_VIDEO_OUTPUT] = ctx.ref(
159
+ "gripper_rgb_video", path, source_key=source,
160
+ metadata={"device_type": "umi_gripper"},
161
+ )
162
+
163
+ parquet = _parquet_columns(ctx.input_root)
164
+ if parquet is not None:
165
+ path, columns = parquet
166
+ for output, column in _PARQUET_COLUMNS.items():
167
+ if column not in columns:
168
+ continue
169
+ metadata = {"column": column, "device_type": "umi_gripper"}
170
+ if output == "slam_trajectory":
171
+ metadata.update({"shape": [7], "names": ["x", "y", "z", "qx", "qy", "qz", "qw"]})
172
+ outputs[output] = ctx.ref(
173
+ output, path, source_key=column, metadata=metadata,
174
+ )
175
+ tactile_columns = [column for column in _TACTILE_COLUMNS.values() if column in columns]
176
+ if len(tactile_columns) == len(_TACTILE_COLUMNS):
177
+ outputs["tactile_force_matrices"] = ctx.ref(
178
+ "tactile_force_matrices", path,
179
+ source_key="observation.gripper_force_matrices",
180
+ metadata={
181
+ "columns": tactile_columns,
182
+ "sides": ["left", "right"],
183
+ "shape": [2, 250, 250, 3],
184
+ "components": ["fx", "fy", "fz"],
185
+ "device_type": "umi_gripper",
186
+ },
187
+ )
188
+
189
+ if not outputs:
190
+ ctx.skip("UMI gripper RGB/video and data channels were not found")
191
+ return outputs
processor/app/project_dataset.py CHANGED
@@ -876,6 +876,55 @@ def _merge_unique_dicts(existing: list[dict[str, Any]], additions: Any,
876
  return result
877
 
878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  def _merge_info(project_root: Path, source_info: dict[str, Any], rows: list[dict[str, Any]],
880
  tasks: list[dict[str, Any]]) -> dict[str, Any]:
881
  target = _read_json(project_root / "meta" / "info.json", {})
@@ -885,8 +934,16 @@ def _merge_info(project_root: Path, source_info: dict[str, Any], rows: list[dict
885
  source_info = normalize_metadata_sources(source_info)
886
  if not target:
887
  target = dict(source_info)
 
 
 
888
  features = dict(target.get("features") or {})
889
  features.update(dict(source_info.get("features") or {}))
 
 
 
 
 
890
  target.update({
891
  "format": "lerobot_v2.1",
892
  "codebase_version": "v2.1",
@@ -917,17 +974,71 @@ def _merge_info(project_root: Path, source_info: dict[str, Any], rows: list[dict
917
  target["devices"] = _merge_unique_dicts(
918
  target.get("devices") or [], source_info.get("devices"), ("key",),
919
  )
 
 
 
 
 
 
 
 
 
 
 
 
920
  names = dict(target.get("device_names") or {})
921
  names.update(dict(source_info.get("device_names") or {}))
922
- target["device_names"] = names
 
 
 
923
  cameras = dict(target.get("cameras") or {})
924
  cameras.update(dict(source_info.get("cameras") or {}))
925
- target["cameras"] = cameras
 
 
 
926
  sensors = list(dict.fromkeys(
927
  [str(value) for value in (target.get("sensors") or [])]
928
  + [str(value) for value in (source_info.get("sensors") or [])]
929
  ))
930
- target["sensors"] = sensors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
  return target
932
 
933
 
@@ -1741,8 +1852,13 @@ def allocate_project_episode_id(project_root: Path, incoming_name: str,
1741
  return incoming
1742
  used = [int(row.get("episode_index")) for row in existing
1743
  if _episode_index(row.get("episode_index")) is not None]
1744
- next_index = max(used, default=0) + 1
1745
  prefix = str(project_name or project_root.name or "episode").strip()
 
 
 
 
 
1746
  return f"{prefix}_{next_index:06d}"
1747
 
1748
 
 
876
  return result
877
 
878
 
879
+ def _project_data_contract(project_root: Path) -> tuple[set[str], set[str], set[str]]:
880
+ """Return the data columns, real video sources and semantic device slots.
881
+
882
+ ``meta/info.json`` is an aggregate and can outlive a camera stream after a
883
+ re-upload. The canonical files are authoritative for the feature list.
884
+ This helper deliberately treats unreadable Parquet as empty instead of
885
+ preserving stale declarations that make the UI request missing media.
886
+ """
887
+ root = Path(project_root)
888
+ data_columns: set[str] = set()
889
+ data_root = root / "data"
890
+ if data_root.is_dir():
891
+ try:
892
+ import pyarrow.parquet as pq
893
+ for path in data_root.rglob("*.parquet"):
894
+ try:
895
+ data_columns.update(str(name) for name in pq.read_schema(path).names)
896
+ except (OSError, ValueError):
897
+ continue
898
+ except ImportError:
899
+ pass
900
+
901
+ video_sources = {
902
+ str(source) for source, _path in iter_video_streams(root / "videos")
903
+ if str(source).strip()
904
+ }
905
+ semantic_slots = set(video_sources)
906
+ if "observation.slam_pose" in data_columns:
907
+ semantic_slots.add("gripper_pose")
908
+ if "observation.gripper_left_force" in data_columns:
909
+ semantic_slots.add("gripper_force_left")
910
+ if "observation.gripper_right_force" in data_columns:
911
+ semantic_slots.add("gripper_force_right")
912
+ if "observation.gripper_left_force_matrix" in data_columns:
913
+ semantic_slots.add("gripper_force_matrix_left")
914
+ if "observation.gripper_right_force_matrix" in data_columns:
915
+ semantic_slots.add("gripper_force_matrix_right")
916
+ return data_columns, video_sources, semantic_slots
917
+
918
+
919
+ def _feature_is_present(key: str, feature: Any,
920
+ data_columns: set[str], video_sources: set[str]) -> bool:
921
+ if not isinstance(feature, dict):
922
+ return key in data_columns
923
+ if feature.get("dtype") == "video" or key.startswith("observation.images."):
924
+ return key.removeprefix("observation.images.") in video_sources
925
+ return key in data_columns
926
+
927
+
928
  def _merge_info(project_root: Path, source_info: dict[str, Any], rows: list[dict[str, Any]],
929
  tasks: list[dict[str, Any]]) -> dict[str, Any]:
930
  target = _read_json(project_root / "meta" / "info.json", {})
 
934
  source_info = normalize_metadata_sources(source_info)
935
  if not target:
936
  target = dict(source_info)
937
+ for key in ("project_id", "project_name"):
938
+ if source_info.get(key) and not target.get(key):
939
+ target[key] = source_info[key]
940
  features = dict(target.get("features") or {})
941
  features.update(dict(source_info.get("features") or {}))
942
+ data_columns, video_sources, semantic_slots = _project_data_contract(project_root)
943
+ features = {
944
+ key: value for key, value in features.items()
945
+ if _feature_is_present(key, value, data_columns, video_sources)
946
+ }
947
  target.update({
948
  "format": "lerobot_v2.1",
949
  "codebase_version": "v2.1",
 
974
  target["devices"] = _merge_unique_dicts(
975
  target.get("devices") or [], source_info.get("devices"), ("key",),
976
  )
977
+ filtered_devices: list[dict[str, Any]] = []
978
+ for device in target["devices"]:
979
+ if not isinstance(device, dict):
980
+ continue
981
+ slots = [str(slot) for slot in (device.get("slots") or [])
982
+ if str(slot) in semantic_slots]
983
+ if not slots:
984
+ continue
985
+ item = dict(device)
986
+ item["slots"] = slots
987
+ filtered_devices.append(item)
988
+ target["devices"] = filtered_devices
989
  names = dict(target.get("device_names") or {})
990
  names.update(dict(source_info.get("device_names") or {}))
991
+ target["device_names"] = {
992
+ key: value for key, value in names.items()
993
+ if key in semantic_slots
994
+ }
995
  cameras = dict(target.get("cameras") or {})
996
  cameras.update(dict(source_info.get("cameras") or {}))
997
+ target["cameras"] = {
998
+ key: value for key, value in cameras.items()
999
+ if key in video_sources
1000
+ }
1001
  sensors = list(dict.fromkeys(
1002
  [str(value) for value in (target.get("sensors") or [])]
1003
  + [str(value) for value in (source_info.get("sensors") or [])]
1004
  ))
1005
+ target["sensors"] = [
1006
+ sensor for sensor in sensors
1007
+ if any(sensor.casefold() in column.casefold() for column in data_columns)
1008
+ ]
1009
+ # Make the common UMI/SLAM contract machine-readable without guessing the
1010
+ # meaning of the legacy three-value ``gripper_state`` vector.
1011
+ semantic_fields = {
1012
+ "observation.slam_pose": {
1013
+ "names": ["x", "y", "z", "qx", "qy", "qz", "qw"],
1014
+ "units": ["m", "m", "m", "unitless", "unitless", "unitless", "unitless"],
1015
+ "quaternion_order": "xyzw",
1016
+ },
1017
+ "observation.gripper_left_force": {
1018
+ "names": ["fx", "fy", "fz"],
1019
+ "units": ["sensor_unit", "sensor_unit", "sensor_unit"],
1020
+ },
1021
+ "observation.gripper_right_force": {
1022
+ "names": ["fx", "fy", "fz"],
1023
+ "units": ["sensor_unit", "sensor_unit", "sensor_unit"],
1024
+ },
1025
+ "observation.gripper_left_force_matrix": {
1026
+ "names": ["fx", "fy", "fz"],
1027
+ "units": ["mN", "mN", "mN"],
1028
+ "encoding": "row_diff_quantized",
1029
+ },
1030
+ "observation.gripper_right_force_matrix": {
1031
+ "names": ["fx", "fy", "fz"],
1032
+ "units": ["mN", "mN", "mN"],
1033
+ "encoding": "row_diff_quantized",
1034
+ },
1035
+ }
1036
+ for key, metadata in semantic_fields.items():
1037
+ if key in target["features"]:
1038
+ target["features"][key] = {
1039
+ **target["features"][key],
1040
+ **metadata,
1041
+ }
1042
  return target
1043
 
1044
 
 
1852
  return incoming
1853
  used = [int(row.get("episode_index")) for row in existing
1854
  if _episode_index(row.get("episode_index")) is not None]
1855
+ next_index = max(used, default=-1) + 1
1856
  prefix = str(project_name or project_root.name or "episode").strip()
1857
+ # Old projects may already contain a malformed pair such as
1858
+ # episode_index=0 / episode_id=Project_000001. Never reuse that ID for a
1859
+ # new upload; the storage index and the public ID are separate concerns.
1860
+ while f"{prefix}_{next_index:06d}" in existing_ids:
1861
+ next_index += 1
1862
  return f"{prefix}_{next_index:06d}"
1863
 
1864
 
processor/app/routes/ingestion.py CHANGED
@@ -642,6 +642,20 @@ def episode_media_groups(episode_id: str):
642
  """单个原始视频条目;手部骨骼由前端 SVG 覆盖层显示。"""
643
  cam = item["source_key"]
644
  stream = streams.get(cam) or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
  entry = {
646
  "source_key": cam,
647
  "role": item.get("role", "primary"),
@@ -654,6 +668,7 @@ def episode_media_groups(episode_id: str):
654
  # raw endpoint remains available for download/diagnostics.
655
  "stream_url": f"/api/v1/video/{episode_id}/{cam}/preview-stream",
656
  "raw_stream_url": f"/api/v1/video/{episode_id}/{cam}/stream",
 
657
  }
658
  return entry
659
 
@@ -666,6 +681,7 @@ def episode_media_groups(episode_id: str):
666
  "label": entry["label"],
667
  "stream_url": entry["stream_url"],
668
  "raw_stream_url": entry.get("raw_stream_url"),
 
669
  "frame_count": entry["frame_count"],
670
  "fps": entry["fps"],
671
  }
 
642
  """单个原始视频条目;手部骨骼由前端 SVG 覆盖层显示。"""
643
  cam = item["source_key"]
644
  stream = streams.get(cam) or {}
645
+ video_cache_key = None
646
+ try:
647
+ raw_path = Path(str(stream.get("path") or ""))
648
+ candidates = [raw_path] if raw_path.is_absolute() else [
649
+ batch_dir / raw_path,
650
+ batch_dir.parents[1] / raw_path,
651
+ ]
652
+ for video_path in candidates:
653
+ if video_path.is_file():
654
+ stat = video_path.stat()
655
+ video_cache_key = f"{stat.st_size:x}-{stat.st_mtime_ns:x}"
656
+ break
657
+ except OSError:
658
+ pass
659
  entry = {
660
  "source_key": cam,
661
  "role": item.get("role", "primary"),
 
668
  # raw endpoint remains available for download/diagnostics.
669
  "stream_url": f"/api/v1/video/{episode_id}/{cam}/preview-stream",
670
  "raw_stream_url": f"/api/v1/video/{episode_id}/{cam}/stream",
671
+ "video_cache_key": video_cache_key,
672
  }
673
  return entry
674
 
 
681
  "label": entry["label"],
682
  "stream_url": entry["stream_url"],
683
  "raw_stream_url": entry.get("raw_stream_url"),
684
+ "video_cache_key": entry.get("video_cache_key"),
685
  "frame_count": entry["frame_count"],
686
  "fps": entry["fps"],
687
  }
processor/app/routes/session.py CHANGED
@@ -267,6 +267,8 @@ async def _process_upload_job(
267
  # ---- 读任务名 ----
268
  meta_root = _find_meta_dir(extract_tmp) or extract_tmp
269
  info = _read_json(meta_root / "meta" / "info.json")
 
 
270
  fps = info.get("fps", 30)
271
  codebase_version = info.get("codebase_version", "v3.0")
272
 
@@ -310,12 +312,22 @@ async def _process_upload_job(
310
  list_projects, write_episode_state, list_upload_history,
311
  )
312
  requested_project_id = str(project_id or "").strip()
313
- project_name = (name or zip_info['prefix']).strip()
 
 
 
 
 
 
 
314
  # 项目可以先建好再搭工作流。只有 archived 项目不再接收新批次;
315
  # draft/active/paused 都保留数据归属,active 才决定是否自动派发。
316
  active_projects = [p for p in list_projects()
317
  if p.get("status", "active") != "archived"]
318
 
 
 
 
319
  def _project_aliases(project: dict) -> set[str]:
320
  aliases = {str(project.get("name") or "").strip()}
321
  raw_aliases = project.get("name_aliases") or []
@@ -364,10 +376,11 @@ async def _process_upload_job(
364
  # Legacy clients may still send only name. Keep compatibility, but
365
  # require a unique exact/prefix resolution when no project_id was
366
  # supplied; the selected project remains visible in the response.
367
- project_name_low = project_name.lower()
368
  exact_hits = [
369
  p for p in active_projects
370
- if project_name_low in {alias.lower() for alias in aliases_by_project[id(p)]}
 
 
371
  ]
372
  if len(exact_hits) == 1:
373
  matched_project = exact_hits[0]
@@ -380,7 +393,7 @@ async def _process_upload_job(
380
  else:
381
  prefix_hits = [
382
  p for p in active_projects
383
- if any(project_name_low.startswith(alias.lower())
384
  for alias in aliases_by_project[id(p)])
385
  ]
386
  # Only use a prefix fallback when exactly one project wins.
@@ -390,13 +403,13 @@ async def _process_upload_job(
390
  len(alias)
391
  for p in prefix_hits
392
  for alias in aliases_by_project[id(p)]
393
- if project_name_low.startswith(alias.lower())
394
  )
395
  longest_hits = [
396
  p for p in prefix_hits
397
  if any(
398
- project_name_low.startswith(alias.lower())
399
- and len(alias) == longest
400
  for alias in aliases_by_project[id(p)]
401
  )
402
  ]
@@ -408,7 +421,18 @@ async def _process_upload_job(
408
  detail=(f"Ambiguous project prefix '{project_name}'; "
409
  "provide project_id explicitly"),
410
  )
411
- project_folder = sanitize_task_name(matched_project["name"]) if matched_project else "Uncategorized"
 
 
 
 
 
 
 
 
 
 
 
412
  upload_history = list_upload_history()
413
  project_dir = settings.storage_root / "sessions" / project_folder
414
  from app.project_dataset import (
@@ -435,7 +459,10 @@ async def _process_upload_job(
435
  project_dir, incoming_name,
436
  matched_project["name"] if matched_project else project_folder,
437
  )
438
- batch_preexisted = batch_name in existing_ids
 
 
 
439
  is_reupload = batch_preexisted
440
  batch_dir = project_dir
441
  archive_size_bytes = archive_path.stat().st_size if archive_path.is_file() else 0
@@ -463,6 +490,13 @@ async def _process_upload_job(
463
  )
464
  normalize_extracted_dataset(staging_dir, batch_name)
465
  info = _read_json(staging_dir / "meta" / "info.json")
 
 
 
 
 
 
 
466
 
467
  # ---- 直接扫描规范化后的源视频,不把纯深度流当成 RGB 相机 ----
468
  video_records = []
@@ -492,7 +526,8 @@ async def _process_upload_job(
492
  new_state = {
493
  "id": batch_name,
494
  "name": batch_name,
495
- "project": matched_project["name"] if matched_project else "Uncategorized",
 
496
  "status": "to_review",
497
  "fps": fps,
498
  "frame_count": master_frame_count,
@@ -647,8 +682,8 @@ async def _process_upload_job(
647
  "session_name": zip_info['prefix'],
648
  "files_preserved": str(batch_dir),
649
  "imported": 1 if video_records else 0,
650
- "project_id": matched_project.get("id") if matched_project else None,
651
- "project_name": matched_project.get("name") if matched_project else None,
652
  "dispatch": dispatch_result,
653
  "episodes": [{
654
  "episode_id": batch_name,
@@ -1059,6 +1094,16 @@ def _read_json(path: Path) -> dict:
1059
  return {}
1060
 
1061
 
 
 
 
 
 
 
 
 
 
 
1062
  def _read_lines(path: Path) -> list[str]:
1063
  if path.exists():
1064
  with open(path) as f: return f.readlines()
 
267
  # ---- 读任务名 ----
268
  meta_root = _find_meta_dir(extract_tmp) or extract_tmp
269
  info = _read_json(meta_root / "meta" / "info.json")
270
+ if not isinstance(info, dict):
271
+ info = {}
272
  fps = info.get("fps", 30)
273
  codebase_version = info.get("codebase_version", "v3.0")
274
 
 
312
  list_projects, write_episode_state, list_upload_history,
313
  )
314
  requested_project_id = str(project_id or "").strip()
315
+ archive_project_name = str(
316
+ info.get("project_name") or info.get("task_name") or ""
317
+ ).strip()
318
+ archive_project_id = str(info.get("project_id") or "").strip()
319
+ # ``task_name`` is the project display name for collector archives.
320
+ # The upload form name remains a backwards-compatible fallback, while
321
+ # an explicit project_id still has highest priority below.
322
+ project_name = (archive_project_name or name or zip_info['prefix']).strip()
323
  # 项目可以先建好再搭工作流。只有 archived 项目不再接收新批次;
324
  # draft/active/paused 都保留数据归属,active 才决定是否自动派发。
325
  active_projects = [p for p in list_projects()
326
  if p.get("status", "active") != "archived"]
327
 
328
+ def _name_key(value: object) -> str:
329
+ return sanitize_task_name(str(value or "")).casefold()
330
+
331
  def _project_aliases(project: dict) -> set[str]:
332
  aliases = {str(project.get("name") or "").strip()}
333
  raw_aliases = project.get("name_aliases") or []
 
376
  # Legacy clients may still send only name. Keep compatibility, but
377
  # require a unique exact/prefix resolution when no project_id was
378
  # supplied; the selected project remains visible in the response.
 
379
  exact_hits = [
380
  p for p in active_projects
381
+ if (_name_key(project_name) in {
382
+ _name_key(alias) for alias in aliases_by_project[id(p)]
383
+ })
384
  ]
385
  if len(exact_hits) == 1:
386
  matched_project = exact_hits[0]
 
393
  else:
394
  prefix_hits = [
395
  p for p in active_projects
396
+ if any(_name_key(project_name).startswith(_name_key(alias))
397
  for alias in aliases_by_project[id(p)])
398
  ]
399
  # Only use a prefix fallback when exactly one project wins.
 
403
  len(alias)
404
  for p in prefix_hits
405
  for alias in aliases_by_project[id(p)]
406
+ if _name_key(project_name).startswith(_name_key(alias))
407
  )
408
  longest_hits = [
409
  p for p in prefix_hits
410
  if any(
411
+ _name_key(project_name).startswith(_name_key(alias))
412
+ and len(_name_key(alias)) == longest
413
  for alias in aliases_by_project[id(p)]
414
  )
415
  ]
 
421
  detail=(f"Ambiguous project prefix '{project_name}'; "
422
  "provide project_id explicitly"),
423
  )
424
+ resolved_project_name = str(
425
+ matched_project.get("name") if matched_project
426
+ else project_name or "Uncategorized"
427
+ ).strip() or "Uncategorized"
428
+ resolved_project_id = (
429
+ str(matched_project.get("id")) if matched_project and matched_project.get("id")
430
+ else archive_project_id or None
431
+ )
432
+ # A collector archive with a task/project name gets its own project
433
+ # root even when no server-side workflow/project record exists yet.
434
+ # This keeps ingestion isolated without creating a workflow.
435
+ project_folder = sanitize_task_name(resolved_project_name)
436
  upload_history = list_upload_history()
437
  project_dir = settings.storage_root / "sessions" / project_folder
438
  from app.project_dataset import (
 
459
  project_dir, incoming_name,
460
  matched_project["name"] if matched_project else project_folder,
461
  )
462
+ # Only an ID that came from an existing upload is a re-upload. Never
463
+ # infer replacement from the generated candidate ID; old datasets may
464
+ # contain mismatched IDs such as index=0 / id=..._000001.
465
+ batch_preexisted = incoming_name in existing_ids
466
  is_reupload = batch_preexisted
467
  batch_dir = project_dir
468
  archive_size_bytes = archive_path.stat().st_size if archive_path.is_file() else 0
 
490
  )
491
  normalize_extracted_dataset(staging_dir, batch_name)
492
  info = _read_json(staging_dir / "meta" / "info.json")
493
+ if not isinstance(info, dict):
494
+ info = {}
495
+ info["project_name"] = resolved_project_name
496
+ if resolved_project_id:
497
+ info["project_id"] = resolved_project_id
498
+ info["task_name"] = str(info.get("task_name") or resolved_project_name)
499
+ _write_json(staging_dir / "meta" / "info.json", info)
500
 
501
  # ---- 直接扫描规范化后的源视频,不把纯深度流当成 RGB 相机 ----
502
  video_records = []
 
526
  new_state = {
527
  "id": batch_name,
528
  "name": batch_name,
529
+ "project": resolved_project_name,
530
+ "project_id": resolved_project_id,
531
  "status": "to_review",
532
  "fps": fps,
533
  "frame_count": master_frame_count,
 
682
  "session_name": zip_info['prefix'],
683
  "files_preserved": str(batch_dir),
684
  "imported": 1 if video_records else 0,
685
+ "project_id": resolved_project_id,
686
+ "project_name": resolved_project_name,
687
  "dispatch": dispatch_result,
688
  "episodes": [{
689
  "episode_id": batch_name,
 
1094
  return {}
1095
 
1096
 
1097
+ def _write_json(path: Path, value: dict) -> None:
1098
+ path.parent.mkdir(parents=True, exist_ok=True)
1099
+ temporary = path.with_suffix(path.suffix + ".tmp")
1100
+ temporary.write_text(
1101
+ json.dumps(value, ensure_ascii=False, indent=2) + "\n",
1102
+ encoding="utf-8",
1103
+ )
1104
+ temporary.replace(path)
1105
+
1106
+
1107
  def _read_lines(path: Path) -> list[str]:
1108
  if path.exists():
1109
  with open(path) as f: return f.readlines()
processor/app/routes/video.py CHANGED
@@ -5,6 +5,8 @@ import re
5
  import asyncio
6
  import shutil
7
  import threading
 
 
8
  from pathlib import Path
9
  from uuid import UUID, uuid4
10
 
@@ -33,6 +35,28 @@ _DEPTH_READER_POOL_LOCK = threading.Lock()
33
  _DEPTH_READER_POOL_MAX = 8
34
  _DEPTH_FRAME_CACHE: dict[tuple[str, int], object] = {}
35
  _DEPTH_FRAME_CACHE_MAX = 48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  # Sequential decode is cheap, but decoding through more than this many
37
  # frames to reach a scrub target is not; jump via a seek reader instead.
38
  _DEPTH_WINDOW_SEEK_GAP = 6
@@ -225,6 +249,621 @@ def _canonical_episode_data_files(session_dir: Path, episode_id: str) -> list[Pa
225
  except (OSError, TypeError, ValueError):
226
  return []
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  async def _get_hand_keypoints_data(
229
  episode_id: str,
230
  frame_index: int,
@@ -770,7 +1409,9 @@ def get_hand_3d(
770
  if cacheable_meta:
771
  from app.media_cache import get_hand3d_meta
772
  cached = get_hand3d_meta(episode_id)
773
- if cached is not None and cached.get("meta_schema_version") == 2:
 
 
774
  return cached
775
 
776
  # 手部产物只从 canonical episode parquet 读取;渲染视频不再持久化。
@@ -857,7 +1498,7 @@ def get_hand_3d(
857
  # canonical parquet. Select the view with the most valid landmarks;
858
  # ties are stable and prefer the left camera. The selected view still
859
  # contains both physical hand slots (hand_0 and hand_1).
860
- columns = set(_pd.read_parquet(path, engine="pyarrow").columns)
861
  source_keys = _hand3d_source_keys(columns)
862
  source_meta = {
863
  key: _hand3d_meta(path, key) for key in source_keys
@@ -914,7 +1555,7 @@ def get_hand_3d(
914
 
915
  def _read_hand3d_frames():
916
  """Read whichever canonical or legacy 3D columns this episode has."""
917
- available = set(_pd.read_parquet(path, engine="pyarrow").columns)
918
  field_map = _hand3d_column_map(available, active_source)
919
  wanted = ["frame_index"]
920
  for hk, side in (("hand_0", "left"), ("hand_1", "right")):
@@ -1181,6 +1822,20 @@ def _hand3d_column_map(columns, source_key: str | None) -> dict[str, str]:
1181
  if not name.startswith("processing.hand_3d.")}
1182
 
1183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184
  def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1185
  """Describe canonical 3D data using the coordinate contract it stores.
1186
 
@@ -1201,20 +1856,27 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1201
  valid_landmark_points = 0
1202
  counted = False # 计数是否真实执行过(前端据此区分"全空"与"统计失败")
1203
  try:
1204
- columns = set(_pd.read_parquet(path, engine="pyarrow").columns)
 
 
 
 
 
 
 
 
 
 
 
 
1205
  field_map = _hand3d_column_map(columns, source_key)
1206
  source_columns = [name for name in field_map.values()
1207
  if name.endswith("_depth_source")]
1208
  if source_columns:
1209
- values = []
1210
- for name in source_columns:
1211
- values.extend(_pd.read_parquet(
1212
- path, columns=[name], engine="pyarrow")[name]
1213
- .dropna().astype(str).tolist())
1214
- is_rgb_estimated = any(
1215
- "rgb_estimate" in value.lower() or
1216
- "rgb_estimated" in value.lower()
1217
- for value in values)
1218
  else:
1219
  is_rgb_estimated = any(
1220
  name.endswith("hand_left_3d") or
@@ -1231,10 +1893,15 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1231
  # metadata for the review UI; it never changes the stored data.
1232
  # 采样统计:均匀散布读 1/3 的 row group(NAS 上全量读这些列
1233
  # 实测 ~20s),计数按采样率放大保持量级;采样失败回退全量读。
1234
- count_raw = [field_map.get(name) for name in (
1235
  "hand_0_present", "hand_1_present",
 
 
1236
  "hand_0_landmarks_3d", "hand_1_landmarks_3d",
1237
  ) if field_map.get(name)]
 
 
 
1238
 
1239
  def _count_rows(rows_iter) -> None:
1240
  nonlocal valid_hand_frames, valid_landmark_points
@@ -1246,6 +1913,10 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1246
  present = count_row.get(present_name) if present_name else True
1247
  points = count_row.get(points_name) if points_name else None
1248
  if present is False or points is None:
 
 
 
 
1249
  continue
1250
  try:
1251
  arr = _np.asarray(points, dtype=_np.float64)
@@ -1311,6 +1982,7 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1311
  "valid_hand_frames": int(valid_hand_frames),
1312
  "valid_landmark_points": int(valid_landmark_points),
1313
  "counted": bool(counted),
 
1314
  "meta_schema_version": 2,
1315
  }
1316
  def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
 
5
  import asyncio
6
  import shutil
7
  import threading
8
+ import math
9
+ from collections import OrderedDict
10
  from pathlib import Path
11
  from uuid import UUID, uuid4
12
 
 
35
  _DEPTH_READER_POOL_MAX = 8
36
  _DEPTH_FRAME_CACHE: dict[tuple[str, int], object] = {}
37
  _DEPTH_FRAME_CACHE_MAX = 48
38
+ # Canonical SLAM rows are shared by the trajectory and frame-window requests.
39
+ # The cache is keyed by file size/mtime so a workflow reprocess cannot return
40
+ # a trajectory from an older parquet.
41
+ _SLAM_DATA_CACHE: dict[str, tuple[str, list[dict]]] = {}
42
+ _SLAM_DATA_CACHE_MAX = 8
43
+ # UMI tactile matrices are stored as three flattened int16 planes per frame
44
+ # (250*250*3). Keep only encoded preview images here; never retain a whole
45
+ # episode of matrices in the API process.
46
+ _TACTILE_PREVIEW_CACHE: OrderedDict[tuple, bytes] = OrderedDict()
47
+ _TACTILE_PREVIEW_CACHE_MAX = 48
48
+ _TACTILE_FIELD_CACHE: OrderedDict[tuple, tuple[bytes, float, float]] = OrderedDict()
49
+ _TACTILE_FIELD_CACHE_MAX = 96
50
+ _TACTILE_PREVIEW_LOCK = threading.Lock()
51
+ _TACTILE_SHAPE = (250, 250)
52
+ _TACTILE_COLUMNS = {
53
+ "left": "observation.gripper_left_force_matrix",
54
+ "right": "observation.gripper_right_force_matrix",
55
+ }
56
+ _TACTILE_FORCE_COLUMNS = {
57
+ "left": "observation.gripper_left_force",
58
+ "right": "observation.gripper_right_force",
59
+ }
60
  # Sequential decode is cheap, but decoding through more than this many
61
  # frames to reach a scrub target is not; jump via a seek reader instead.
62
  _DEPTH_WINDOW_SEEK_GAP = 6
 
249
  except (OSError, TypeError, ValueError):
250
  return []
251
 
252
+
253
+ def _read_gripper_state(value) -> list[float] | None:
254
+ """Read the legacy UMI state vector without changing its raw values.
255
+
256
+ The acquisition contract used by the current gripper writer is
257
+ ``[percent, gripped, raw]``. Keep the complete vector in the response so
258
+ future renderers can use the encoder value without recomputing it.
259
+ """
260
+ if value is None:
261
+ return None
262
+ try:
263
+ values = list(value)
264
+ except TypeError:
265
+ return None
266
+ result: list[float] = []
267
+ for item in values[:3]:
268
+ try:
269
+ number = float(item)
270
+ except (TypeError, ValueError, OverflowError):
271
+ return None
272
+ if not math.isfinite(number):
273
+ return None
274
+ result.append(number)
275
+ return result or None
276
+
277
+
278
+ def _read_slam_rows(path: Path) -> tuple[str, list[dict]]:
279
+ """Read canonical ``observation.slam_pose`` rows once per parquet revision."""
280
+ import numpy as np
281
+ import pandas as pd
282
+
283
+ path = Path(path)
284
+ try:
285
+ stat = path.stat()
286
+ revision = f"{int(stat.st_size):x}-{int(stat.st_mtime_ns):x}"
287
+ except OSError:
288
+ return "unknown", []
289
+ cache_key = str(path.resolve())
290
+ cached = _SLAM_DATA_CACHE.get(cache_key)
291
+ if cached is not None and cached[0] == revision:
292
+ return cached
293
+ columns = ["frame_index", "timestamp", "observation.slam_pose"]
294
+ has_gripper_state = False
295
+ try:
296
+ import pyarrow.parquet as pq
297
+ available_columns = set(pq.ParquetFile(path).schema_arrow.names)
298
+ has_gripper_state = "observation.gripper_state" in available_columns
299
+ except Exception:
300
+ pass
301
+ if has_gripper_state:
302
+ columns.append("observation.gripper_state")
303
+ try:
304
+ df = pd.read_parquet(path, columns=columns, engine="pyarrow")
305
+ except Exception:
306
+ return revision, []
307
+
308
+ rows: list[dict] = []
309
+ for _, row in df.iterrows():
310
+ try:
311
+ frame = int(row.get("frame_index", len(rows)))
312
+ except (TypeError, ValueError):
313
+ continue
314
+ timestamp = row.get("timestamp")
315
+ try:
316
+ timestamp = float(timestamp) if np.isfinite(float(timestamp)) else None
317
+ except (TypeError, ValueError):
318
+ timestamp = None
319
+ pose = row.get("observation.slam_pose")
320
+ try:
321
+ values = np.asarray(pose, dtype=np.float64).reshape(-1)
322
+ except (TypeError, ValueError):
323
+ values = np.empty(0, dtype=np.float64)
324
+
325
+ # A missing native SLAM sample is represented by an all-zero pose in
326
+ # some recordings. It is not the same thing as a valid pose at the
327
+ # origin: a valid pose always has a unit quaternion. Keep only valid
328
+ # poses and remember the gap so the renderer can break the polyline.
329
+ valid = values.size >= 7 and np.isfinite(values[:7]).all()
330
+ if valid:
331
+ quaternion_norm = float(np.linalg.norm(values[3:7]))
332
+ valid = 0.5 <= quaternion_norm <= 1.5
333
+ if not valid:
334
+ continue
335
+ rows.append({
336
+ "f": frame,
337
+ "p": [float(value) for value in values[:7]],
338
+ "t": timestamp,
339
+ "g": _read_gripper_state(row.get("observation.gripper_state"))
340
+ if has_gripper_state else None,
341
+ })
342
+ rows.sort(key=lambda item: item["f"])
343
+
344
+ # Decimation can legitimately make frame numbers non-consecutive, so the
345
+ # frontend must not infer a gap from every frame jump. Mark only gaps
346
+ # caused by missing/invalid source rows; sampled long trajectories remain
347
+ # a continuous polyline.
348
+ previous_frame = None
349
+ for item in rows:
350
+ frame = int(item["f"])
351
+ item["gap_before"] = (
352
+ previous_frame is not None and frame != previous_frame + 1
353
+ )
354
+ previous_frame = frame
355
+ result = (revision, rows)
356
+ _SLAM_DATA_CACHE[cache_key] = result
357
+ while len(_SLAM_DATA_CACHE) > _SLAM_DATA_CACHE_MAX:
358
+ _SLAM_DATA_CACHE.pop(next(iter(_SLAM_DATA_CACHE)))
359
+ return result
360
+
361
+
362
+ def _decimate_slam_rows(rows: list[dict], max_points: int) -> list[dict]:
363
+ if max_points <= 0 or len(rows) <= max_points:
364
+ return rows
365
+ if max_points == 1:
366
+ return [rows[0]]
367
+ indexes = {
368
+ round(index * (len(rows) - 1) / (max_points - 1))
369
+ for index in range(max_points)
370
+ }
371
+ return [rows[index] for index in sorted(indexes)]
372
+
373
+
374
+ def _tactile_episode_file(episode_id: str) -> Path | None:
375
+ ep = get_episode(episode_id)
376
+ if ep is None:
377
+ return None
378
+ files = _canonical_episode_data_files(Path(ep["path"]), episode_id)
379
+ return files[0] if files else None
380
+
381
+
382
+ def _tactile_matrix_columns(path: Path) -> set[str]:
383
+ try:
384
+ import pyarrow.parquet as pq
385
+ return set(pq.ParquetFile(path).schema_arrow.names)
386
+ except Exception:
387
+ return set()
388
+
389
+
390
+ def _read_tactile_force_series(path: Path) -> dict | None:
391
+ """Read the small scalar-force series used by the matrix value badges."""
392
+ import numpy as np
393
+ import pyarrow.parquet as pq
394
+
395
+ try:
396
+ available = set(pq.ParquetFile(path).schema_arrow.names)
397
+ selected = [
398
+ column for column in _TACTILE_FORCE_COLUMNS.values()
399
+ if column in available
400
+ ]
401
+ if not selected:
402
+ return None
403
+ table = pq.read_table(path, columns=["frame_index", *selected])
404
+ frames = [int(value) for value in table.column("frame_index").to_pylist()]
405
+ result = {"frames": frames, "left": [], "right": []}
406
+ for side, column in _TACTILE_FORCE_COLUMNS.items():
407
+ if column not in selected:
408
+ result[side] = [None] * len(frames)
409
+ continue
410
+ values = table.column(column).to_pylist()
411
+ normalized = []
412
+ for value in values:
413
+ try:
414
+ vector = np.asarray(value, dtype=np.float64).reshape(-1)
415
+ if vector.size < 3 or not np.isfinite(vector[:3]).all():
416
+ normalized.append(None)
417
+ else:
418
+ normalized.append([float(item) for item in vector[:3]])
419
+ except (TypeError, ValueError, OverflowError):
420
+ normalized.append(None)
421
+ result[side] = normalized
422
+ return result
423
+ except Exception:
424
+ return None
425
+
426
+
427
+ def _decode_tactile_matrix(value):
428
+ """Decode one canonical 250x250x3 row-diff force matrix to mN.
429
+
430
+ The recorder difference-encodes each sensor row after interleaving the
431
+ three components: ``[fx0, fy0, fz0, fx1, fy1, fz1, ...]``. Therefore the
432
+ prefix sum must run over the full 750-value row before reshaping back to
433
+ HWC. Splitting H/W/C first and accumulating each channel independently
434
+ produces a different matrix and was the cause of the incorrect preview.
435
+ The int16 cast is intentional: the recorder's contract is modulo-2^16 and
436
+ the final values are integer mN.
437
+ """
438
+ import numpy as np
439
+
440
+ if value is None:
441
+ return None
442
+ try:
443
+ if isinstance(value, (bytes, bytearray, memoryview)):
444
+ raw = np.frombuffer(value, dtype="<i2")
445
+ else:
446
+ raw = np.asarray(value, dtype=np.int16).reshape(-1)
447
+ except (TypeError, ValueError):
448
+ return None
449
+ if raw.size == 0:
450
+ return None
451
+ pixels = _TACTILE_SHAPE[0] * _TACTILE_SHAPE[1]
452
+ if raw.size == pixels * 3:
453
+ # Canonical data: row-diff over H x (W*3), then H x W x [fx,fy,fz].
454
+ flat_rows = raw.reshape(_TACTILE_SHAPE[0], _TACTILE_SHAPE[1] * 3)
455
+ decoded_hwc = np.cumsum(flat_rows, axis=1, dtype=np.int16)
456
+ decoded = decoded_hwc.reshape(*_TACTILE_SHAPE, 3).transpose(2, 0, 1)
457
+ elif raw.size == pixels:
458
+ # Legacy single-channel field: row-diff over H x W.
459
+ rows = raw.reshape(*_TACTILE_SHAPE)
460
+ decoded = np.cumsum(rows, axis=1, dtype=np.int16)[None, ...]
461
+ else:
462
+ return None
463
+ decoded = decoded.astype(np.float32)
464
+ if decoded.shape[0] == 1:
465
+ decoded = decoded.repeat(3, axis=0)
466
+ return decoded
467
+
468
+
469
+ def _read_tactile_frame(path: Path, frame_index: int, side: str):
470
+ """Read only the requested parquet row and side.
471
+
472
+ PyArrow applies the frame_index predicate before materialising the list
473
+ column. This keeps normal scrubbing bounded to one matrix row instead of
474
+ loading both force streams into the browser.
475
+ """
476
+ import numpy as np
477
+ import pyarrow as pa
478
+ import pyarrow.parquet as pq
479
+
480
+ column = _TACTILE_COLUMNS.get(side)
481
+ if column is None:
482
+ return None
483
+ try:
484
+ table = pq.read_table(
485
+ path,
486
+ columns=["frame_index", column],
487
+ filters=[("frame_index", "=", int(frame_index))],
488
+ )
489
+ if table.num_rows == 0:
490
+ return None
491
+ frames = table.column("frame_index").to_pylist()
492
+ column_data = table.column(column)
493
+ if isinstance(column_data, pa.ChunkedArray):
494
+ column_data = column_data.combine_chunks()
495
+ offsets = np.asarray(column_data.offsets, dtype=np.int64)
496
+ values = np.asarray(column_data.values, dtype=np.int16)
497
+ for index, frame in enumerate(frames):
498
+ if int(frame) == int(frame_index):
499
+ decoded = _decode_tactile_matrix(
500
+ values[offsets[index]:offsets[index + 1]])
501
+ # Some writers emit an empty vector for a valid first frame
502
+ # before the tactile device publishes its first sample. Keep
503
+ # that frame drawable as an all-zero matrix rather than
504
+ # making the UI show a broken image.
505
+ if decoded is None:
506
+ return np.zeros((3, *_TACTILE_SHAPE), dtype=np.float32)
507
+ return decoded
508
+ except Exception:
509
+ return None
510
+ return None
511
+
512
+
513
+ def _tactile_force_field(
514
+ path: Path, frame_index: int, side: str,
515
+ ) -> tuple[bytes, float, float] | None:
516
+ """Return one smoothed fz pressure field as float32 bytes.
517
+
518
+ The payload is numeric data, not a rendered image. The frontend applies
519
+ the color map in WebGL2. ``lo``/``hi`` preserve the acquisition-side
520
+ per-frame contrast normalization without sending a rendered image.
521
+ """
522
+ import cv2
523
+ import numpy as np
524
+
525
+ try:
526
+ stat = path.stat()
527
+ revision = (int(stat.st_size), int(stat.st_mtime_ns))
528
+ except OSError:
529
+ revision = (0, 0)
530
+ if side not in _TACTILE_COLUMNS:
531
+ return None
532
+ cache_key = (str(path.resolve()), revision, int(frame_index), side)
533
+ with _TACTILE_PREVIEW_LOCK:
534
+ cached = _TACTILE_FIELD_CACHE.get(cache_key)
535
+ if cached is not None:
536
+ _TACTILE_FIELD_CACHE.move_to_end(cache_key)
537
+ return cached
538
+
539
+ matrix = _read_tactile_frame(path, frame_index, side)
540
+ if matrix is None:
541
+ return None
542
+ # Match the acquisition-side heatmap exactly: the color field is the
543
+ # calibrated normal-pressure channel fz. fx/fy remain available in the
544
+ # numeric force badge and are intentionally not mixed into this primary
545
+ # pressure visualization.
546
+ pressure = np.maximum(matrix[2], 0.0)
547
+ pressure = cv2.GaussianBlur(pressure, (3, 3), 0.8)
548
+ valid = pressure[pressure > 0.01]
549
+ lo = 0.0
550
+ hi = 1.0
551
+ if valid.size > 50:
552
+ lo = float(np.percentile(valid, 10))
553
+ hi = max(float(np.percentile(valid, 98)) * 1.3, lo + 0.5)
554
+ field = np.ascontiguousarray(pressure, dtype="<f4")
555
+ result = (field.tobytes(), lo, hi)
556
+ with _TACTILE_PREVIEW_LOCK:
557
+ _TACTILE_FIELD_CACHE[cache_key] = result
558
+ _TACTILE_FIELD_CACHE.move_to_end(cache_key)
559
+ while len(_TACTILE_FIELD_CACHE) > _TACTILE_FIELD_CACHE_MAX:
560
+ _TACTILE_FIELD_CACHE.popitem(last=False)
561
+ return result
562
+
563
+
564
+ _TACTILE_RANGE_CACHE: OrderedDict[tuple, bytes] = OrderedDict()
565
+ _TACTILE_RANGE_CACHE_MAX = 24
566
+
567
+
568
+ def _tactile_range_payload(path: Path, first: int, last: int,
569
+ side: str) -> bytes | None:
570
+ """Encode a run of consecutive fz fields as R8 plus per-frame min/max.
571
+
572
+ Body layout: ``frames * 250 * 250`` bytes of R8 (one byte per cell, row
573
+ major) followed by ``frames * 2`` little-endian float32 values holding
574
+ each frame's ``lo``/``hi`` normalisation pair. One request per window
575
+ replaces one 250 KB request per frame, which is what saturated the
576
+ browser's socket buffers (net::ERR_NO_BUFFER_SPACE) during playback.
577
+ """
578
+ import cv2
579
+ import numpy as np
580
+ import pyarrow.parquet as pq
581
+
582
+ column = _TACTILE_COLUMNS.get(side)
583
+ if column is None or last <= first:
584
+ return None
585
+ try:
586
+ stat = path.stat()
587
+ revision = f"{int(stat.st_size):x}-{int(stat.st_mtime_ns):x}"
588
+ except OSError:
589
+ revision = "0-0"
590
+ cache_key = (str(path.resolve()), revision, int(first), int(last), side)
591
+ with _TACTILE_PREVIEW_LOCK:
592
+ cached = _TACTILE_RANGE_CACHE.get(cache_key)
593
+ if cached is not None:
594
+ _TACTILE_RANGE_CACHE.move_to_end(cache_key)
595
+ return cached
596
+ try:
597
+ table = pq.read_table(
598
+ path,
599
+ columns=["frame_index", column],
600
+ filters=[("frame_index", ">=", int(first)),
601
+ ("frame_index", "<", int(last))],
602
+ )
603
+ except Exception:
604
+ return None
605
+ frames = [int(value) for value in table.column("frame_index").to_pylist()]
606
+ # Read the list column straight out of the arrow buffers: as_py() would
607
+ # materialise 187500 Python ints per frame (~100 ms each).
608
+ import pyarrow as pa
609
+ column_data = table.column(column)
610
+ if isinstance(column_data, pa.ChunkedArray):
611
+ column_data = column_data.combine_chunks()
612
+ offsets = np.asarray(column_data.offsets, dtype=np.int64)
613
+ values = np.asarray(column_data.values, dtype=np.int16)
614
+ rows = {frame: values[offsets[index]:offsets[index + 1]]
615
+ for index, frame in enumerate(frames)}
616
+ pixels = _TACTILE_SHAPE[0] * _TACTILE_SHAPE[1]
617
+ blocks: list[bytes] = []
618
+ pairs: list[float] = []
619
+ for frame in range(int(first), int(last)):
620
+ matrix = _decode_tactile_matrix(rows.get(frame))
621
+ if matrix is None:
622
+ blocks.append(bytes(pixels))
623
+ pairs.extend((0.0, 1.0))
624
+ continue
625
+ pressure = np.maximum(matrix[2], 0.0)
626
+ pressure = cv2.GaussianBlur(pressure, (3, 3), 0.8)
627
+ valid = pressure[pressure > 0.01]
628
+ lo = 0.0
629
+ hi = 1.0
630
+ if valid.size > 50:
631
+ lo = float(np.percentile(valid, 10))
632
+ hi = max(float(np.percentile(valid, 98)) * 1.3, lo + 0.5)
633
+ scaled = np.clip((pressure - lo) / (hi - lo + 1e-6), 0.0, 1.0)
634
+ blocks.append(np.rint(scaled * 255.0).astype(np.uint8).tobytes())
635
+ pairs.extend((lo, hi))
636
+ payload = b"".join(blocks) + np.asarray(pairs, dtype="<f4").tobytes()
637
+ with _TACTILE_PREVIEW_LOCK:
638
+ _TACTILE_RANGE_CACHE[cache_key] = payload
639
+ _TACTILE_RANGE_CACHE.move_to_end(cache_key)
640
+ while len(_TACTILE_RANGE_CACHE) > _TACTILE_RANGE_CACHE_MAX:
641
+ _TACTILE_RANGE_CACHE.popitem(last=False)
642
+ return payload
643
+
644
+
645
+ def _tactile_preview_png(path: Path, frame_index: int, side: str) -> bytes | None:
646
+ """Render the numeric force field as a legacy WebP preview."""
647
+ import cv2
648
+ import numpy as np
649
+
650
+ try:
651
+ stat = path.stat()
652
+ revision = (int(stat.st_size), int(stat.st_mtime_ns))
653
+ except OSError:
654
+ revision = (0, 0)
655
+ cache_key = (str(path.resolve()), revision, int(frame_index), side)
656
+ with _TACTILE_PREVIEW_LOCK:
657
+ cached = _TACTILE_PREVIEW_CACHE.get(cache_key)
658
+ if cached is not None:
659
+ _TACTILE_PREVIEW_CACHE.move_to_end(cache_key)
660
+ return cached
661
+ field_data = _tactile_force_field(path, frame_index, side)
662
+ if field_data is None:
663
+ return None
664
+ raw, lo, hi = field_data
665
+ force_magnitude = np.frombuffer(raw, dtype="<f4").reshape(_TACTILE_SHAPE)
666
+ force_magnitude = np.clip(
667
+ (force_magnitude - lo) / (hi - lo + 1e-6), 0.0, 1.0
668
+ )
669
+ gray = np.uint8(np.round(force_magnitude * 255.0))
670
+ # Keep the complete 250x250 field visible as a real heatmap. JET's
671
+ # lowest value is the cold blue end of the scale, so zero-pressure cells
672
+ # remain visible instead of turning the preview into a mostly-black image
673
+ # with a thin coloured line. This changes only the visualization; the
674
+ # decoded force matrix and its zero cells are not modified.
675
+ colored = cv2.applyColorMap(gray, cv2.COLORMAP_JET)
676
+ _, encoded = cv2.imencode(".webp", colored,
677
+ [cv2.IMWRITE_WEBP_QUALITY, 90])
678
+ result = encoded.tobytes()
679
+ with _TACTILE_PREVIEW_LOCK:
680
+ _TACTILE_PREVIEW_CACHE[cache_key] = result
681
+ _TACTILE_PREVIEW_CACHE.move_to_end(cache_key)
682
+ while len(_TACTILE_PREVIEW_CACHE) > _TACTILE_PREVIEW_CACHE_MAX:
683
+ _TACTILE_PREVIEW_CACHE.popitem(last=False)
684
+ return result
685
+
686
+
687
+ @router.get("/{episode_id}/tactile-matrix-data/{frame_index}")
688
+ def get_tactile_matrix_data(
689
+ episode_id: str,
690
+ frame_index: int,
691
+ side: str = Query("left", pattern="^(left|right)$"),
692
+ ):
693
+ """Return one numeric all-component force field for WebGL2 rendering."""
694
+ if frame_index < 0:
695
+ raise HTTPException(status_code=400, detail="frame_index must be non-negative")
696
+ path = _tactile_episode_file(episode_id)
697
+ if path is None or not _tactile_matrix_columns(path).intersection(_TACTILE_COLUMNS.values()):
698
+ raise HTTPException(status_code=404, detail="force matrices are unavailable")
699
+ field = _tactile_force_field(path, frame_index, side)
700
+ if field is None:
701
+ raise HTTPException(status_code=404, detail=f"no force matrix at frame {frame_index}")
702
+ raw, lo, hi = field
703
+ return Response(
704
+ content=raw,
705
+ media_type="application/octet-stream",
706
+ headers={
707
+ "Cache-Control": "public, max-age=31536000, immutable",
708
+ "X-Force-Width": str(_TACTILE_SHAPE[1]),
709
+ "X-Force-Height": str(_TACTILE_SHAPE[0]),
710
+ "X-Force-Dtype": "float32-le",
711
+ "X-Force-Min": repr(lo),
712
+ "X-Force-Max": repr(hi),
713
+ },
714
+ )
715
+
716
+
717
+ @router.get("/{episode_id}/tactile-matrix-meta")
718
+ def get_tactile_matrix_meta(episode_id: str):
719
+ path = _tactile_episode_file(episode_id)
720
+ columns = _tactile_matrix_columns(path) if path else set()
721
+ sides = [side for side, column in _TACTILE_COLUMNS.items() if column in columns]
722
+ ep = get_episode(episode_id)
723
+ try:
724
+ stat = path.stat() if path else None
725
+ revision = f"{int(stat.st_size):x}-{int(stat.st_mtime_ns):x}" if stat else None
726
+ except OSError:
727
+ revision = None
728
+ force_series = _read_tactile_force_series(path) if path else None
729
+ return {
730
+ "available": bool(sides),
731
+ "episode_id": episode_id,
732
+ "sides": sides,
733
+ "shape": [250, 250, 3],
734
+ "units": ["mN", "mN", "mN"],
735
+ "channel_order": ["fx", "fy", "fz"],
736
+ "layout": "HWC",
737
+ "encoding": "row_diff_quantized",
738
+ "force_order": ["fx", "fy", "fz"],
739
+ "force_units": "mN",
740
+ "force_series": force_series,
741
+ "artifact_revision": revision,
742
+ "frame_count": int(ep.get("frame_count") or 0) if ep else 0,
743
+ "fps": float(ep.get("fps") or 30) if ep else 30.0,
744
+ }
745
+
746
+
747
+ @router.get("/{episode_id}/tactile-matrix-range")
748
+ def get_tactile_matrix_range(
749
+ episode_id: str,
750
+ side: str = Query("left", pattern="^(left|right)$"),
751
+ start: int = Query(0, ge=0),
752
+ end: int = Query(0, ge=0),
753
+ limit: int = Query(96, ge=1, le=256),
754
+ ):
755
+ """Return a run of consecutive fz fields for one side (R8 + min/max)."""
756
+ path = _tactile_episode_file(episode_id)
757
+ if path is None or not _tactile_matrix_columns(path).intersection(
758
+ _TACTILE_COLUMNS.values()):
759
+ raise HTTPException(status_code=404, detail="force matrices are unavailable")
760
+ ep = get_episode(episode_id)
761
+ total = int(ep.get("frame_count") or 0) if ep else 0
762
+ first = max(0, int(start))
763
+ last = int(end) if int(end) > first else first + int(limit)
764
+ last = min(last, first + int(limit))
765
+ if total:
766
+ first = min(first, max(0, total - 1))
767
+ last = min(last, total)
768
+ if last <= first:
769
+ raise HTTPException(status_code=404, detail="empty range")
770
+ payload = _tactile_range_payload(path, first, last, side)
771
+ if payload is None:
772
+ raise HTTPException(status_code=404, detail="no force matrices in range")
773
+ return Response(
774
+ content=payload,
775
+ media_type="application/octet-stream",
776
+ headers={
777
+ "Cache-Control": "public, max-age=31536000, immutable",
778
+ "X-Force-First": str(first),
779
+ "X-Force-Frames": str(last - first),
780
+ "X-Force-Width": str(_TACTILE_SHAPE[1]),
781
+ "X-Force-Height": str(_TACTILE_SHAPE[0]),
782
+ "X-Force-Encoding": "r8+minmax",
783
+ },
784
+ )
785
+
786
+
787
+ @router.get("/{episode_id}/tactile-matrix/{frame_index}")
788
+ def get_tactile_matrix_preview(
789
+ episode_id: str,
790
+ frame_index: int,
791
+ side: str = Query("left", pattern="^(left|right)$"),
792
+ ):
793
+ if frame_index < 0:
794
+ raise HTTPException(status_code=400, detail="frame_index must be non-negative")
795
+ path = _tactile_episode_file(episode_id)
796
+ if path is None or not _tactile_matrix_columns(path).intersection(_TACTILE_COLUMNS.values()):
797
+ raise HTTPException(status_code=404, detail="force matrices are unavailable")
798
+ image = _tactile_preview_png(path, frame_index, side)
799
+ if image is None:
800
+ raise HTTPException(status_code=404, detail=f"no force matrix at frame {frame_index}")
801
+ return Response(
802
+ content=image,
803
+ media_type="image/webp",
804
+ headers={"Cache-Control": "public, max-age=31536000, immutable"},
805
+ )
806
+
807
+
808
+ @router.get("/{episode_id}/slam-trajectory")
809
+ def get_slam_trajectory(
810
+ episode_id: str,
811
+ start_frame: int | None = Query(None, ge=0),
812
+ end_frame: int | None = Query(None, ge=0),
813
+ max_points: int = Query(2400, ge=1, le=10000),
814
+ ):
815
+ """Return synchronized SLAM poses and a display-ready 3D trajectory.
816
+
817
+ Without a range, long episodes return a decimated trajectory. With a
818
+ range, every valid pose in that frame window is returned so the browser
819
+ can place the current marker exactly without one request per frame.
820
+ Pose order is ``x,y,z,qx,qy,qz,qw`` and coordinates are metres when the
821
+ collector follows the UMI SLAM contract.
822
+ """
823
+ ep = get_episode(episode_id)
824
+ if ep is None:
825
+ return {"available": False, "episode_id": episode_id, "frames": [], "trajectory": []}
826
+ sdir = Path(ep["path"])
827
+ files = _canonical_episode_data_files(sdir, episode_id)
828
+ if not files:
829
+ return {"available": False, "episode_id": episode_id, "frames": [], "trajectory": []}
830
+ revision, rows = _read_slam_rows(files[0])
831
+ frame_count = int(ep.get("frame_count") or 0)
832
+ if rows:
833
+ frame_count = max(frame_count, int(rows[-1]["f"]) + 1)
834
+
835
+ ranged = start_frame is not None or end_frame is not None
836
+ if ranged:
837
+ start = max(0, int(start_frame or 0))
838
+ end = max(start, int(end_frame if end_frame is not None else start))
839
+ selected = [row for row in rows if start <= row["f"] <= end]
840
+ selected = _decimate_slam_rows(selected, max_points)
841
+ trajectory = selected
842
+ else:
843
+ start = 0
844
+ end = max(0, frame_count - 1)
845
+ trajectory = _decimate_slam_rows(rows, max_points)
846
+ # A short episode is returned in full, while long episodes use the
847
+ # same bounded sample for the initial current-frame lookup.
848
+ selected = trajectory
849
+ return {
850
+ "available": bool(rows),
851
+ "episode_id": episode_id,
852
+ "frame_count": frame_count,
853
+ "fps": float(ep.get("fps") or 30),
854
+ "unit": "m",
855
+ "pose_order": ["x", "y", "z", "qx", "qy", "qz", "qw"],
856
+ "gripper_state_order": ["percent", "gripped", "raw"] if any(
857
+ row.get("g") is not None for row in rows) else None,
858
+ "coordinate_frame": "slam_world",
859
+ "artifact_revision": revision,
860
+ "valid_frames": len(rows),
861
+ "start": start,
862
+ "end": end,
863
+ "frames": selected,
864
+ "trajectory": trajectory,
865
+ }
866
+
867
  async def _get_hand_keypoints_data(
868
  episode_id: str,
869
  frame_index: int,
 
1409
  if cacheable_meta:
1410
  from app.media_cache import get_hand3d_meta
1411
  cached = get_hand3d_meta(episode_id)
1412
+ if (cached is not None
1413
+ and cached.get("meta_schema_version") == 2
1414
+ and cached.get("artifact_revision")):
1415
  return cached
1416
 
1417
  # 手部产物只从 canonical episode parquet 读取;渲染视频不再持久化。
 
1498
  # canonical parquet. Select the view with the most valid landmarks;
1499
  # ties are stable and prefer the left camera. The selected view still
1500
  # contains both physical hand slots (hand_0 and hand_1).
1501
+ columns = _hand3d_parquet_columns(path)
1502
  source_keys = _hand3d_source_keys(columns)
1503
  source_meta = {
1504
  key: _hand3d_meta(path, key) for key in source_keys
 
1555
 
1556
  def _read_hand3d_frames():
1557
  """Read whichever canonical or legacy 3D columns this episode has."""
1558
+ available = _hand3d_parquet_columns(path)
1559
  field_map = _hand3d_column_map(available, active_source)
1560
  wanted = ["frame_index"]
1561
  for hk, side in (("hand_0", "left"), ("hand_1", "right")):
 
1822
  if not name.startswith("processing.hand_3d.")}
1823
 
1824
 
1825
+ def _hand3d_parquet_columns(path: Path) -> set[str]:
1826
+ """Read parquet column names without materializing any row data."""
1827
+ try:
1828
+ import pyarrow.parquet as _pq
1829
+ # ``schema.names`` exposes physical leaf names for nested list columns
1830
+ # (often just ``element``), which hides hand_N_landmarks_3d. Use the
1831
+ # logical Arrow schema so the frame reader sees the real top-level
1832
+ # parquet fields.
1833
+ return set(_pq.ParquetFile(path).schema_arrow.names)
1834
+ except Exception:
1835
+ import pandas as _pd
1836
+ return set(_pd.read_parquet(path, columns=[]).columns)
1837
+
1838
+
1839
  def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1840
  """Describe canonical 3D data using the coordinate contract it stores.
1841
 
 
1856
  valid_landmark_points = 0
1857
  counted = False # 计数是否真实执行过(前端据此区分"全空"与"统计失败")
1858
  try:
1859
+ # The browser must be able to distinguish two workflow outputs that
1860
+ # happen to have the same episode id/frame count. Size + mtime is a
1861
+ # cheap revision for the canonical parquet and is stable for the
1862
+ # lifetime of one published artifact.
1863
+ artifact_stat = path.stat()
1864
+ artifact_revision = (
1865
+ f"{int(artifact_stat.st_size):x}-"
1866
+ f"{int(artifact_stat.st_mtime_ns):x}"
1867
+ )
1868
+ except OSError:
1869
+ artifact_revision = "unknown"
1870
+ try:
1871
+ columns = _hand3d_parquet_columns(path)
1872
  field_map = _hand3d_column_map(columns, source_key)
1873
  source_columns = [name for name in field_map.values()
1874
  if name.endswith("_depth_source")]
1875
  if source_columns:
1876
+ # New metric depth artifacts always carry *_depth_source. Avoid
1877
+ # decoding that entire object column during the click-time meta
1878
+ # request; the source namespace is enough to classify the preview.
1879
+ is_rgb_estimated = False
 
 
 
 
 
1880
  else:
1881
  is_rgb_estimated = any(
1882
  name.endswith("hand_left_3d") or
 
1893
  # metadata for the review UI; it never changes the stored data.
1894
  # 采样统计:均匀散布读 1/3 的 row group(NAS 上全量读这些列
1895
  # 实测 ~20s),计数按采样率放大保持量级;采样失败回退全量读。
1896
+ present_raw = [field_map.get(name) for name in (
1897
  "hand_0_present", "hand_1_present",
1898
+ ) if field_map.get(name)]
1899
+ landmark_raw = [field_map.get(name) for name in (
1900
  "hand_0_landmarks_3d", "hand_1_landmarks_3d",
1901
  ) if field_map.get(name)]
1902
+ # Presence booleans are enough for source selection and are much
1903
+ # cheaper than decoding nested 21x3 landmark arrays over SSHFS.
1904
+ count_raw = present_raw or landmark_raw
1905
 
1906
  def _count_rows(rows_iter) -> None:
1907
  nonlocal valid_hand_frames, valid_landmark_points
 
1913
  present = count_row.get(present_name) if present_name else True
1914
  points = count_row.get(points_name) if points_name else None
1915
  if present is False or points is None:
1916
+ if (points is None and present is not None
1917
+ and bool(present)):
1918
+ valid_landmark_points += 21
1919
+ row_has_hand = True
1920
  continue
1921
  try:
1922
  arr = _np.asarray(points, dtype=_np.float64)
 
1982
  "valid_hand_frames": int(valid_hand_frames),
1983
  "valid_landmark_points": int(valid_landmark_points),
1984
  "counted": bool(counted),
1985
+ "artifact_revision": artifact_revision,
1986
  "meta_schema_version": 2,
1987
  }
1988
  def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
processor/app/workflow_dispatch.py CHANGED
@@ -36,6 +36,7 @@ _CAMERA_INPUTS = {
36
  "mono_camera", "rgbd_camera", "fisheye_camera", "rgb_camera",
37
  "stereo_camera", "stereo_rgbd_camera",
38
  }
 
39
 
40
 
41
  def project_workflow_ids(project: dict | None) -> list[str]:
@@ -100,10 +101,10 @@ def _node_source_groups(workflow: dict, bindings: dict | None) -> tuple[list[str
100
  continue
101
  data = node.get("data") or {}
102
  node_type = str(data.get("nodeType") or "")
103
- if node_type not in _CAMERA_INPUTS and node_type != "glove_sensor":
104
  continue
105
  values = _config_source_keys(data.get("config") or {})
106
- target = sensor_keys if node_type == "glove_sensor" else camera_keys
107
  target.extend(values)
108
  return camera_keys, sensor_keys
109
 
@@ -144,7 +145,8 @@ def _episode_input_groups(episode: dict) -> list[dict]:
144
  """Build physical input groups from the uploaded episode metadata.
145
 
146
  A group is the matching unit: one RGB stream, one Stereo pair, one RGB-D
147
- device, or one Stereo RGB-D device with an RGB pair plus depth metadata.
 
148
  The real source
149
  keys are kept for the worker snapshot, while ``input_type`` is the stable
150
  workflow contract.
@@ -282,10 +284,11 @@ def _workflow_input_specs(workflow: dict, bindings: dict | None) -> list[dict]:
282
  continue
283
  data = node.get("data") or {}
284
  node_type = str(data.get("nodeType") or "")
285
- if node_type not in _CAMERA_INPUTS and node_type != "glove_sensor":
286
  continue
287
  semantic = (
288
  "glove_sensor" if node_type == "glove_sensor"
 
289
  else "stereo_rgbd_camera" if node_type == "stereo_rgbd_camera"
290
  else "rgbd_camera" if node_type == "rgbd_camera"
291
  else "stereo_rgb" if node_type == "stereo_camera"
@@ -348,7 +351,7 @@ def _auto_bindings_for_episode(workflow: dict, episode: dict,
348
  continue
349
  auto[spec["node_id"]] = (
350
  {"source_keys": ",".join(keys)}
351
- if len(keys) > 1 or spec["semantic_type"] == "glove_sensor"
352
  else {"source_key": keys[0]}
353
  )
354
  matched.add(spec["node_id"])
@@ -363,7 +366,7 @@ def _clear_unmatched_input_configs(graph: dict, matched: set[str]) -> None:
363
  node_id = str(node.get("id") or "")
364
  data = node.get("data") or {}
365
  node_type = str(data.get("nodeType") or "")
366
- if node_id in matched or (node_type not in _CAMERA_INPUTS and node_type != "glove_sensor"):
367
  continue
368
  config = data.get("config")
369
  if not isinstance(config, dict):
 
36
  "mono_camera", "rgbd_camera", "fisheye_camera", "rgb_camera",
37
  "stereo_camera", "stereo_rgbd_camera",
38
  }
39
+ _COMPOSITE_INPUTS = {"gripper_device"}
40
 
41
 
42
  def project_workflow_ids(project: dict | None) -> list[str]:
 
101
  continue
102
  data = node.get("data") or {}
103
  node_type = str(data.get("nodeType") or "")
104
+ if node_type not in _CAMERA_INPUTS | _COMPOSITE_INPUTS and node_type != "glove_sensor":
105
  continue
106
  values = _config_source_keys(data.get("config") or {})
107
+ target = sensor_keys if node_type in {"glove_sensor", "gripper_device"} else camera_keys
108
  target.extend(values)
109
  return camera_keys, sensor_keys
110
 
 
145
  """Build physical input groups from the uploaded episode metadata.
146
 
147
  A group is the matching unit: one RGB stream, one Stereo pair, one RGB-D
148
+ device, one UMI gripper, or one Stereo RGB-D device with an RGB pair plus
149
+ depth metadata.
150
  The real source
151
  keys are kept for the worker snapshot, while ``input_type`` is the stable
152
  workflow contract.
 
284
  continue
285
  data = node.get("data") or {}
286
  node_type = str(data.get("nodeType") or "")
287
+ if node_type not in _CAMERA_INPUTS | _COMPOSITE_INPUTS and node_type != "glove_sensor":
288
  continue
289
  semantic = (
290
  "glove_sensor" if node_type == "glove_sensor"
291
+ else "gripper_device" if node_type == "gripper_device"
292
  else "stereo_rgbd_camera" if node_type == "stereo_rgbd_camera"
293
  else "rgbd_camera" if node_type == "rgbd_camera"
294
  else "stereo_rgb" if node_type == "stereo_camera"
 
351
  continue
352
  auto[spec["node_id"]] = (
353
  {"source_keys": ",".join(keys)}
354
+ if len(keys) > 1 or spec["semantic_type"] in {"glove_sensor", "gripper_device"}
355
  else {"source_key": keys[0]}
356
  )
357
  matched.add(spec["node_id"])
 
366
  node_id = str(node.get("id") or "")
367
  data = node.get("data") or {}
368
  node_type = str(data.get("nodeType") or "")
369
+ if node_id in matched or (node_type not in _CAMERA_INPUTS | _COMPOSITE_INPUTS and node_type != "glove_sensor"):
370
  continue
371
  config = data.get("config")
372
  if not isinstance(config, dict):
processor/app/workflow_instantiate.py CHANGED
@@ -39,7 +39,7 @@ from app.workflow_types import HAND_PROCESS_TYPES, migrate_graph_types
39
  # increment-instance de-duplication.
40
  INPUT_MODULES = {
41
  "mono_camera", "rgbd_camera", "rgb_camera", "fisheye_camera",
42
- "stereo_camera", "stereo_rgbd_camera", "glove_sensor",
43
  }
44
  VIDEO_PROCESS_TYPES = {
45
  "mediapipe_hand", *HAND_PROCESS_TYPES,
@@ -273,7 +273,22 @@ def complete_graph(graph: dict, camera_names: list[str], sensors: list[str]) ->
273
  key for module, key in covered
274
  if module in {"stereo_camera", "stereo_rgbd_camera"}
275
  }
276
- for group in _camera_groups(mains):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  mod = "stereo_camera" if len(group) > 1 else _camera_module(group[0])
278
  if mod in {"stereo_camera", "stereo_rgbd_camera"}:
279
  # A complete existing pair is already covered. An unpaired
@@ -340,7 +355,9 @@ def complete_graph(graph: dict, camera_names: list[str], sensors: list[str]) ->
340
 
341
  # ── 5. 视频输入节点:连接模板中的视频处理节点 ──
342
  for mod, keys in video_groups:
343
- if mod in {"stereo_camera", "stereo_rgbd_camera"}:
 
 
344
  handles = ["video_left"] if len(keys) < 2 else ["video_left", "video_right"]
345
  else:
346
  handles = ["video"]
@@ -484,7 +501,7 @@ def batch_covers_instance(instance: dict, camera_names: list[str]) -> bool:
484
  }
485
  for n in graph.get("nodes", []):
486
  data = n.get("data") or {}
487
- if data.get("category") != "input" or data.get("nodeType") == "glove_sensor":
488
  continue
489
  keys = [str(k).lower() for k in _keys_of(data.get("config") or {})]
490
  if not keys:
 
39
  # increment-instance de-duplication.
40
  INPUT_MODULES = {
41
  "mono_camera", "rgbd_camera", "rgb_camera", "fisheye_camera",
42
+ "stereo_camera", "stereo_rgbd_camera", "glove_sensor", "gripper_device",
43
  }
44
  VIDEO_PROCESS_TYPES = {
45
  "mediapipe_hand", *HAND_PROCESS_TYPES,
 
273
  key for module, key in covered
274
  if module in {"stereo_camera", "stereo_rgbd_camera"}
275
  }
276
+ # UMI publishes a primary RGB stream and internal left/right streams. They
277
+ # belong to one physical source node, not three camera inputs.
278
+ gripper_mains = [
279
+ c for c in mains
280
+ if "gripper" in str(c).lower() or "umi" in str(c).lower()
281
+ ]
282
+ camera_mains = [c for c in mains if c not in gripper_mains]
283
+ covered_gripper = any(
284
+ module == "gripper_device" and key in {
285
+ value.lower() for value in gripper_mains
286
+ }
287
+ for module, key in covered
288
+ )
289
+ if gripper_mains and not covered_gripper:
290
+ video_groups.append(("gripper_device", gripper_mains))
291
+ for group in _camera_groups(camera_mains):
292
  mod = "stereo_camera" if len(group) > 1 else _camera_module(group[0])
293
  if mod in {"stereo_camera", "stereo_rgbd_camera"}:
294
  # A complete existing pair is already covered. An unpaired
 
355
 
356
  # ── 5. 视频输入节点:连接模板中的视频处理节点 ──
357
  for mod, keys in video_groups:
358
+ if mod == "gripper_device":
359
+ handles = ["rgb_video"]
360
+ elif mod in {"stereo_camera", "stereo_rgbd_camera"}:
361
  handles = ["video_left"] if len(keys) < 2 else ["video_left", "video_right"]
362
  else:
363
  handles = ["video"]
 
501
  }
502
  for n in graph.get("nodes", []):
503
  data = n.get("data") or {}
504
+ if data.get("category") != "input" or data.get("nodeType") in {"glove_sensor", "gripper_device"}:
505
  continue
506
  keys = [str(k).lower() for k in _keys_of(data.get("config") or {})]
507
  if not keys:
processor/app/workflow_types.py CHANGED
@@ -89,6 +89,17 @@ def _migrate_hand_ports(node_type: str, data: dict[str, Any]) -> bool:
89
  if outputs != old_outputs:
90
  data["outputs"] = outputs
91
  changed = True
 
 
 
 
 
 
 
 
 
 
 
92
  if node_type in RGB_2D_TYPES:
93
  old_outputs = data.get("outputs") or []
94
  # The public contract is intentionally one output. Multi-view files
@@ -176,6 +187,15 @@ def migrate_graph_types(graph: dict | None) -> tuple[dict, bool]:
176
  )
177
  changed = True
178
  continue
 
 
 
 
 
 
 
 
 
179
  if source_type not in RGB_2D_TYPES:
180
  continue
181
  if handle == "hand_3d" or handle.startswith("hand_3d#"):
@@ -205,6 +225,30 @@ def migrate_graph_types(graph: dict | None) -> tuple[dict, bool]:
205
  )
206
  changed = True
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  # If an old workflow already used an RGB-D input card, complete its new
209
  # typed depth edge. Do not invent a depth source for old mono cards: those
210
  # remain executable through the processing module's legacy auto-pair path.
 
89
  if outputs != old_outputs:
90
  data["outputs"] = outputs
91
  changed = True
92
+ if node_type == "gripper_device":
93
+ old_outputs = data.get("outputs") or []
94
+ outputs = [
95
+ _port("rgb_video", "RGB Video"),
96
+ _port("gripper_state", "ESP Gripper State"),
97
+ _port("slam_trajectory", "SLAM Trajectory"),
98
+ _port("tactile_force_matrices", "Left and Right Force Matrices"),
99
+ ]
100
+ if outputs != old_outputs:
101
+ data["outputs"] = outputs
102
+ changed = True
103
  if node_type in RGB_2D_TYPES:
104
  old_outputs = data.get("outputs") or []
105
  # The public contract is intentionally one output. Multi-view files
 
187
  )
188
  changed = True
189
  continue
190
+ if source_type == "gripper_device" and handle in {"tactile_left", "tactile_right"}:
191
+ edge["sourceHandle"] = "tactile_force_matrices"
192
+ edge["id"] = (
193
+ f"xy-edge__{edge.get('source') or ''}"
194
+ f"{edge.get('sourceHandle') or ''}-"
195
+ f"{edge.get('target') or ''}{edge.get('targetHandle') or ''}"
196
+ )
197
+ changed = True
198
+ continue
199
  if source_type not in RGB_2D_TYPES:
200
  continue
201
  if handle == "hand_3d" or handle.startswith("hand_3d#"):
 
225
  )
226
  changed = True
227
 
228
+ # Merging the two tactile ports can make two historical edges identical
229
+ # when both sides were connected to the same downstream input.
230
+ unique_edges: list[dict[str, Any]] = []
231
+ seen_gripper_edges: set[tuple[str, str, str, str]] = set()
232
+ for edge in result.get("edges") or []:
233
+ if not isinstance(edge, dict):
234
+ unique_edges.append(edge)
235
+ continue
236
+ if (node_types.get(str(edge.get("source") or "")) == "gripper_device"
237
+ and edge.get("sourceHandle") == "tactile_force_matrices"):
238
+ key = (
239
+ str(edge.get("source") or ""),
240
+ str(edge.get("sourceHandle") or ""),
241
+ str(edge.get("target") or ""),
242
+ str(edge.get("targetHandle") or ""),
243
+ )
244
+ if key in seen_gripper_edges:
245
+ changed = True
246
+ continue
247
+ seen_gripper_edges.add(key)
248
+ unique_edges.append(edge)
249
+ if len(unique_edges) != len(result.get("edges") or []):
250
+ result["edges"] = unique_edges
251
+
252
  # If an old workflow already used an RGB-D input card, complete its new
253
  # typed depth edge. Do not invent a depth source for old mono cards: those
254
  # remain executable through the processing module's legacy auto-pair path.
processor/scripts/ensure_stack_linux.sh CHANGED
@@ -57,12 +57,15 @@ while :; do
57
  continue
58
  fi
59
 
60
- ensure_unit_started egodata-backend.service
 
 
 
61
 
62
  # Workers must only be started after the API has confirmed both the
63
  # PostgreSQL tunnel and the remote storage are usable.
64
  if backend_ready; then
65
- ensure_unit_started egodata-workers.service
66
  fi
67
 
68
  sleep "${sleep_until_ready}"
 
57
  continue
58
  fi
59
 
60
+ # The active installation uses the processor-local API/worker units.
61
+ # The old egodata-backend/egodata-workers units point at the pre-split
62
+ # Data Acquisition directory and must not be started after boot.
63
+ ensure_unit_started egodata-api.service
64
 
65
  # Workers must only be started after the API has confirmed both the
66
  # PostgreSQL tunnel and the remote storage are usable.
67
  if backend_ready; then
68
+ ensure_unit_started egodata-worker.service
69
  fi
70
 
71
  sleep "${sleep_until_ready}"
processor/tests/test_gripper_device.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ import pyarrow as pa
7
+ import pyarrow.parquet as pq
8
+
9
+ from app.processing import JobContext
10
+ from app.processing.modules.gripper_device import GripperDeviceModule
11
+ from app.workflow_dispatch import _episode_input_groups
12
+ from app.workflow_types import migrate_graph_types
13
+
14
+
15
+ class GripperDeviceModuleTests(unittest.TestCase):
16
+ def _context(self, root: Path) -> JobContext:
17
+ return JobContext(
18
+ node_id="gripper",
19
+ node_type="gripper_device",
20
+ config={"source_key": "gripper_rgb"},
21
+ job={},
22
+ input_root=root,
23
+ output_root=root / "outputs",
24
+ incoming={},
25
+ progress=lambda _value: None,
26
+ node_states={},
27
+ )
28
+
29
+ def test_publishes_one_composite_device_with_paired_force_output(self):
30
+ with tempfile.TemporaryDirectory() as temp:
31
+ root = Path(temp)
32
+ (root / "meta").mkdir()
33
+ (root / "videos" / "observation.images.gripper_rgb" / "chunk-000").mkdir(parents=True)
34
+ (root / "videos" / "observation.images.gripper_rgb" / "chunk-000" / "episode_000000.mp4").touch()
35
+ (root / "meta" / "info.json").write_text(json.dumps({
36
+ "devices": [{
37
+ "key": "gripper:test",
38
+ "kind": "gripper",
39
+ "name": "UMI Gripper",
40
+ "slots": ["gripper_rgb", "gripper_force_left", "gripper_force_right"],
41
+ }],
42
+ }), encoding="utf-8")
43
+ (root / "data" / "chunk-000").mkdir(parents=True)
44
+ pq.write_table(pa.table({
45
+ "observation.gripper_state": [[0.5, 1.0, 0.0]],
46
+ "observation.slam_pose": [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]],
47
+ "observation.gripper_left_force_matrix": [list(range(3))],
48
+ "observation.gripper_right_force_matrix": [list(range(3))],
49
+ }), root / "data" / "chunk-000" / "episode_000000.parquet")
50
+
51
+ outputs = GripperDeviceModule().run(self._context(root))
52
+
53
+ self.assertEqual(set(outputs), {
54
+ "rgb_video", "gripper_state", "slam_trajectory",
55
+ "tactile_force_matrices",
56
+ })
57
+ self.assertEqual(outputs["slam_trajectory"].metadata["names"],
58
+ ["x", "y", "z", "qx", "qy", "qz", "qw"])
59
+ self.assertEqual(outputs["tactile_force_matrices"].metadata["columns"], [
60
+ "observation.gripper_left_force_matrix",
61
+ "observation.gripper_right_force_matrix",
62
+ ])
63
+ self.assertEqual(outputs["tactile_force_matrices"].metadata["shape"],
64
+ [2, 250, 250, 3])
65
+
66
+ def test_dispatch_groups_gripper_stream_as_one_device(self):
67
+ episode = {
68
+ "camera_names": ["gripper_rgb", "gripper_stereo_left", "gripper_stereo_right"],
69
+ "device_names": {
70
+ "gripper_rgb": "UMI Gripper",
71
+ "gripper_stereo_left": "UMI Gripper",
72
+ "gripper_stereo_right": "UMI Gripper",
73
+ },
74
+ "devices": [],
75
+ "sensors": [],
76
+ }
77
+ groups = _episode_input_groups(episode)
78
+ self.assertEqual([group["input_type"] for group in groups], ["gripper_device"])
79
+ self.assertEqual(groups[0]["source_keys"], [
80
+ "gripper_rgb", "gripper_stereo_left", "gripper_stereo_right",
81
+ ])
82
+
83
+ def test_migrates_old_force_ports_to_one_connection(self):
84
+ graph, changed = migrate_graph_types({
85
+ "nodes": [{
86
+ "id": "gripper", "data": {
87
+ "nodeType": "gripper_device",
88
+ "outputs": [
89
+ {"key": "tactile_left", "label": "Left Force Matrix"},
90
+ {"key": "tactile_right", "label": "Right Force Matrix"},
91
+ ],
92
+ },
93
+ }, {"id": "target", "data": {"nodeType": "annotation"}}],
94
+ "edges": [
95
+ {"id": "left", "source": "gripper", "sourceHandle": "tactile_left",
96
+ "target": "target", "targetHandle": "data"},
97
+ {"id": "right", "source": "gripper", "sourceHandle": "tactile_right",
98
+ "target": "target", "targetHandle": "data"},
99
+ ],
100
+ })
101
+ self.assertTrue(changed)
102
+ self.assertEqual(graph["nodes"][0]["data"]["outputs"][-1]["key"],
103
+ "tactile_force_matrices")
104
+ self.assertEqual(len(graph["edges"]), 1)
105
+ self.assertEqual(graph["edges"][0]["sourceHandle"], "tactile_force_matrices")
106
+
107
+
108
+ if __name__ == "__main__":
109
+ unittest.main()
processor/tests/test_project_dataset.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pyarrow as pa
4
+ import pyarrow.parquet as pq
5
+
6
+ from app.project_dataset import (
7
+ _merge_info,
8
+ allocate_project_episode_id,
9
+ write_project_episode_index,
10
+ )
11
+
12
+
13
+ def _write_frame_data(root: Path) -> None:
14
+ path = root / "data" / "chunk-000" / "episode_000000.parquet"
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ pq.write_table(pa.table({
17
+ "episode_index": pa.array([0]),
18
+ "frame_index": pa.array([0]),
19
+ "observation.slam_pose": pa.array([[0.0] * 7]),
20
+ "observation.gripper_left_force": pa.array([[1.0, 2.0, 3.0]]),
21
+ }), path)
22
+
23
+
24
+ def test_new_episode_id_does_not_reuse_malformed_existing_id(tmp_path: Path):
25
+ write_project_episode_index(tmp_path, [{
26
+ "episode_index": 0,
27
+ "episode_id": "Uncategorized_000001",
28
+ "length": 10,
29
+ }])
30
+
31
+ assert allocate_project_episode_id(
32
+ tmp_path, "Test9_4", "Uncategorized",
33
+ ) == "Uncategorized_000002"
34
+
35
+
36
+ def test_empty_project_starts_at_episode_zero(tmp_path: Path):
37
+ assert allocate_project_episode_id(
38
+ tmp_path, "new_upload", "UMI夹爪项目",
39
+ ) == "UMI夹爪项目_000000"
40
+
41
+
42
+ def test_info_is_filtered_to_real_files_and_columns(tmp_path: Path):
43
+ _write_frame_data(tmp_path)
44
+ video = (tmp_path / "videos" / "observation.images.gripper_rgb"
45
+ / "chunk-000" / "episode_000000.mp4")
46
+ video.parent.mkdir(parents=True, exist_ok=True)
47
+ video.write_bytes(b"placeholder")
48
+
49
+ stale_info = {
50
+ "task_name": "Test9/4",
51
+ "features": {
52
+ "observation.slam_pose": {"dtype": "float32", "shape": [7]},
53
+ "observation.gripper_left_force": {
54
+ "dtype": "float32", "shape": [3],
55
+ },
56
+ "observation.images.gripper_rgb": {"dtype": "video"},
57
+ "observation.images.D435_rgb": {"dtype": "video"},
58
+ "observation.imu": {"dtype": "float32", "shape": [6]},
59
+ },
60
+ "devices": [
61
+ {"key": "d435", "name": "D435", "slots": ["D435_rgb"]},
62
+ {"key": "gripper", "name": "UMI", "slots": [
63
+ "gripper_rgb", "gripper_pose", "gripper_force_left",
64
+ ]},
65
+ ],
66
+ "device_names": {
67
+ "D435_rgb": "D435",
68
+ "gripper_rgb": "UMI",
69
+ "gripper_pose": "UMI",
70
+ "gripper_force_left": "UMI",
71
+ },
72
+ "cameras": {
73
+ "D435_rgb": {"width": 1280},
74
+ "gripper_rgb": {"width": 1280},
75
+ },
76
+ "sensors": ["right_glove"],
77
+ }
78
+ merged = _merge_info(tmp_path, stale_info, [{
79
+ "episode_index": 0,
80
+ "length": 1,
81
+ }], [{"task_index": 0, "task": "Test9/4"}])
82
+
83
+ assert set(merged["features"]) == {
84
+ "observation.slam_pose",
85
+ "observation.gripper_left_force",
86
+ "observation.images.gripper_rgb",
87
+ }
88
+ assert merged["cameras"] == {"gripper_rgb": {"width": 1280}}
89
+ assert [d["key"] for d in merged["devices"]] == ["gripper"]
90
+ assert merged["features"]["observation.slam_pose"]["quaternion_order"] == "xyzw"
91
+ assert merged["features"]["observation.gripper_left_force"]["names"] == [
92
+ "fx", "fy", "fz",
93
+ ]
processor/tests/test_tactile_decode.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ import numpy as np
4
+
5
+ from app.routes.video import _decode_tactile_matrix
6
+
7
+
8
+ class TactileDecodeTests(unittest.TestCase):
9
+ def test_interleaved_row_diff_is_decoded_before_hwc_reshape(self):
10
+ matrix = np.zeros((250, 250, 3), dtype=np.int16)
11
+ matrix[12, 34] = [7, -2, 19]
12
+ matrix[12, 35] = [8, -1, 23]
13
+ matrix[200, 249] = [-11, 3, 31]
14
+
15
+ rows = matrix.reshape(250, 750)
16
+ diff = np.empty_like(rows)
17
+ diff[:, 0] = rows[:, 0]
18
+ diff[:, 1:] = rows[:, 1:] - rows[:, :-1]
19
+
20
+ decoded = _decode_tactile_matrix(diff.reshape(-1).tolist())
21
+
22
+ self.assertEqual(decoded.shape, (3, 250, 250))
23
+ np.testing.assert_array_equal(decoded.transpose(1, 2, 0), matrix)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()
processor/web/static/js/hand-overlay.js CHANGED
@@ -257,6 +257,24 @@
257
  overlays.set(key, state);
258
  updateMenuStatus();
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  // 首帧探测:数据存在性 + 第一窗口
261
  const initialReady = (async () => {
262
  const count = Number(source.frame_count) || 0;
@@ -290,6 +308,11 @@
290
  const st = overlays.get(key);
291
  if (!st) return;
292
  overlays.delete(key);
 
 
 
 
 
293
  st.svg.remove();
294
  updateMenuStatus();
295
  }
 
257
  overlays.set(key, state);
258
  updateMenuStatus();
259
 
260
+ // The keypoint request and video metadata arrive independently. If
261
+ // keypoints win the race, _render() sees videoWidth === 0 and exits;
262
+ // without a later metadata event the overlay only appears after the
263
+ // central clock advances a few frames. Re-render on the first usable
264
+ // video layout while keeping the exact current frame.
265
+ const video = holder.querySelector('video');
266
+ const renderWhenVideoReady = () => {
267
+ if (overlays.get(key) !== state || state.status !== 'ready') return;
268
+ _render(state);
269
+ };
270
+ state.video = video;
271
+ state.renderWhenVideoReady = renderWhenVideoReady;
272
+ if (video) {
273
+ ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'resize']
274
+ .forEach(eventName => video.addEventListener(
275
+ eventName, renderWhenVideoReady));
276
+ }
277
+
278
  // 首帧探测:数据存在性 + 第一窗口
279
  const initialReady = (async () => {
280
  const count = Number(source.frame_count) || 0;
 
308
  const st = overlays.get(key);
309
  if (!st) return;
310
  overlays.delete(key);
311
+ if (st.video && st.renderWhenVideoReady) {
312
+ ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'resize']
313
+ .forEach(eventName => st.video.removeEventListener(
314
+ eventName, st.renderWhenVideoReady));
315
+ }
316
  st.svg.remove();
317
  updateMenuStatus();
318
  }
processor/web/static/js/player.js CHANGED
@@ -1,5 +1,5 @@
1
  /* Plyr video player management */
2
- console.log('[player.js] build 202609030900-sync-barrier-central-clock');
3
 
4
  const players = {}; // {camera_name: Plyr instance}
5
  let currentEpisodeId = null;
@@ -58,6 +58,8 @@ function _cancelEpisodeMediaLoad(clearCanvas = true) {
58
  currentImageTiles = [];
59
  currentDepthPreviewTiles = [];
60
  currentHand3dTiles = [];
 
 
61
  workspaceGrid = null;
62
  workspaceMainRow = null;
63
  workspaceHandFooter = null;
@@ -172,6 +174,15 @@ let hand3dFrameCacheBySource = {};
172
  let _hand3dTileEntry = null; // 3D tile 引用,懒加载回调重绘用
173
  let currentHand3dTiles = []; // 画布中的 3D 骨骼 tile {canvas, source, rotX, rotY, ...}
174
  let currentDepthPreviewTiles = []; // raw uint16 depth codes + frontend Canvas
 
 
 
 
 
 
 
 
 
175
  const _depthLittleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
176
  // Keep one recently-used complete raw-code buffer across episode clicks. The
177
  // decoded D435 stream is large, so bound this cache to one typical episode;
@@ -258,6 +269,74 @@ const DEPTH_FULL_PRELOAD = true;
258
  // the strict all-windows preload barrier instead.
259
  const DEPTH_FULL_PRELOAD_MAX_FRAMES = 2000;
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  let _depthPlaybackStall = null;
262
 
263
  function _depthFrameReady(frame) {
@@ -756,6 +835,11 @@ window.invalidateEpisodePlaybackCache = function (episodeId) {
756
  for (const key of _hand3dFullCache.keys()) {
757
  if (key.startsWith(prefix)) _hand3dFullCache.delete(key);
758
  }
 
 
 
 
 
759
  if (window.EgoMediaCache && episodeId) {
760
  window.EgoMediaCache.removeEpisode(episodeId).catch(() => {});
761
  }
@@ -817,55 +901,88 @@ function _hand3dCached(frame, sourceKey) {
817
  return fr ? { h0: fr.h0 || null, h1: fr.h1 || null } : null;
818
  }
819
 
820
- function _hand3dFullCacheKey(sourceKey) {
821
- return `${currentEpisodeId || ''}:${sourceKey || 'default'}`;
 
 
 
 
 
 
 
 
822
  }
823
 
824
- function _restoreHand3DFull(cache, sourceKey) {
825
- const saved = _hand3dFullCache.get(_hand3dFullCacheKey(sourceKey));
826
- if (!saved || !saved.frames) return false;
 
 
 
 
827
  cache.frames = saved.frames;
828
  cache.start = 0;
829
  cache.end = Math.max(0, Number(saved.count || 0) - 1);
830
  cache.fullReady = true;
 
831
  return true;
832
  }
833
 
834
- function _rememberHand3DFull(cache, sourceKey, count) {
835
- const key = _hand3dFullCacheKey(sourceKey);
836
  _hand3dFullCache.delete(key);
837
- _hand3dFullCache.set(key, { frames: cache.frames, count });
 
 
 
838
  while (_hand3dFullCache.size > HAND3D_FULL_CACHE_MAX_EPISODES) {
839
  _hand3dFullCache.delete(_hand3dFullCache.keys().next().value);
840
  }
841
  }
842
 
843
- async function fetchHand3DFull(sourceKey) {
844
  const source = sourceKey || 'default';
 
 
 
 
 
845
  const cache = _hand3dCacheFor(source);
846
- if (cache.fullReady) return true;
847
- if (_restoreHand3DFull(cache, source)) return true;
848
- if (cache.fullInflight) return cache.fullInflight;
849
  const data = hand3dDataBySource[source] || hand3dData;
 
 
 
 
 
 
 
 
 
 
850
  const count = Number(data && data.count || 0);
851
  if (!count || count > HAND3D_FULL_PRELOAD_MAX_FRAMES) return false;
852
- const episodeAtRequest = currentEpisodeId;
853
  const signal = getMediaLoadSignal();
854
- const persistentKey = `${episodeAtRequest}:hand3d:${source}:${count}`;
855
  cache.fullInflight = (async () => {
856
  try {
857
  if (window.EgoMediaCache) {
858
  const saved = await window.EgoMediaCache.get(persistentKey);
 
859
  const value = saved && saved.value;
860
  if (value && Array.isArray(value.frames)
861
- && Number(value.count || 0) === count) {
 
 
862
  const frames = {};
863
  value.frames.forEach(fr => { frames[fr.f] = fr; });
 
864
  cache.frames = frames;
865
  cache.start = 0;
866
  cache.end = Math.max(0, count - 1);
867
  cache.fullReady = true;
868
- _rememberHand3DFull(cache, source, count);
 
 
869
  return true;
870
  }
871
  }
@@ -873,19 +990,26 @@ async function fetchHand3DFull(sourceKey) {
873
  `/api/v1/video/${episodeAtRequest}/hand-3d?source_key=`
874
  + `${encodeURIComponent(source)}&start_frame=0&end_frame=${count - 1}`,
875
  signal ? { signal } : {});
876
- if (!res.ok || currentEpisodeId !== episodeAtRequest) return false;
877
  const payload = await res.json();
878
- if (currentEpisodeId !== episodeAtRequest) return false;
 
 
879
  const frames = {};
880
  (payload.frames || []).forEach(fr => { frames[fr.f] = fr; });
 
881
  cache.frames = frames;
882
  cache.start = 0;
883
  cache.end = Math.max(0, count - 1);
884
  cache.fullReady = true;
885
- _rememberHand3DFull(cache, source, count);
886
- if (window.EgoMediaCache) {
 
 
887
  window.EgoMediaCache.put(persistentKey, {
888
- frames: payload.frames || [], count,
 
 
889
  });
890
  }
891
  return true;
@@ -1048,12 +1172,16 @@ function groupedSourceLabel(source) {
1048
  if (source.kind === 'skeleton') return `${source.label || source.source_key} · ${t('skeleton_suffix')}`;
1049
  if (source.kind === 'glove') return source.label || `${source.source_key} · ${t('glove_suffix')}`;
1050
  if (source.kind === 'depth') return source.label || `${source.source_key} · ${t('depth_suffix')}`;
 
 
 
1051
  if (source.kind === 'stereo_group') return `${source.label} · ${t('stereo_pair')}`;
1052
  return source.label || source.source_key || t('video_suffix');
1053
  }
1054
 
1055
  function _isBottomSource(source) {
1056
- return ['hand', 'depth', 'glove'].includes(source.kind);
 
1057
  }
1058
 
1059
  function groupedSource(kind, source_key, meta) {
@@ -1065,7 +1193,8 @@ function expandWorkspaceItems(source) {
1065
  if (source.kind === 'stereo_group') {
1066
  return (source.members || []).map(m => ({
1067
  kind: 'video', source_key: m.source_key, label: m.label,
1068
- stream_url: m.stream_url, frame_count: m.frame_count, fps: m.fps,
 
1069
  }));
1070
  }
1071
  return [{ ...source }];
@@ -1167,6 +1296,10 @@ function removeGroupedSource(source) {
1167
  groupedSourceKey(entry.source) !== key);
1168
  currentDepthPreviewTiles = currentDepthPreviewTiles.filter(entry =>
1169
  groupedSourceKey(entry.source) !== key);
 
 
 
 
1170
  workspaceSources = workspaceSources.filter(item => groupedSourceKey(item) !== key);
1171
  renderSourceBar();
1172
  renderPreviewVideoSources();
@@ -1374,6 +1507,7 @@ function _serializeSource(source) {
1374
  depth_codes_url: source.depth_codes_url,
1375
  raw_depth_url: source.raw_depth_url,
1376
  depth_cache_key: source.depth_cache_key,
 
1377
  // Optional grayscale clock stream; never used as depth pixels.
1378
  depth_video_url: source.depth_video_url,
1379
  missing_frames: source.missing_frames,
@@ -1389,9 +1523,479 @@ function _frameUrl(urlTemplate, frameIndex) {
1389
  return urlTemplate.replace('{frame}', Math.max(0, frameIndex));
1390
  }
1391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1392
  async function mountGroupedSource(source, tile) {
1393
  const episodeAtMount = currentEpisodeId;
 
1394
  if (!tile || currentEpisodeId !== episodeAtMount) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1395
  if (source.kind === 'hand3d_world') {
1396
  // 3D 手部世界坐标交互窗口:canvas 渲染 /hand-3d 逐帧数据
1397
  // (worker 只产 parquet,前端实时投影;拖拽旋转 + 滚轮缩放)
@@ -1407,21 +2011,82 @@ async function mountGroupedSource(source, tile) {
1407
  // Do not reveal the spatial canvas before its initial frame window is
1408
  // available. RGB/2D and 3D then enter the workspace together.
1409
  await _ensureHand3DInitialWindow(source.source_key || 'default');
1410
- if (currentEpisodeId !== episodeAtMount || !tile.isConnected) return;
 
1411
  renderHand3DTile(entry, 0);
1412
  return;
1413
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1414
  if (source.kind === 'depth' && source.depth_video_url) {
1415
  // Depth previews are browser-decodable 8-bit Turbo MP4s generated
1416
  // once per source from the canonical 12-bit stream (identical
1417
  // clamp/scale/colormap math as the former canvas renderer). Play
1418
  // them as native videos: instant open, native seek, and frame sync
1419
  // through the same master-drift loop as every other camera.
 
 
 
 
1420
  const holder = document.createElement('div');
1421
  holder.id = `player-container-${groupedSourceDomId(source)}`;
1422
  holder.className = 'bg-black rounded overflow-hidden w-full h-full min-h-0 relative';
1423
  tile.appendChild(holder);
1424
- const player = initPlayer(holder.id, source.depth_video_url);
 
 
1425
  if (player) players[groupedSourceKey(source)] = player;
1426
  return;
1427
  }
@@ -1465,10 +2130,13 @@ async function mountGroupedSource(source, tile) {
1465
  holder.id = `player-container-${groupedSourceDomId(source)}`;
1466
  holder.className = 'bg-black rounded overflow-hidden w-full h-full min-h-0';
1467
  tile.appendChild(holder);
1468
- const url = source.stream_url ||
1469
  await pickVideoUrl(episodeAtMount, source.source_key);
1470
- if (currentEpisodeId !== episodeAtMount || !tile.isConnected) return;
1471
- const player = initPlayer(holder.id, url);
 
 
 
1472
  if (player) players[groupedSourceKey(source)] = player;
1473
  // 手部骨骼 SVG 叠加层(只加在原始视频上;skeleton/渲染 tile 不加)
1474
  // The first keypoint window is part of the shared initial-frame barrier.
@@ -1490,12 +2158,12 @@ function bindGloveSync(player) {
1490
 
1491
  let _lastImageTileFrame = -1;
1492
  function _refreshImageTilesAt(frame) {
1493
- if (!currentImageTiles.length && !currentDepthPreviewTiles.length) return;
 
1494
  _refreshDepthTilesAt(frame);
1495
- if (!currentImageTiles.length) return;
1496
- if (frame === _lastImageTileFrame) return;
1497
- _lastImageTileFrame = frame;
1498
- currentImageTiles.forEach((entry) => {
1499
  const { img, source } = entry;
1500
  // Hand/glove tiles are driven by heatmap.js from the preloaded
1501
  // frames-data payload; only template-based non-depth tiles are
@@ -1508,6 +2176,13 @@ function _refreshImageTilesAt(frame) {
1508
  url = source.heatmap_url || source.depth_preview_url || source.depth_url;
1509
  }
1510
  if (url) img.src = _frameUrl(url, frame);
 
 
 
 
 
 
 
1511
  });
1512
  }
1513
 
@@ -1668,21 +2343,25 @@ function _createWorkspaceTile(source, movable, reorderContainer) {
1668
  tile.style.alignSelf = 'center';
1669
  }
1670
 
1671
- const label = document.createElement('span');
1672
- label.className = 'absolute z-10 top-1 left-1 bg-black/70 text-gray-200 text-[10px] px-1.5 py-0.5 rounded';
1673
- label.textContent = groupedSourceLabel(source);
1674
- tile.appendChild(label);
 
 
1675
 
1676
- const remove = document.createElement('button');
1677
- remove.type = 'button';
1678
- remove.className = 'absolute z-10 top-1 right-1 bg-black/70 text-gray-300 hover:text-white text-xs w-5 h-5 rounded';
1679
- remove.textContent = '\u00d7';
1680
- remove.title = t('remove_source');
1681
- remove.onclick = event => {
1682
- event.stopPropagation();
1683
- removeGroupedSource(source);
1684
- };
1685
- tile.appendChild(remove);
 
 
1686
 
1687
  if (!movable) return tile;
1688
 
@@ -2042,7 +2721,8 @@ function renderGroupedWorkspaceLegacy() {
2042
  // Two fixed zones: a video area on top (free drag-to-reorder) and a
2043
  // heatmap area below (left-hand on the left column, right-hand on the
2044
  // right column — always, per requirement).
2045
- const isImageSource = s => ['glove', 'depth', 'hand'].includes(s.kind);
 
2046
  const vids = workspaceSources.filter(s => !isImageSource(s));
2047
  const imgs = workspaceSources.filter(s => isImageSource(s));
2048
 
@@ -2086,17 +2766,21 @@ function renderGroupedWorkspaceLegacy() {
2086
  tile.style.minWidth = '0';
2087
  tile.style.width = '100%';
2088
  tile.style.height = '100%';
2089
- const label = document.createElement('span');
2090
- label.className = 'absolute z-10 top-1 left-1 bg-black/70 text-gray-200 text-[10px] px-1.5 py-0.5 rounded';
2091
- label.textContent = groupedSourceLabel(source);
2092
- tile.appendChild(label);
2093
- const remove = document.createElement('button');
2094
- remove.type = 'button';
2095
- remove.className = 'absolute z-10 top-1 right-1 bg-black/70 text-gray-300 hover:text-white text-xs w-5 h-5 rounded';
2096
- remove.textContent = '×';
2097
- remove.title = t('remove_source');
2098
- remove.onclick = () => removeGroupedSource(source);
2099
- tile.appendChild(remove);
 
 
 
 
2100
  if (inVideoRow) {
2101
  // Drag to reorder videos (DOM-level swap — playback keeps running)
2102
  tile.draggable = true;
@@ -2197,7 +2881,9 @@ function _defaultWorkspaceFromGroups() {
2197
  // 并已把 hand3d_world 加入 workspaceSources,整表重建不能冲掉
2198
  // (否则开关显示开启、窗口实际不存在 —— 竞态脱节 bug)
2199
  const keep = workspaceSources.filter(s =>
2200
- s.kind === 'hand3d_world' || s.kind === 'depth');
 
 
2201
  // Depth is a first-class default preview source. It must be present in
2202
  // workspaceSources before renderGroupedWorkspace takes its mount Promise
2203
  // snapshot; adding it immediately after render creates a race where the
@@ -2213,7 +2899,8 @@ function _defaultWorkspaceFromGroups() {
2213
  if (grp && (grp.members || []).length) {
2214
  workspaceSources = grp.members.map(m => ({
2215
  kind: 'video', source_key: m.source_key, label: m.label,
2216
- stream_url: m.stream_url, frame_count: m.frame_count, fps: m.fps,
 
2217
  }));
2218
  workspaceSources.push(...retained);
2219
  return;
@@ -2222,7 +2909,8 @@ function _defaultWorkspaceFromGroups() {
2222
  if (singles.length) {
2223
  workspaceSources = singles.map(single => ({
2224
  kind: 'video', source_key: single.source_key, label: single.label,
2225
- stream_url: single.stream_url, frame_count: single.frame_count, fps: single.fps,
 
2226
  }));
2227
  }
2228
  workspaceSources.push(...retained);
@@ -2248,12 +2936,18 @@ async function loadGroupedEpisodeVideo(episodeId, cameras, options = {}) {
2248
  currentImageTiles = [];
2249
  currentDepthPreviewTiles = [];
2250
  currentHand3dTiles = [];
 
 
2251
  hand3dData = null;
2252
  hand3dDataBySource = {};
2253
  hand3dFrameCache = { frame: -1, data: null, inflight: -1 };
2254
  hand3dFrameCacheBySource = {};
2255
  hand3dWindow = { start: -1, end: -1, frames: {}, inflight: false };
2256
  hand3dWindowBySource = {};
 
 
 
 
2257
  _depthPlaybackStall = null;
2258
  _hand3dViewAnchor = null;
2259
  _h3dBaseDist = null;
@@ -2316,6 +3010,8 @@ async function loadGroupedEpisodeVideo(episodeId, cameras, options = {}) {
2316
  // building the workspace. The 3D tile itself then awaits its first data
2317
  // window together with each RGB overlay's first keypoint window.
2318
  const hand3dReady = loadHand3D(episodeId, loadToken);
 
 
2319
 
2320
  const grid = document.getElementById('video-grid');
2321
  if (!grid) { hideVideoLoading(); return; }
@@ -2330,10 +3026,18 @@ async function loadGroupedEpisodeVideo(episodeId, cameras, options = {}) {
2330
  if (!isCurrentPlaybackSession(episodeId, loadToken)) return; // 丢弃过期响应
2331
  if (res.ok) {
2332
  currentMediaGroups = await res.json();
2333
- await hand3dReady;
2334
  if (!isCurrentPlaybackSession(episodeId, loadToken)) return;
2335
  renderSourceBar();
2336
  _defaultWorkspaceFromGroups();
 
 
 
 
 
 
 
 
2337
  // Add the selected spatial source before renderGroupedWorkspace
2338
  // takes its Promise.all(mounted) snapshot.
2339
  if (typeof ensureHand3dWorldTile === 'function'
@@ -2418,12 +3122,16 @@ function loadEpisodeVideo(episodeId, cameras, options = {}) {
2418
  // can leave the global skeleton switch disabled for the next episode.
2419
  if (typeof destroyAllHandOverlays === 'function') destroyAllHandOverlays();
2420
  currentHand3dTiles = [];
 
2421
  hand3dData = null;
2422
  hand3dDataBySource = {};
2423
  hand3dFrameCache = { frame: -1, data: null, inflight: -1 };
2424
  hand3dFrameCacheBySource = {};
2425
  hand3dWindow = { start: -1, end: -1, frames: {}, inflight: false };
2426
  hand3dWindowBySource = {};
 
 
 
2427
  _depthPlaybackStall = null;
2428
  _hand3dViewAnchor = null;
2429
  _h3dBaseDist = null;
@@ -3382,10 +4090,469 @@ const HAND3D_CONNECTIONS = [
3382
  [0, 17], // 掌根
3383
  ];
3384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3385
  async function loadHand3D(episodeId, sessionToken = null) {
3386
  try {
3387
  const signal = getMediaLoadSignal();
3388
- const res = await fetch(`/api/v1/video/${episodeId}/hand-3d`, signal ? { signal } : {});
 
 
3389
  if (!isCurrentPlaybackSession(episodeId, sessionToken)) return; // 丢弃过期响应
3390
  if (!res.ok) return;
3391
  const data = await res.json();
@@ -3805,11 +4972,21 @@ function _normalizeDepthWorldDisplayScale(slots, entry, w, h) {
3805
  for (const slot of slots) {
3806
  const valid = slot.valid || [];
3807
  if (valid.length < 4) continue;
3808
- const xs = valid.map(p => p[0]), ys = valid.map(p => p[1]);
3809
- const zs = valid.map(p => p[2]);
3810
- spans.push(Math.max(Math.max(...xs) - Math.min(...xs),
3811
- Math.max(...ys) - Math.min(...ys),
3812
- Math.max(...zs) - Math.min(...zs)));
 
 
 
 
 
 
 
 
 
 
3813
  }
3814
  const measured = spans.length
3815
  ? spans.reduce((sum, value) => sum + value, 0) / spans.length : 0;
@@ -3841,6 +5018,41 @@ function _normalizeDepthWorldDisplayScale(slots, entry, w, h) {
3841
  return scale;
3842
  }
3843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3844
  function _hand3dJointRadius(proj, index, tip = false, styleScale = 1) {
3845
  if (!proj[index] || !Number.isFinite(proj[index][0])
3846
  || !Number.isFinite(proj[index][1])) return 0;
@@ -4129,6 +5341,9 @@ function renderHand3DTile(entry, frameOverride) {
4129
  slots.forEach(s => all.push(...s.valid));
4130
  }
4131
  if (sourceData && sourceData.worldMode) {
 
 
 
4132
  _normalizeDepthWorldDisplayScale(slots, entry, w, h);
4133
  all.length = 0;
4134
  slots.forEach(s => all.push(...s.valid));
@@ -4332,6 +5547,7 @@ function initHand3dDrag(entry) {
4332
  标注高亮共用;闭包在调用时遍历,脚本加载时 tile 列表为空无影响) */
4333
  setOnFrameChange((f) => {
4334
  for (const entry of currentHand3dTiles) renderHand3DTile(entry, f);
 
4335
  });
4336
 
4337
  /* 图像素材(深度图/手套热力图)的逐帧 rAF 刷新循环(见 _startImageTileRaf) */
 
1
  /* Plyr video player management */
2
+ console.log('[player.js] build 202609091912-tactile-chunk-prefetch');
3
 
4
  const players = {}; // {camera_name: Plyr instance}
5
  let currentEpisodeId = null;
 
58
  currentImageTiles = [];
59
  currentDepthPreviewTiles = [];
60
  currentHand3dTiles = [];
61
+ currentSlamTiles = [];
62
+ currentTactileTiles = [];
63
  workspaceGrid = null;
64
  workspaceMainRow = null;
65
  workspaceHandFooter = null;
 
174
  let _hand3dTileEntry = null; // 3D tile 引用,懒加载回调重绘用
175
  let currentHand3dTiles = []; // 画布中的 3D 骨骼 tile {canvas, source, rotX, rotY, ...}
176
  let currentDepthPreviewTiles = []; // raw uint16 depth codes + frontend Canvas
177
+ let currentTactileTiles = []; // 左右力矩阵合并预览,按统一帧号刷新
178
+ let slamData = null; // /slam-trajectory response for current episode
179
+ let slamFramesByIndex = {};
180
+ let slamWindowInflight = null;
181
+ let currentSlamTiles = []; // 画布中的 SLAM 轨迹 tile
182
+ let tactileMatrixData = null; // /tactile-matrix-meta response
183
+ const SLAM_DISPLAY_SMOOTH_ALPHA = 0.35;
184
+ const SLAM_DISPLAY_JOIN_GAP_FRAMES = 3;
185
+ const SLAM_DISPLAY_CAMERA_DISTANCE_SCALE = 1.55;
186
  const _depthLittleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
187
  // Keep one recently-used complete raw-code buffer across episode clicks. The
188
  // decoded D435 stream is large, so bound this cache to one typical episode;
 
269
  // the strict all-windows preload barrier instead.
270
  const DEPTH_FULL_PRELOAD_MAX_FRAMES = 2000;
271
 
272
+ // Complete browser-side video blobs make arbitrary progress-bar seeks local
273
+ // and keep the rendered depth preview on the same seek timeline as RGB.
274
+ const _browserVideoAssetCache = new Map();
275
+ const BROWSER_VIDEO_ASSET_CACHE_MAX = 4;
276
+
277
+ function _browserVideoAssetUrl(source, fallbackUrl) {
278
+ const url = String(fallbackUrl || '');
279
+ if (!url) return '';
280
+ const revision = source && (
281
+ source.depth_cache_key || source.video_cache_key || source.cache_key);
282
+ if (!revision || /(?:^|[?&])v=/.test(url)) return url;
283
+ return `${url}${url.includes('?') ? '&' : '?'}v=${encodeURIComponent(revision)}`;
284
+ }
285
+
286
+ async function _preloadBrowserVideoAsset(source, fallbackUrl, sessionToken) {
287
+ const episodeAtRequest = currentEpisodeId;
288
+ const tokenAtRequest = sessionToken == null
289
+ ? _playbackSessionToken : sessionToken;
290
+ const isActive = () => isCurrentPlaybackSession(
291
+ episodeAtRequest, tokenAtRequest);
292
+ if (!isActive()) return null;
293
+ const url = _browserVideoAssetUrl(source, fallbackUrl);
294
+ if (!url) return null;
295
+ const key = `${episodeAtRequest}:${source?.kind || 'video'}:` +
296
+ `${source?.source_key || ''}:${url}`;
297
+ const existing = _browserVideoAssetCache.get(key);
298
+ if (existing?.url) return existing.url;
299
+ if (existing?.promise) return existing.promise;
300
+
301
+ const promise = (async () => {
302
+ try {
303
+ const response = await fetch(url, {
304
+ cache: 'force-cache',
305
+ signal: getMediaLoadSignal() || undefined,
306
+ });
307
+ if (!response.ok) throw new Error(`video preload failed: ${response.status}`);
308
+ const blob = await response.blob();
309
+ if (!isActive()) return null;
310
+ const objectUrl = URL.createObjectURL(blob);
311
+ _browserVideoAssetCache.set(key, {
312
+ url: objectUrl, bytes: blob.size, ready: true,
313
+ });
314
+ while (_browserVideoAssetCache.size > BROWSER_VIDEO_ASSET_CACHE_MAX) {
315
+ const oldestKey = _browserVideoAssetCache.keys().next().value;
316
+ const oldest = _browserVideoAssetCache.get(oldestKey);
317
+ if (oldest?.url) URL.revokeObjectURL(oldest.url);
318
+ _browserVideoAssetCache.delete(oldestKey);
319
+ }
320
+ return objectUrl;
321
+ } catch (error) {
322
+ if (isActive()) {
323
+ console.warn('[player] complete video preload failed; fallback to range stream', error);
324
+ }
325
+ return null;
326
+ } finally {
327
+ const state = _browserVideoAssetCache.get(key);
328
+ if (state?.promise) {
329
+ if (state.url) _browserVideoAssetCache.set(key, {
330
+ url: state.url, bytes: state.bytes, ready: true,
331
+ });
332
+ else _browserVideoAssetCache.delete(key);
333
+ }
334
+ }
335
+ })();
336
+ _browserVideoAssetCache.set(key, { promise });
337
+ return promise;
338
+ }
339
+
340
  let _depthPlaybackStall = null;
341
 
342
  function _depthFrameReady(frame) {
 
835
  for (const key of _hand3dFullCache.keys()) {
836
  if (key.startsWith(prefix)) _hand3dFullCache.delete(key);
837
  }
838
+ if (currentEpisodeId === episodeId) {
839
+ slamData = null;
840
+ slamFramesByIndex = {};
841
+ slamWindowInflight = null;
842
+ }
843
  if (window.EgoMediaCache && episodeId) {
844
  window.EgoMediaCache.removeEpisode(episodeId).catch(() => {});
845
  }
 
901
  return fr ? { h0: fr.h0 || null, h1: fr.h1 || null } : null;
902
  }
903
 
904
+ function _hand3dArtifactRevision(sourceKey) {
905
+ const source = sourceKey || 'default';
906
+ const data = hand3dDataBySource[source] || hand3dData;
907
+ return String(data && data.artifact_revision || 'unknown');
908
+ }
909
+
910
+ function _hand3dFullCacheKey(sourceKey, artifactRevision = null) {
911
+ const revision = artifactRevision == null
912
+ ? _hand3dArtifactRevision(sourceKey) : String(artifactRevision);
913
+ return `${currentEpisodeId || ''}:${sourceKey || 'default'}:${revision}`;
914
  }
915
 
916
+ function _restoreHand3DFull(cache, sourceKey, artifactRevision) {
917
+ const saved = _hand3dFullCache.get(
918
+ _hand3dFullCacheKey(sourceKey, artifactRevision));
919
+ if (!saved || !saved.frames
920
+ || String(saved.artifactRevision || 'unknown') !== String(artifactRevision)) {
921
+ return false;
922
+ }
923
  cache.frames = saved.frames;
924
  cache.start = 0;
925
  cache.end = Math.max(0, Number(saved.count || 0) - 1);
926
  cache.fullReady = true;
927
+ cache.fullArtifactRevision = String(artifactRevision);
928
  return true;
929
  }
930
 
931
+ function _rememberHand3DFull(cache, sourceKey, count, artifactRevision) {
932
+ const key = _hand3dFullCacheKey(sourceKey, artifactRevision);
933
  _hand3dFullCache.delete(key);
934
+ _hand3dFullCache.set(key, {
935
+ frames: cache.frames, count,
936
+ artifactRevision: String(artifactRevision),
937
+ });
938
  while (_hand3dFullCache.size > HAND3D_FULL_CACHE_MAX_EPISODES) {
939
  _hand3dFullCache.delete(_hand3dFullCache.keys().next().value);
940
  }
941
  }
942
 
943
+ async function fetchHand3DFull(sourceKey, sessionToken = _playbackSessionToken) {
944
  const source = sourceKey || 'default';
945
+ const episodeAtRequest = currentEpisodeId;
946
+ const tokenAtRequest = sessionToken;
947
+ const isActive = () => isCurrentPlaybackSession(
948
+ episodeAtRequest, tokenAtRequest);
949
+ if (!isActive()) return false;
950
  const cache = _hand3dCacheFor(source);
 
 
 
951
  const data = hand3dDataBySource[source] || hand3dData;
952
+ const artifactRevision = String(
953
+ data && data.artifact_revision || 'unknown');
954
+ if (cache.fullReady
955
+ && String(cache.fullArtifactRevision || 'unknown')
956
+ === artifactRevision) {
957
+ return true;
958
+ }
959
+ if (cache.fullReady) cache.fullReady = false;
960
+ if (_restoreHand3DFull(cache, source, artifactRevision)) return true;
961
+ if (cache.fullInflight) return cache.fullInflight;
962
  const count = Number(data && data.count || 0);
963
  if (!count || count > HAND3D_FULL_PRELOAD_MAX_FRAMES) return false;
 
964
  const signal = getMediaLoadSignal();
965
+ const persistentKey = `${episodeAtRequest}:hand3d:${source}:${count}:${artifactRevision}`;
966
  cache.fullInflight = (async () => {
967
  try {
968
  if (window.EgoMediaCache) {
969
  const saved = await window.EgoMediaCache.get(persistentKey);
970
+ if (!isActive()) return false;
971
  const value = saved && saved.value;
972
  if (value && Array.isArray(value.frames)
973
+ && Number(value.count || 0) === count
974
+ && String(value.artifact_revision || 'unknown')
975
+ === artifactRevision) {
976
  const frames = {};
977
  value.frames.forEach(fr => { frames[fr.f] = fr; });
978
+ if (!isActive()) return false;
979
  cache.frames = frames;
980
  cache.start = 0;
981
  cache.end = Math.max(0, count - 1);
982
  cache.fullReady = true;
983
+ cache.fullArtifactRevision = artifactRevision;
984
+ _rememberHand3DFull(
985
+ cache, source, count, artifactRevision);
986
  return true;
987
  }
988
  }
 
990
  `/api/v1/video/${episodeAtRequest}/hand-3d?source_key=`
991
  + `${encodeURIComponent(source)}&start_frame=0&end_frame=${count - 1}`,
992
  signal ? { signal } : {});
993
+ if (!res.ok || !isActive()) return false;
994
  const payload = await res.json();
995
+ if (!isActive()) return false;
996
+ if (String(payload && payload.artifact_revision || 'unknown')
997
+ !== artifactRevision) return false;
998
  const frames = {};
999
  (payload.frames || []).forEach(fr => { frames[fr.f] = fr; });
1000
+ if (!isActive()) return false;
1001
  cache.frames = frames;
1002
  cache.start = 0;
1003
  cache.end = Math.max(0, count - 1);
1004
  cache.fullReady = true;
1005
+ cache.fullArtifactRevision = artifactRevision;
1006
+ _rememberHand3DFull(
1007
+ cache, source, count, artifactRevision);
1008
+ if (window.EgoMediaCache && isActive()) {
1009
  window.EgoMediaCache.put(persistentKey, {
1010
+ frames: payload.frames || [],
1011
+ count,
1012
+ artifact_revision: artifactRevision,
1013
  });
1014
  }
1015
  return true;
 
1172
  if (source.kind === 'skeleton') return `${source.label || source.source_key} · ${t('skeleton_suffix')}`;
1173
  if (source.kind === 'glove') return source.label || `${source.source_key} · ${t('glove_suffix')}`;
1174
  if (source.kind === 'depth') return source.label || `${source.source_key} · ${t('depth_suffix')}`;
1175
+ if (source.kind === 'slam_trajectory') return source.label || 'SLAM';
1176
+ if (source.kind === 'tactile_force_left') return 'Left';
1177
+ if (source.kind === 'tactile_force_right') return 'Right';
1178
  if (source.kind === 'stereo_group') return `${source.label} · ${t('stereo_pair')}`;
1179
  return source.label || source.source_key || t('video_suffix');
1180
  }
1181
 
1182
  function _isBottomSource(source) {
1183
+ return ['hand', 'depth', 'glove', 'tactile_force_left',
1184
+ 'tactile_force_right'].includes(source.kind);
1185
  }
1186
 
1187
  function groupedSource(kind, source_key, meta) {
 
1193
  if (source.kind === 'stereo_group') {
1194
  return (source.members || []).map(m => ({
1195
  kind: 'video', source_key: m.source_key, label: m.label,
1196
+ stream_url: m.stream_url, video_cache_key: m.video_cache_key,
1197
+ frame_count: m.frame_count, fps: m.fps,
1198
  }));
1199
  }
1200
  return [{ ...source }];
 
1296
  groupedSourceKey(entry.source) !== key);
1297
  currentDepthPreviewTiles = currentDepthPreviewTiles.filter(entry =>
1298
  groupedSourceKey(entry.source) !== key);
1299
+ currentSlamTiles = currentSlamTiles.filter(entry =>
1300
+ groupedSourceKey(entry.source) !== key);
1301
+ currentTactileTiles = currentTactileTiles.filter(entry =>
1302
+ groupedSourceKey(entry.source) !== key);
1303
  workspaceSources = workspaceSources.filter(item => groupedSourceKey(item) !== key);
1304
  renderSourceBar();
1305
  renderPreviewVideoSources();
 
1507
  depth_codes_url: source.depth_codes_url,
1508
  raw_depth_url: source.raw_depth_url,
1509
  depth_cache_key: source.depth_cache_key,
1510
+ video_cache_key: source.video_cache_key,
1511
  // Optional grayscale clock stream; never used as depth pixels.
1512
  depth_video_url: source.depth_video_url,
1513
  missing_frames: source.missing_frames,
 
1523
  return urlTemplate.replace('{frame}', Math.max(0, frameIndex));
1524
  }
1525
 
1526
+ function _tactileForceAt(frame, side) {
1527
+ const map = tactileMatrixData?._forceByFrame;
1528
+ const value = map && map[String(Math.max(0, Number(frame) || 0))]?.[side];
1529
+ if (!Array.isArray(value) || value.length < 3
1530
+ || !value.slice(0, 3).every(item => Number.isFinite(Number(item)))) {
1531
+ return null;
1532
+ }
1533
+ const fx = Number(value[0]);
1534
+ const fy = Number(value[1]);
1535
+ const fz = Number(value[2]);
1536
+ return { fx, fy, fz, magnitude: Math.hypot(fx, fy, fz) };
1537
+ }
1538
+
1539
+ function _updateTactileForceTile(entry, frame) {
1540
+ if (!entry) return;
1541
+ const force = _tactileForceAt(frame, entry.source.side);
1542
+ if (entry.forceValue) {
1543
+ if (!force) {
1544
+ entry.forceValue.textContent = 'Fx — · Fy — · Fz —';
1545
+ entry.forceValue.title = 'Force data unavailable';
1546
+ } else {
1547
+ entry.forceValue.textContent =
1548
+ `Fx ${Math.round(force.fx).toLocaleString()} · `
1549
+ + `Fy ${Math.round(force.fy).toLocaleString()} · `
1550
+ + `Fz ${Math.round(force.fz).toLocaleString()}`;
1551
+ entry.forceValue.title =
1552
+ `fx ${force.fx.toFixed(1)} · fy ${force.fy.toFixed(1)} · `
1553
+ + `fz ${force.fz.toFixed(1)} mN`;
1554
+ }
1555
+ }
1556
+ _renderTactileForceCurve(entry, frame);
1557
+ }
1558
+
1559
+ // Fx/Fy/Fz history for one side across the whole episode, with a playhead at
1560
+ // the current frame. Colours follow the acquisition-side ForceCurveWidget.
1561
+ const TACTILE_FORCE_COLORS = ['#4fc3f7', '#aed581', '#ff8a65'];
1562
+
1563
+ function _forceSeriesIndex(frame) {
1564
+ const series = tactileMatrixData?.force_series;
1565
+ if (!series || !Array.isArray(series.frames)) return null;
1566
+ if (!tactileMatrixData._frameIndex) {
1567
+ const map = new Map();
1568
+ series.frames.forEach((value, index) => map.set(Number(value), index));
1569
+ tactileMatrixData._frameIndex = map;
1570
+ }
1571
+ const index = tactileMatrixData._frameIndex.get(Math.round(Number(frame)));
1572
+ return index === undefined ? null : index;
1573
+ }
1574
+
1575
+ function _renderTactileForceCurve(entry, frame) {
1576
+ const canvas = entry?.curveCanvas;
1577
+ if (!canvas) return;
1578
+ const rect = canvas.getBoundingClientRect();
1579
+ const width = Math.max(1, rect.width), height = Math.max(1, rect.height);
1580
+ if (rect.width < 2 || rect.height < 2) {
1581
+ // The tile is built before it is attached to the workspace, so the
1582
+ // first call can run before layout. Retry once the layout exists.
1583
+ if (!entry._curveRetry) {
1584
+ entry._curveRetry = true;
1585
+ requestAnimationFrame(() => {
1586
+ entry._curveRetry = false;
1587
+ _renderTactileForceCurve(entry, frame);
1588
+ });
1589
+ }
1590
+ return;
1591
+ }
1592
+ const dpr = window.devicePixelRatio || 1;
1593
+ if (canvas.width !== Math.round(width * dpr)
1594
+ || canvas.height !== Math.round(height * dpr)) {
1595
+ canvas.width = Math.round(width * dpr);
1596
+ canvas.height = Math.round(height * dpr);
1597
+ }
1598
+ const ctx = canvas.getContext('2d');
1599
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1600
+ ctx.clearRect(0, 0, width, height);
1601
+
1602
+ const series = tactileMatrixData?.force_series?.[entry.source.side];
1603
+ if (!Array.isArray(series) || !series.length) return;
1604
+
1605
+ const pad = { l: 4, r: 4, t: 8, b: 4 };
1606
+ const innerW = width - pad.l - pad.r;
1607
+ const innerH = height - pad.t - pad.b;
1608
+ // Symmetric auto range: p99.5 of |value| rounded up to 500 mN, so the
1609
+ // fixed +/-3000 mN range of the acquisition widget does not clip our data.
1610
+ if (!entry._curveRange) {
1611
+ const samples = [];
1612
+ series.forEach(value => {
1613
+ if (!value) return;
1614
+ for (let axis = 0; axis < 3; axis++) {
1615
+ const item = Math.abs(Number(value[axis]));
1616
+ if (Number.isFinite(item)) samples.push(item);
1617
+ }
1618
+ });
1619
+ samples.sort((a, b) => a - b);
1620
+ const p995 = samples.length
1621
+ ? samples[Math.min(samples.length - 1, Math.floor(samples.length * 0.995))]
1622
+ : 0;
1623
+ entry._curveRange = Math.max(500, Math.ceil(p995 / 500) * 500);
1624
+ }
1625
+ const range = entry._curveRange;
1626
+ const mid = pad.t + innerH / 2;
1627
+ const Y = value => mid - (value / range) * (innerH / 2) * 0.92;
1628
+ const X = index => pad.l + (series.length <= 1
1629
+ ? 0 : (index / (series.length - 1)) * innerW);
1630
+
1631
+ ctx.lineWidth = 1;
1632
+ ctx.strokeStyle = 'rgba(148,163,184,0.16)';
1633
+ [0.5, -0.5].forEach(frac => {
1634
+ const y = Y(frac * range);
1635
+ ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(width - pad.r, y); ctx.stroke();
1636
+ });
1637
+ ctx.strokeStyle = 'rgba(148,163,184,0.38)';
1638
+ ctx.beginPath();
1639
+ ctx.moveTo(pad.l, mid); ctx.lineTo(width - pad.r, mid);
1640
+ ctx.stroke();
1641
+
1642
+ for (let axis = 0; axis < 3; axis++) {
1643
+ ctx.strokeStyle = TACTILE_FORCE_COLORS[axis];
1644
+ ctx.lineWidth = 1.2;
1645
+ ctx.beginPath();
1646
+ let started = false;
1647
+ series.forEach((value, index) => {
1648
+ const item = value ? Number(value[axis]) : NaN;
1649
+ if (!Number.isFinite(item)) { started = false; return; }
1650
+ const x = X(index), y = Y(item);
1651
+ if (!started) { ctx.moveTo(x, y); started = true; }
1652
+ else { ctx.lineTo(x, y); }
1653
+ });
1654
+ ctx.stroke();
1655
+ }
1656
+
1657
+ const headIndex = _forceSeriesIndex(frame);
1658
+ if (headIndex !== null) {
1659
+ const head = X(headIndex);
1660
+ ctx.strokeStyle = 'rgba(248,250,252,0.85)';
1661
+ ctx.lineWidth = 1;
1662
+ ctx.beginPath();
1663
+ ctx.moveTo(head, pad.t - 3); ctx.lineTo(head, height - pad.b);
1664
+ ctx.stroke();
1665
+ }
1666
+ }
1667
+
1668
+ function _createTactileWebGLRenderer(canvas) {
1669
+ const gl = canvas.getContext('webgl2', {
1670
+ alpha: false, antialias: false, premultipliedAlpha: false,
1671
+ });
1672
+ if (!gl) return null;
1673
+ const vertexSource = `#version 300 es
1674
+ in vec2 a_position;
1675
+ out vec2 v_uv;
1676
+ void main() {
1677
+ v_uv = a_position * 0.5 + 0.5;
1678
+ gl_Position = vec4(a_position, 0.0, 1.0);
1679
+ }`;
1680
+ const fragmentSource = `#version 300 es
1681
+ precision highp float;
1682
+ uniform sampler2D u_force_field;
1683
+ uniform float u_zoom;
1684
+ uniform vec2 u_pan;
1685
+ in vec2 v_uv;
1686
+ out vec4 out_color;
1687
+ vec3 jet(float x) {
1688
+ return clamp(vec3(
1689
+ 1.5 - abs(4.0 * x - 3.0),
1690
+ 1.5 - abs(4.0 * x - 2.0),
1691
+ 1.5 - abs(4.0 * x - 1.0)
1692
+ ), 0.0, 1.0);
1693
+ }
1694
+ void main() {
1695
+ // Zoom/pan happen in screen-uv space; the field itself is 250x250.
1696
+ vec2 uv = (v_uv - 0.5) / u_zoom + 0.5 + u_pan;
1697
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
1698
+ out_color = vec4(0.0039, 0.0039, 0.502, 1.0);
1699
+ return;
1700
+ }
1701
+ // Matrix row 0 is the top of the sensor; WebGL texture v=0 is
1702
+ // the bottom, so flip only the sampling coordinate. The field is
1703
+ // uploaded already normalised to 0..1 (R8), so no rescaling here.
1704
+ float value = texture(u_force_field, vec2(uv.x, 1.0 - uv.y)).r;
1705
+ out_color = vec4(jet(clamp(value, 0.0, 1.0)), 1.0);
1706
+ }`;
1707
+ const compile = (type, source) => {
1708
+ const shader = gl.createShader(type);
1709
+ gl.shaderSource(shader, source);
1710
+ gl.compileShader(shader);
1711
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1712
+ console.warn('[player] tactile WebGL shader failed',
1713
+ gl.getShaderInfoLog(shader));
1714
+ gl.deleteShader(shader);
1715
+ return null;
1716
+ }
1717
+ return shader;
1718
+ };
1719
+ const vertex = compile(gl.VERTEX_SHADER, vertexSource);
1720
+ const fragment = compile(gl.FRAGMENT_SHADER, fragmentSource);
1721
+ if (!vertex || !fragment) return null;
1722
+ const program = gl.createProgram();
1723
+ gl.attachShader(program, vertex);
1724
+ gl.attachShader(program, fragment);
1725
+ gl.linkProgram(program);
1726
+ gl.deleteShader(vertex);
1727
+ gl.deleteShader(fragment);
1728
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1729
+ console.warn('[player] tactile WebGL program failed',
1730
+ gl.getProgramInfoLog(program));
1731
+ gl.deleteProgram(program);
1732
+ return null;
1733
+ }
1734
+ const position = gl.createBuffer();
1735
+ gl.bindBuffer(gl.ARRAY_BUFFER, position);
1736
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
1737
+ -1, -1, 1, -1, -1, 1, 1, 1,
1738
+ ]), gl.STATIC_DRAW);
1739
+ const texture = gl.createTexture();
1740
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1741
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1742
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1743
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1744
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1745
+ return {
1746
+ gl, program, position, texture,
1747
+ positionLocation: gl.getAttribLocation(program, 'a_position'),
1748
+ fieldLocation: gl.getUniformLocation(program, 'u_force_field'),
1749
+ zoomLocation: gl.getUniformLocation(program, 'u_zoom'),
1750
+ panLocation: gl.getUniformLocation(program, 'u_pan'),
1751
+ field: null,
1752
+ zoom: 1, pan: [0, 0], filter: null,
1753
+ render(field = null, min = 0, max = 1) {
1754
+ if (field) {
1755
+ // R32F float textures are not filterable without
1756
+ // OES_texture_float_linear; without it every sample returns 0
1757
+ // and the whole tile renders as JET(0) - a flat dark blue that
1758
+ // is indistinguishable from the background. Normalising to
1759
+ // 8-bit here keeps the texture in the always-filterable R8
1760
+ // format, so LINEAR/NEAREST both work on every driver.
1761
+ if (field instanceof Float32Array) {
1762
+ const lo = Number.isFinite(Number(min)) ? Number(min) : 0;
1763
+ const hi = Number.isFinite(Number(max)) ? Number(max) : 1;
1764
+ const span = Math.max(hi - lo, 1e-6);
1765
+ const bytes = new Uint8Array(field.length);
1766
+ for (let i = 0; i < field.length; i++) {
1767
+ const value = (field[i] - lo) / span;
1768
+ bytes[i] = value <= 0 ? 0
1769
+ : value >= 1 ? 255 : Math.round(value * 255);
1770
+ }
1771
+ this.field = bytes;
1772
+ } else {
1773
+ this.field = field;
1774
+ }
1775
+ }
1776
+ const rect = canvas.getBoundingClientRect();
1777
+ const dpr = window.devicePixelRatio || 1;
1778
+ const width = Math.max(1, Math.round(rect.width * dpr));
1779
+ const height = Math.max(1, Math.round(rect.height * dpr));
1780
+ if (canvas.width !== width || canvas.height !== height) {
1781
+ canvas.width = width;
1782
+ canvas.height = height;
1783
+ }
1784
+ gl.viewport(0, 0, width, height);
1785
+ gl.clearColor(0.0039, 0.0039, 0.502, 1.0);
1786
+ gl.clear(gl.COLOR_BUFFER_BIT);
1787
+ if (!this.field) return;
1788
+ gl.useProgram(program);
1789
+ gl.bindBuffer(gl.ARRAY_BUFFER, position);
1790
+ gl.enableVertexAttribArray(this.positionLocation);
1791
+ gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0);
1792
+ gl.activeTexture(gl.TEXTURE0);
1793
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1794
+ // One texel is one sensor cell. Sample NEAREST as soon as a cell
1795
+ // covers at least one screen pixel (crisp cells, like the
1796
+ // acquisition demo), and LINEAR only when shrinking - a NEAREST
1797
+ // minification would drop isolated contact cells entirely.
1798
+ const texelsPerPixel = (width / 250) * (this.zoom || 1);
1799
+ const filter = texelsPerPixel >= 1 ? gl.NEAREST : gl.LINEAR;
1800
+ if (this.filter !== filter) {
1801
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
1802
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
1803
+ this.filter = filter;
1804
+ }
1805
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1806
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); // 250-wide R8 rows
1807
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, 250, 250, 0,
1808
+ gl.RED, gl.UNSIGNED_BYTE, this.field);
1809
+ gl.uniform1i(this.fieldLocation, 0);
1810
+ gl.uniform1f(this.zoomLocation, this.zoom || 1);
1811
+ gl.uniform2f(this.panLocation,
1812
+ this.pan ? this.pan[0] : 0, this.pan ? this.pan[1] : 0);
1813
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
1814
+ },
1815
+ };
1816
+ }
1817
+
1818
+ // Wheel zoom / drag pan / double-click reset for one tactile matrix tile.
1819
+ // One texture texel is one sensor cell, so zooming in reveals the individual
1820
+ // contact cells that the whole-field view averages away (same interaction as
1821
+ // the acquisition-side demo viewer).
1822
+ function _tactileScreenUv(canvas, clientX, clientY) {
1823
+ const rect = canvas.getBoundingClientRect();
1824
+ return [
1825
+ (clientX - rect.left) / Math.max(1, rect.width),
1826
+ 1 - (clientY - rect.top) / Math.max(1, rect.height),
1827
+ ];
1828
+ }
1829
+
1830
+ function _clampTactileView(renderer) {
1831
+ const limit = Math.max(0, (1 - 1 / renderer.zoom) / 2);
1832
+ renderer.pan[0] = Math.max(-limit, Math.min(limit, renderer.pan[0]));
1833
+ renderer.pan[1] = Math.max(-limit, Math.min(limit, renderer.pan[1]));
1834
+ }
1835
+
1836
+ function initTactileView(entry) {
1837
+ const canvas = entry?.canvas;
1838
+ const renderer = entry?.renderer;
1839
+ if (!canvas || !renderer) return;
1840
+ const maxZoom = 12;
1841
+ canvas.addEventListener('wheel', event => {
1842
+ event.preventDefault();
1843
+ const [u, v] = _tactileScreenUv(canvas, event.clientX, event.clientY);
1844
+ const before = 1 / renderer.zoom;
1845
+ renderer.zoom = Math.max(1, Math.min(maxZoom,
1846
+ renderer.zoom * (event.deltaY < 0 ? 1.2 : 1 / 1.2)));
1847
+ const delta = before - 1 / renderer.zoom;
1848
+ renderer.pan[0] += (u - 0.5) * delta;
1849
+ renderer.pan[1] += (v - 0.5) * delta;
1850
+ _clampTactileView(renderer);
1851
+ canvas.style.cursor = renderer.zoom > 1 ? 'grab' : 'default';
1852
+ renderer.render();
1853
+ }, { passive: false });
1854
+ canvas.addEventListener('pointerdown', event => {
1855
+ if (event.button !== 0 || renderer.zoom <= 1) return;
1856
+ entry._panDrag = { x: event.clientX, y: event.clientY };
1857
+ canvas.style.cursor = 'grabbing';
1858
+ if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId);
1859
+ event.preventDefault();
1860
+ });
1861
+ canvas.addEventListener('pointermove', event => {
1862
+ if (!entry._panDrag) return;
1863
+ const rect = canvas.getBoundingClientRect();
1864
+ renderer.pan[0] -= (event.clientX - entry._panDrag.x)
1865
+ / Math.max(1, rect.width) / renderer.zoom;
1866
+ renderer.pan[1] += (event.clientY - entry._panDrag.y)
1867
+ / Math.max(1, rect.height) / renderer.zoom;
1868
+ entry._panDrag = { x: event.clientX, y: event.clientY };
1869
+ _clampTactileView(renderer);
1870
+ renderer.render();
1871
+ });
1872
+ const endPan = event => {
1873
+ if (!entry._panDrag) return;
1874
+ entry._panDrag = null;
1875
+ canvas.style.cursor = renderer.zoom > 1 ? 'grab' : 'default';
1876
+ if (event && canvas.hasPointerCapture
1877
+ && canvas.hasPointerCapture(event.pointerId)) {
1878
+ canvas.releasePointerCapture(event.pointerId);
1879
+ }
1880
+ };
1881
+ canvas.addEventListener('pointerup', endPan);
1882
+ canvas.addEventListener('pointercancel', endPan);
1883
+ canvas.addEventListener('dblclick', () => {
1884
+ renderer.zoom = 1;
1885
+ renderer.pan = [0, 0];
1886
+ canvas.style.cursor = 'default';
1887
+ renderer.render();
1888
+ });
1889
+ }
1890
+
1891
+ // One chunk = 96 frames of 250x250 R8 = 6 MB per request. The old code issued
1892
+ // one 250 KB request per frame per side (15 MB/s during playback), which
1893
+ // saturated the browser's socket buffers (net::ERR_NO_BUFFER_SPACE). Chunks
1894
+ // are cached in memory, so playback and scrubbing need no network at all.
1895
+ const TACTILE_CHUNK_FRAMES = 96;
1896
+ const TACTILE_FIELD_STRIDE = 250 * 250;
1897
+
1898
+ function _tactileChunkKey(frame) {
1899
+ return Math.floor(Math.max(0, frame) / TACTILE_CHUNK_FRAMES)
1900
+ * TACTILE_CHUNK_FRAMES;
1901
+ }
1902
+
1903
+ function _tactileFrameField(entry, frame) {
1904
+ const chunk = entry.chunks.get(_tactileChunkKey(frame));
1905
+ if (!chunk) return null;
1906
+ const index = Math.round(Number(frame)) - chunk.first;
1907
+ if (index < 0 || index >= chunk.count) return null;
1908
+ return chunk.bytes.subarray(
1909
+ index * TACTILE_FIELD_STRIDE, (index + 1) * TACTILE_FIELD_STRIDE);
1910
+ }
1911
+
1912
+ function _fetchTactileChunk(entry, chunkStart) {
1913
+ if (!entry?.renderer || entry.chunks.has(chunkStart)
1914
+ || entry.chunkInflight.has(chunkStart)) return;
1915
+ entry.chunkInflight.add(chunkStart);
1916
+ const episodeAtRequest = currentEpisodeId;
1917
+ const tokenAtRequest = _playbackSessionToken;
1918
+ const signal = getMediaLoadSignal();
1919
+ const url = `/api/v1/video/${currentEpisodeId}/tactile-matrix-range`
1920
+ + `?side=${entry.source.side}&start=${chunkStart}`
1921
+ + `&limit=${TACTILE_CHUNK_FRAMES}&v=202609091912-tactile-chunk-prefetch`;
1922
+ const options = { credentials: 'same-origin', cache: 'force-cache' };
1923
+ if (signal) options.signal = signal;
1924
+ fetch(url, options).then(response => {
1925
+ if (!response.ok) throw new Error(`tactile range failed: ${response.status}`);
1926
+ const first = Number(response.headers.get('X-Force-First'));
1927
+ const count = Number(response.headers.get('X-Force-Frames'));
1928
+ return response.arrayBuffer().then(buffer => ({ buffer, first, count }));
1929
+ }).then(({ buffer, first, count }) => {
1930
+ entry.chunkInflight.delete(chunkStart);
1931
+ if (!isCurrentPlaybackSession(episodeAtRequest, tokenAtRequest)
1932
+ || !entry.canvas.isConnected) return;
1933
+ if (!Number.isFinite(first) || !Number.isFinite(count) || count <= 0
1934
+ || buffer.byteLength < count * TACTILE_FIELD_STRIDE) return;
1935
+ entry.chunks.set(chunkStart, {
1936
+ first, count,
1937
+ bytes: new Uint8Array(buffer, 0, count * TACTILE_FIELD_STRIDE),
1938
+ });
1939
+ // Paint the frame the player is showing if it just arrived.
1940
+ if (entry.pendingFrame !== null && entry.pendingFrame !== undefined) {
1941
+ const field = _tactileFrameField(entry, entry.pendingFrame);
1942
+ if (field) {
1943
+ entry.renderer.render(field);
1944
+ entry.loadedFrame = entry.pendingFrame;
1945
+ }
1946
+ }
1947
+ // Keep one chunk of look-ahead warm so playback never waits on the
1948
+ // network; the server caches each chunk, so repeats are cheap.
1949
+ const next = chunkStart + TACTILE_CHUNK_FRAMES;
1950
+ if (!entry.frameCount || next < entry.frameCount) {
1951
+ _fetchTactileChunk(entry, next);
1952
+ }
1953
+ }).catch(error => {
1954
+ entry.chunkInflight.delete(chunkStart);
1955
+ if (error?.name !== 'AbortError'
1956
+ && isCurrentPlaybackSession(episodeAtRequest, tokenAtRequest)) {
1957
+ console.warn('[player] tactile chunk unavailable', error);
1958
+ }
1959
+ });
1960
+ }
1961
+
1962
+ function _loadTactileWebGLFrame(entry, frame) {
1963
+ if (!entry?.canvas || !entry.renderer || !entry.source) return;
1964
+ const targetFrame = Math.max(0, Math.round(Number(frame) || 0));
1965
+ entry.pendingFrame = targetFrame;
1966
+ const field = _tactileFrameField(entry, targetFrame);
1967
+ if (field) {
1968
+ entry.renderer.render(field);
1969
+ entry.loadedFrame = targetFrame;
1970
+ const next = _tactileChunkKey(targetFrame) + TACTILE_CHUNK_FRAMES;
1971
+ if (!entry.frameCount || next < entry.frameCount) {
1972
+ _fetchTactileChunk(entry, next);
1973
+ }
1974
+ return;
1975
+ }
1976
+ _fetchTactileChunk(entry, _tactileChunkKey(targetFrame));
1977
+ }
1978
+
1979
  async function mountGroupedSource(source, tile) {
1980
  const episodeAtMount = currentEpisodeId;
1981
+ const sessionTokenAtMount = _playbackSessionToken;
1982
  if (!tile || currentEpisodeId !== episodeAtMount) return;
1983
+ if (source.kind === 'slam_trajectory') {
1984
+ const canvas = document.createElement('canvas');
1985
+ canvas.className = 'w-full h-full block bg-black';
1986
+ tile.appendChild(canvas);
1987
+ const entry = {
1988
+ canvas, source, rotY: 0, rotX: 0.35, zoom: 1,
1989
+ pan: [0, 0, 0], dragging: false, dragMode: '',
1990
+ lastX: 0, lastY: 0, fit: null, _lastCam: null,
1991
+ };
1992
+ currentSlamTiles.push(entry);
1993
+ initSlamDrag(entry);
1994
+ if (!isCurrentPlaybackSession(episodeAtMount, sessionTokenAtMount)
1995
+ || !tile.isConnected) return;
1996
+ renderSlamTile(entry, 0);
1997
+ return;
1998
+ }
1999
  if (source.kind === 'hand3d_world') {
2000
  // 3D 手部世界坐标交互窗口:canvas 渲染 /hand-3d 逐帧数据
2001
  // (worker 只产 parquet,前端实时投影;拖拽旋转 + 滚轮缩放)
 
2011
  // Do not reveal the spatial canvas before its initial frame window is
2012
  // available. RGB/2D and 3D then enter the workspace together.
2013
  await _ensureHand3DInitialWindow(source.source_key || 'default');
2014
+ if (!isCurrentPlaybackSession(episodeAtMount, sessionTokenAtMount)
2015
+ || !tile.isConnected) return;
2016
  renderHand3DTile(entry, 0);
2017
  return;
2018
  }
2019
+ if (source.kind === 'tactile_force_left'
2020
+ || source.kind === 'tactile_force_right') {
2021
+ // One tile per side: pressure field on top, its Fx/Fy/Fz history
2022
+ // underneath. The acquisition-side GripperSideWidget stacks the same
2023
+ // way (heatmap above, force curve below).
2024
+ const holder = document.createElement('div');
2025
+ holder.className = 'w-full h-full min-h-0 flex flex-col';
2026
+ const matrixBox = document.createElement('div');
2027
+ matrixBox.className = 'relative flex-1 min-h-0';
2028
+ matrixBox.style.backgroundColor = 'rgb(1, 1, 128)';
2029
+ const canvas = document.createElement('canvas');
2030
+ canvas.className = 'w-full h-full block';
2031
+ canvas.setAttribute('aria-label',
2032
+ source.side === 'right' ? 'Right force matrix' : 'Left force matrix');
2033
+ matrixBox.appendChild(canvas);
2034
+ const forceValue = document.createElement('div');
2035
+ forceValue.className = 'absolute z-10 rounded text-white pointer-events-none';
2036
+ forceValue.style.cssText = 'top:6px;left:6px;padding:2px 6px;font-size:11px;'
2037
+ + 'background:rgba(0,0,0,.6);font-variant-numeric:tabular-nums;';
2038
+ forceValue.textContent = 'Fx — · Fy — · Fz —';
2039
+ matrixBox.appendChild(forceValue);
2040
+ const curveBox = document.createElement('div');
2041
+ curveBox.className = 'relative shrink-0';
2042
+ curveBox.style.cssText = 'height:92px;background:#0b1220;'
2043
+ + 'border-top:1px solid rgba(51,65,85,.5);';
2044
+ const curveCanvas = document.createElement('canvas');
2045
+ curveCanvas.className = 'w-full h-full block';
2046
+ curveBox.appendChild(curveCanvas);
2047
+ const curveCaption = document.createElement('div');
2048
+ curveCaption.className = 'absolute pointer-events-none';
2049
+ curveCaption.style.cssText = 'top:2px;left:6px;font-size:10px;color:#64748b;';
2050
+ curveCaption.textContent = '力 mN';
2051
+ curveBox.appendChild(curveCaption);
2052
+ holder.appendChild(matrixBox);
2053
+ holder.appendChild(curveBox);
2054
+ tile.appendChild(holder);
2055
+ const renderer = _createTactileWebGLRenderer(canvas);
2056
+ const entry = { canvas, renderer, source, lastFrame: -1, forceValue,
2057
+ curveCanvas, loadedFrame: -1, pendingFrame: null,
2058
+ chunks: new Map(), chunkInflight: new Set(),
2059
+ frameCount: Number(tactileMatrixData?.frame_count) || 0 };
2060
+ initTactileView(entry);
2061
+ canvas.title = '滚轮缩放 / 拖拽平移 / 双击复位';
2062
+ currentTactileTiles.push(entry);
2063
+ const frame = typeof currentFrameTarget === 'number' ? currentFrameTarget : 0;
2064
+ _updateTactileForceTile(entry, frame);
2065
+ if (renderer) {
2066
+ renderer.render();
2067
+ _loadTactileWebGLFrame(entry, frame);
2068
+ } else {
2069
+ forceValue.textContent = 'WebGL2 unavailable';
2070
+ }
2071
+ return;
2072
+ }
2073
  if (source.kind === 'depth' && source.depth_video_url) {
2074
  // Depth previews are browser-decodable 8-bit Turbo MP4s generated
2075
  // once per source from the canonical 12-bit stream (identical
2076
  // clamp/scale/colormap math as the former canvas renderer). Play
2077
  // them as native videos: instant open, native seek, and frame sync
2078
  // through the same master-drift loop as every other camera.
2079
+ const playbackUrl = await _preloadBrowserVideoAsset(
2080
+ source, source.depth_video_url, sessionTokenAtMount);
2081
+ if (!isCurrentPlaybackSession(episodeAtMount, sessionTokenAtMount)
2082
+ || !tile.isConnected) return;
2083
  const holder = document.createElement('div');
2084
  holder.id = `player-container-${groupedSourceDomId(source)}`;
2085
  holder.className = 'bg-black rounded overflow-hidden w-full h-full min-h-0 relative';
2086
  tile.appendChild(holder);
2087
+ const player = initPlayer(
2088
+ holder.id, playbackUrl || _browserVideoAssetUrl(
2089
+ source, source.depth_video_url));
2090
  if (player) players[groupedSourceKey(source)] = player;
2091
  return;
2092
  }
 
2130
  holder.id = `player-container-${groupedSourceDomId(source)}`;
2131
  holder.className = 'bg-black rounded overflow-hidden w-full h-full min-h-0';
2132
  tile.appendChild(holder);
2133
+ const sourceUrl = source.stream_url ||
2134
  await pickVideoUrl(episodeAtMount, source.source_key);
2135
+ const playbackUrl = await _preloadBrowserVideoAsset(
2136
+ source, sourceUrl, sessionTokenAtMount);
2137
+ if (!isCurrentPlaybackSession(episodeAtMount, sessionTokenAtMount)
2138
+ || !tile.isConnected) return;
2139
+ const player = initPlayer(holder.id, playbackUrl || sourceUrl);
2140
  if (player) players[groupedSourceKey(source)] = player;
2141
  // 手部骨骼 SVG 叠加层(只加在原始视频上;skeleton/渲染 tile 不加)
2142
  // The first keypoint window is part of the shared initial-frame barrier.
 
2158
 
2159
  let _lastImageTileFrame = -1;
2160
  function _refreshImageTilesAt(frame) {
2161
+ if (!currentImageTiles.length && !currentDepthPreviewTiles.length
2162
+ && !currentTactileTiles.length) return;
2163
  _refreshDepthTilesAt(frame);
2164
+ if (currentImageTiles.length && frame !== _lastImageTileFrame) {
2165
+ _lastImageTileFrame = frame;
2166
+ currentImageTiles.forEach((entry) => {
 
2167
  const { img, source } = entry;
2168
  // Hand/glove tiles are driven by heatmap.js from the preloaded
2169
  // frames-data payload; only template-based non-depth tiles are
 
2176
  url = source.heatmap_url || source.depth_preview_url || source.depth_url;
2177
  }
2178
  if (url) img.src = _frameUrl(url, frame);
2179
+ });
2180
+ }
2181
+ currentTactileTiles.forEach(entry => {
2182
+ if (!entry.canvas || entry.lastFrame === frame) return;
2183
+ entry.lastFrame = frame;
2184
+ _updateTactileForceTile(entry, frame);
2185
+ _loadTactileWebGLFrame(entry, frame);
2186
  });
2187
  }
2188
 
 
2343
  tile.style.alignSelf = 'center';
2344
  }
2345
 
2346
+ if (!source.kind.startsWith('tactile_force_')) {
2347
+ const label = document.createElement('span');
2348
+ label.className = 'absolute z-10 top-1 left-1 bg-black/70 text-gray-200 text-[10px] px-1.5 py-0.5 rounded';
2349
+ label.textContent = groupedSourceLabel(source);
2350
+ tile.appendChild(label);
2351
+ }
2352
 
2353
+ if (!source.kind.startsWith('tactile_force_')) {
2354
+ const remove = document.createElement('button');
2355
+ remove.type = 'button';
2356
+ remove.className = 'absolute z-10 top-1 right-1 bg-black/70 text-gray-300 hover:text-white text-xs w-5 h-5 rounded';
2357
+ remove.textContent = '\u00d7';
2358
+ remove.title = t('remove_source');
2359
+ remove.onclick = event => {
2360
+ event.stopPropagation();
2361
+ removeGroupedSource(source);
2362
+ };
2363
+ tile.appendChild(remove);
2364
+ }
2365
 
2366
  if (!movable) return tile;
2367
 
 
2721
  // Two fixed zones: a video area on top (free drag-to-reorder) and a
2722
  // heatmap area below (left-hand on the left column, right-hand on the
2723
  // right column — always, per requirement).
2724
+ const isImageSource = s => ['glove', 'depth', 'hand',
2725
+ 'tactile_force_left', 'tactile_force_right'].includes(s.kind);
2726
  const vids = workspaceSources.filter(s => !isImageSource(s));
2727
  const imgs = workspaceSources.filter(s => isImageSource(s));
2728
 
 
2766
  tile.style.minWidth = '0';
2767
  tile.style.width = '100%';
2768
  tile.style.height = '100%';
2769
+ if (!source.kind.startsWith('tactile_force_')) {
2770
+ const label = document.createElement('span');
2771
+ label.className = 'absolute z-10 top-1 left-1 bg-black/70 text-gray-200 text-[10px] px-1.5 py-0.5 rounded';
2772
+ label.textContent = groupedSourceLabel(source);
2773
+ tile.appendChild(label);
2774
+ }
2775
+ if (!source.kind.startsWith('tactile_force_')) {
2776
+ const remove = document.createElement('button');
2777
+ remove.type = 'button';
2778
+ remove.className = 'absolute z-10 top-1 right-1 bg-black/70 text-gray-300 hover:text-white text-xs w-5 h-5 rounded';
2779
+ remove.textContent = '×';
2780
+ remove.title = t('remove_source');
2781
+ remove.onclick = () => removeGroupedSource(source);
2782
+ tile.appendChild(remove);
2783
+ }
2784
  if (inVideoRow) {
2785
  // Drag to reorder videos (DOM-level swap — playback keeps running)
2786
  tile.draggable = true;
 
2881
  // 并已把 hand3d_world 加入 workspaceSources,整表重建不能冲掉
2882
  // (否则开关显示开启、窗口实际不存在 —— 竞态脱节 bug)
2883
  const keep = workspaceSources.filter(s =>
2884
+ s.kind === 'hand3d_world' || s.kind === 'slam_trajectory'
2885
+ || s.kind === 'depth' || s.kind === 'tactile_force_left'
2886
+ || s.kind === 'tactile_force_right');
2887
  // Depth is a first-class default preview source. It must be present in
2888
  // workspaceSources before renderGroupedWorkspace takes its mount Promise
2889
  // snapshot; adding it immediately after render creates a race where the
 
2899
  if (grp && (grp.members || []).length) {
2900
  workspaceSources = grp.members.map(m => ({
2901
  kind: 'video', source_key: m.source_key, label: m.label,
2902
+ stream_url: m.stream_url, video_cache_key: m.video_cache_key,
2903
+ frame_count: m.frame_count, fps: m.fps,
2904
  }));
2905
  workspaceSources.push(...retained);
2906
  return;
 
2909
  if (singles.length) {
2910
  workspaceSources = singles.map(single => ({
2911
  kind: 'video', source_key: single.source_key, label: single.label,
2912
+ stream_url: single.stream_url, video_cache_key: single.video_cache_key,
2913
+ frame_count: single.frame_count, fps: single.fps,
2914
  }));
2915
  }
2916
  workspaceSources.push(...retained);
 
2936
  currentImageTiles = [];
2937
  currentDepthPreviewTiles = [];
2938
  currentHand3dTiles = [];
2939
+ currentSlamTiles = [];
2940
+ currentTactileTiles = [];
2941
  hand3dData = null;
2942
  hand3dDataBySource = {};
2943
  hand3dFrameCache = { frame: -1, data: null, inflight: -1 };
2944
  hand3dFrameCacheBySource = {};
2945
  hand3dWindow = { start: -1, end: -1, frames: {}, inflight: false };
2946
  hand3dWindowBySource = {};
2947
+ slamData = null;
2948
+ slamFramesByIndex = {};
2949
+ slamWindowInflight = null;
2950
+ tactileMatrixData = null;
2951
  _depthPlaybackStall = null;
2952
  _hand3dViewAnchor = null;
2953
  _h3dBaseDist = null;
 
3010
  // building the workspace. The 3D tile itself then awaits its first data
3011
  // window together with each RGB overlay's first keypoint window.
3012
  const hand3dReady = loadHand3D(episodeId, loadToken);
3013
+ const slamReady = loadSlamTrajectory(episodeId, loadToken);
3014
+ const tactileReady = loadTactileMatrixMeta(episodeId, loadToken);
3015
 
3016
  const grid = document.getElementById('video-grid');
3017
  if (!grid) { hideVideoLoading(); return; }
 
3026
  if (!isCurrentPlaybackSession(episodeId, loadToken)) return; // 丢弃过期响应
3027
  if (res.ok) {
3028
  currentMediaGroups = await res.json();
3029
+ await Promise.all([hand3dReady, slamReady, tactileReady]);
3030
  if (!isCurrentPlaybackSession(episodeId, loadToken)) return;
3031
  renderSourceBar();
3032
  _defaultWorkspaceFromGroups();
3033
+ // SLAM is a first-class spatial source. Add it before the initial
3034
+ // workspace render so it enters the same mount barrier as RGB.
3035
+ if (typeof window.ensureSlamTrajectoryTile === 'function') {
3036
+ window.ensureSlamTrajectoryTile();
3037
+ }
3038
+ if (typeof window.ensureTactileMatrixTile === 'function') {
3039
+ window.ensureTactileMatrixTile();
3040
+ }
3041
  // Add the selected spatial source before renderGroupedWorkspace
3042
  // takes its Promise.all(mounted) snapshot.
3043
  if (typeof ensureHand3dWorldTile === 'function'
 
3122
  // can leave the global skeleton switch disabled for the next episode.
3123
  if (typeof destroyAllHandOverlays === 'function') destroyAllHandOverlays();
3124
  currentHand3dTiles = [];
3125
+ currentSlamTiles = [];
3126
  hand3dData = null;
3127
  hand3dDataBySource = {};
3128
  hand3dFrameCache = { frame: -1, data: null, inflight: -1 };
3129
  hand3dFrameCacheBySource = {};
3130
  hand3dWindow = { start: -1, end: -1, frames: {}, inflight: false };
3131
  hand3dWindowBySource = {};
3132
+ slamData = null;
3133
+ slamFramesByIndex = {};
3134
+ slamWindowInflight = null;
3135
  _depthPlaybackStall = null;
3136
  _hand3dViewAnchor = null;
3137
  _h3dBaseDist = null;
 
4090
  [0, 17], // 掌根
4091
  ];
4092
 
4093
+ async function loadSlamTrajectory(episodeId, sessionToken = null) {
4094
+ try {
4095
+ const signal = getMediaLoadSignal();
4096
+ const res = await fetch(
4097
+ `/api/v1/video/${episodeId}/slam-trajectory?max_points=10000`,
4098
+ signal ? { signal, cache: 'no-store' } : { cache: 'no-store' });
4099
+ if (!isCurrentPlaybackSession(episodeId, sessionToken)) return null;
4100
+ if (!res.ok) return null;
4101
+ const data = await res.json();
4102
+ if (!isCurrentPlaybackSession(episodeId, sessionToken)) return null;
4103
+ if (!data || data.available !== true) return null;
4104
+ slamData = data;
4105
+ slamFramesByIndex = {};
4106
+ (data.frames || data.trajectory || []).forEach(row => {
4107
+ const frame = Number(row && row.f);
4108
+ const pose = row && row.p;
4109
+ const valid = Array.isArray(pose) && pose.length >= 7
4110
+ && pose.slice(0, 7).every(value => Number.isFinite(Number(value)))
4111
+ && Math.hypot(...pose.slice(3, 7).map(Number)) >= 0.5;
4112
+ if (Number.isFinite(frame) && valid) slamFramesByIndex[frame] = row;
4113
+ });
4114
+ return data;
4115
+ } catch (_) {
4116
+ return null;
4117
+ }
4118
+ }
4119
+
4120
+ async function loadTactileMatrixMeta(episodeId, sessionToken = null) {
4121
+ try {
4122
+ const signal = getMediaLoadSignal();
4123
+ const res = await fetch(
4124
+ `/api/v1/video/${episodeId}/tactile-matrix-meta?v=202609091720-tactile-fetch-slam-axis`,
4125
+ signal ? { signal, cache: 'no-store' } : { cache: 'no-store' });
4126
+ if (!isCurrentPlaybackSession(episodeId, sessionToken)) return null;
4127
+ if (!res.ok) return null;
4128
+ const data = await res.json();
4129
+ if (!isCurrentPlaybackSession(episodeId, sessionToken)) return null;
4130
+ if (!data || data.available !== true) return null;
4131
+ // Scalar fx/fy/fz values are small enough to preload once per
4132
+ // episode. Keep them indexed by frame so scrubbing does not issue a
4133
+ // second parquet request for every tactile image.
4134
+ const series = data.force_series;
4135
+ const forceByFrame = Object.create(null);
4136
+ if (series && Array.isArray(series.frames)) {
4137
+ series.frames.forEach((frame, index) => {
4138
+ const key = String(Number(frame));
4139
+ forceByFrame[key] = {
4140
+ left: Array.isArray(series.left) ? series.left[index] : null,
4141
+ right: Array.isArray(series.right) ? series.right[index] : null,
4142
+ };
4143
+ });
4144
+ }
4145
+ data._forceByFrame = forceByFrame;
4146
+ tactileMatrixData = data;
4147
+ return data;
4148
+ } catch (_) {
4149
+ return null;
4150
+ }
4151
+ }
4152
+
4153
+ window.ensureSlamTrajectoryTile = function () {
4154
+ if (!slamData || slamData.available !== true) return;
4155
+ const source = {
4156
+ kind: 'slam_trajectory',
4157
+ source_key: 'slam_trajectory',
4158
+ label: 'SLAM',
4159
+ };
4160
+ const key = groupedSourceKey(source);
4161
+ if (workspaceSources.some(item => groupedSourceKey(item) === key)) return;
4162
+ workspaceSources.push(source);
4163
+ };
4164
+
4165
+ window.ensureTactileMatrixTile = function () {
4166
+ if (!tactileMatrixData || tactileMatrixData.available !== true) return;
4167
+ const sources = [
4168
+ { kind: 'tactile_force_left', source_key: 'tactile_force_left',
4169
+ side: 'left', label: 'Left' },
4170
+ { kind: 'tactile_force_right', source_key: 'tactile_force_right',
4171
+ side: 'right', label: 'Right' },
4172
+ ];
4173
+ sources.forEach(source => {
4174
+ const key = groupedSourceKey(source);
4175
+ if (!workspaceSources.some(item => groupedSourceKey(item) === key)) {
4176
+ workspaceSources.push(source);
4177
+ }
4178
+ });
4179
+ };
4180
+
4181
+ function _slamRows() {
4182
+ if (!slamData) return [];
4183
+ const rows = Array.isArray(slamData.trajectory) && slamData.trajectory.length
4184
+ ? slamData.trajectory : slamData.frames;
4185
+ return (rows || []).filter(row => row && Array.isArray(row.p)
4186
+ && row.p.length >= 7
4187
+ && row.p.slice(0, 3).every(value => Number.isFinite(Number(value))));
4188
+ }
4189
+
4190
+ function _slamSmoothRows() {
4191
+ if (!slamData) return [];
4192
+ if (Array.isArray(slamData._displayRows)) return slamData._displayRows;
4193
+ const rows = _slamRows();
4194
+ let previous = null;
4195
+ let previousFrame = null;
4196
+ const smoothed = rows.map(row => {
4197
+ const raw = row.p.slice(0, 3).map(Number);
4198
+ const frame = Number(row.f);
4199
+ const frameGap = previousFrame === null ? Infinity : frame - previousFrame;
4200
+ const canSmooth = previous && frameGap <= SLAM_DISPLAY_JOIN_GAP_FRAMES;
4201
+ const point = canSmooth
4202
+ ? raw.map((value, axis) => previous[axis]
4203
+ + SLAM_DISPLAY_SMOOTH_ALPHA * (value - previous[axis]))
4204
+ : raw;
4205
+ row._displayPosition = point;
4206
+ previous = point;
4207
+ previousFrame = frame;
4208
+ return row;
4209
+ });
4210
+ slamData._displayRows = smoothed;
4211
+ return smoothed;
4212
+ }
4213
+
4214
+ // The acquisition side records the S80M camera pose in a gravity-aligned
4215
+ // world frame: +Z is up (measured on real data - a physical turn of the
4216
+ // gripper rotates about world +Z, and the gripper's own up axis sits within
4217
+ // ~12 deg of it for the whole episode). The browser camera uses a right-handed
4218
+ // frame with Y up, so display=[source X, source Z, -source Y] keeps the
4219
+ // stored values untouched and puts world up on screen up.
4220
+ function _slamDisplayPoint(sourcePoint) {
4221
+ return [Number(sourcePoint[0]), Number(sourcePoint[2]), -Number(sourcePoint[1])];
4222
+ }
4223
+
4224
+ function _slamSourcePoint(row) {
4225
+ const point = Array.isArray(row && row._displayPosition)
4226
+ ? row._displayPosition : row && row.p;
4227
+ return point ? point.slice(0, 3).map(Number) : null;
4228
+ }
4229
+
4230
+ function _slamCanJoinRows(previousRow, row) {
4231
+ if (!previousRow || !row) return false;
4232
+ const gap = Number(row.f) - Number(previousRow.f);
4233
+ return Number.isFinite(gap) && gap <= SLAM_DISPLAY_JOIN_GAP_FRAMES;
4234
+ }
4235
+
4236
+ function _slamNearestRow(frame) {
4237
+ const target = Math.max(0, Math.round(Number(frame) || 0));
4238
+ const rows = _slamSmoothRows();
4239
+ if (!rows.length) return null;
4240
+ // If the current video frame has no valid pose, keep the last pose that
4241
+ // has already happened. Never select a future pose for the live marker.
4242
+ let best = null;
4243
+ rows.forEach(row => {
4244
+ const rowFrame = Number(row.f);
4245
+ if (rowFrame <= target && (!best || rowFrame > Number(best.f))) {
4246
+ best = row;
4247
+ }
4248
+ });
4249
+ return best;
4250
+ }
4251
+
4252
+ function _slamFit(rows) {
4253
+ const points = rows.map(row => _slamDisplayPoint(_slamSourcePoint(row)));
4254
+ const min = [Infinity, Infinity, Infinity];
4255
+ const max = [-Infinity, -Infinity, -Infinity];
4256
+ points.forEach(point => point.forEach((value, axis) => {
4257
+ min[axis] = Math.min(min[axis], value);
4258
+ max[axis] = Math.max(max[axis], value);
4259
+ }));
4260
+ const span = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2], 0.4);
4261
+ const center = min.map((value, axis) => (value + max[axis]) / 2);
4262
+ const gridStep = 0.2;
4263
+ const gridY = Math.floor((min[1] - 0.08) / gridStep) * gridStep;
4264
+ const half = Math.max(0.6, Math.ceil((span * 0.7) / gridStep) * gridStep);
4265
+ const fov = 45 * Math.PI / 180;
4266
+ const baseDist = Math.max(
4267
+ 1.2,
4268
+ Math.min(
4269
+ 6,
4270
+ span * 1.25 * SLAM_DISPLAY_CAMERA_DISTANCE_SCALE
4271
+ / Math.tan(fov / 2),
4272
+ ),
4273
+ );
4274
+ return { points, min, max, center, gridY, half, baseDist };
4275
+ }
4276
+
4277
+ function _slamRotateVector(pose, vector) {
4278
+ let qx = Number(pose[3]), qy = Number(pose[4]);
4279
+ let qz = Number(pose[5]), qw = Number(pose[6]);
4280
+ const norm = Math.hypot(qx, qy, qz, qw);
4281
+ if (![qx, qy, qz, qw].every(Number.isFinite) || norm < 1e-6) return vector;
4282
+ qx /= norm;
4283
+ qy /= norm;
4284
+ qz /= norm;
4285
+ qw /= norm;
4286
+ const tx = 2 * (qy * vector[2] - qz * vector[1]);
4287
+ const ty = 2 * (qz * vector[0] - qx * vector[2]);
4288
+ const tz = 2 * (qx * vector[1] - qy * vector[0]);
4289
+ return [
4290
+ vector[0] + qw * tx + qy * tz - qz * ty,
4291
+ vector[1] + qw * ty + qz * tx - qx * tz,
4292
+ vector[2] + qw * tz + qx * ty - qy * tx,
4293
+ ];
4294
+ }
4295
+
4296
+ function _slamGripperState(row) {
4297
+ const values = row && Array.isArray(row.g) ? row.g : null;
4298
+ if (!values || values.length < 1) return null;
4299
+ const percent = Number(values[0]);
4300
+ if (!Number.isFinite(percent)) return null;
4301
+ return {
4302
+ percent: Math.max(0, Math.min(100, percent)),
4303
+ gripped: Number(values[1]) >= 0.5,
4304
+ };
4305
+ }
4306
+
4307
+ function renderSlamTile(entry, frameOverride) {
4308
+ if (!entry || !entry.canvas) return;
4309
+ const canvas = entry.canvas;
4310
+ const rect = canvas.getBoundingClientRect();
4311
+ const dpr = window.devicePixelRatio || 1;
4312
+ const w = Math.max(1, rect.width), h = Math.max(1, rect.height);
4313
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
4314
+ canvas.width = Math.round(w * dpr);
4315
+ canvas.height = Math.round(h * dpr);
4316
+ }
4317
+ const ctx = canvas.getContext('2d');
4318
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
4319
+ ctx.clearRect(0, 0, w, h);
4320
+ ctx.fillStyle = 'rgba(3,7,18,0.95)';
4321
+ ctx.fillRect(0, 0, w, h);
4322
+
4323
+ const rows = _slamSmoothRows();
4324
+ if (!rows.length) return;
4325
+ if (!entry.fit) entry.fit = _slamFit(rows);
4326
+ const fit = entry.fit;
4327
+ const frame = typeof frameOverride === 'number'
4328
+ ? frameOverride : (typeof currentFrameTarget === 'number' ? currentFrameTarget : 0);
4329
+ const cam = _hand3dCam(
4330
+ fit.points, w, h, entry.rotY || 0, entry.rotX || 0.35,
4331
+ entry.zoom || 1, fit.baseDist, entry.pan || [0, 0, 0]);
4332
+ entry._lastCam = cam;
4333
+ const project = point => _projectHand3D(point, cam);
4334
+ const line = (a, b, color, width = 1) => {
4335
+ const pa = project(a), pb = project(b);
4336
+ if (!Number.isFinite(pa[0]) || !Number.isFinite(pa[1])
4337
+ || !Number.isFinite(pb[0]) || !Number.isFinite(pb[1])) return;
4338
+ ctx.strokeStyle = color;
4339
+ ctx.lineWidth = width;
4340
+ ctx.beginPath();
4341
+ ctx.moveTo(pa[0], pa[1]);
4342
+ ctx.lineTo(pb[0], pb[1]);
4343
+ ctx.stroke();
4344
+ };
4345
+
4346
+ // Ground grid: every square is exactly 0.2 m. Labels are intentionally
4347
+ // kept to one small unit marker so the spatial view stays uncluttered.
4348
+ const minX = fit.center[0] - fit.half;
4349
+ const maxX = fit.center[0] + fit.half;
4350
+ const minZ = fit.center[2] - fit.half;
4351
+ const maxZ = fit.center[2] + fit.half;
4352
+ for (let x = minX; x <= maxX + 0.001; x += 0.2) {
4353
+ line([x, fit.gridY, minZ], [x, fit.gridY, maxZ], 'rgba(75,85,99,0.55)');
4354
+ }
4355
+ for (let z = minZ; z <= maxZ + 0.001; z += 0.2) {
4356
+ line([minX, fit.gridY, z], [maxX, fit.gridY, z], 'rgba(75,85,99,0.55)');
4357
+ }
4358
+
4359
+ // Small axis marker at the fitted area: the recorded world axes, drawn in
4360
+ // the display frame that _slamDisplayPoint produces (world +Z is up).
4361
+ const axisOrigin = [fit.center[0], fit.gridY, fit.center[2]];
4362
+ const axisTips = [
4363
+ // World axes: X and Y horizontal, Z up.
4364
+ [[axisOrigin[0] + 0.2, axisOrigin[1], axisOrigin[2]], 'rgb(248,90,90)', 'X'],
4365
+ [[axisOrigin[0], axisOrigin[1], axisOrigin[2] - 0.2], 'rgb(80,220,130)', 'Y'],
4366
+ [[axisOrigin[0], axisOrigin[1] + 0.2, axisOrigin[2]], 'rgb(90,150,255)', 'Z'],
4367
+ ];
4368
+ axisTips.forEach(([tip, color, label]) => {
4369
+ line(axisOrigin, tip, color, 1.5);
4370
+ const projected = project(tip);
4371
+ if (!Number.isFinite(projected[0]) || !Number.isFinite(projected[1])) return;
4372
+ ctx.font = '10px sans-serif';
4373
+ ctx.fillStyle = color;
4374
+ ctx.fillText(label, projected[0] + 3, projected[1] - 3);
4375
+ });
4376
+ const axis = project(axisOrigin);
4377
+ if (Number.isFinite(axis[0]) && Number.isFinite(axis[1])) {
4378
+ ctx.font = '9px sans-serif';
4379
+ ctx.fillStyle = 'rgba(148,163,184,0.8)';
4380
+ ctx.fillText('0.2m', axis[0] + 5, axis[1] + 12);
4381
+ }
4382
+
4383
+ const current = Number(frame);
4384
+ let previous = null;
4385
+ let previousRow = null;
4386
+ rows.forEach(row => {
4387
+ const rowFrame = Number(row.f);
4388
+ if (rowFrame > current) return;
4389
+ const sourcePoint = _slamSourcePoint(row);
4390
+ const point = _slamDisplayPoint(sourcePoint);
4391
+ if (previous && _slamCanJoinRows(previousRow, row)) {
4392
+ line(previous, point, 'rgba(34,211,238,0.95)', 2);
4393
+ }
4394
+ previous = point;
4395
+ previousRow = row;
4396
+ });
4397
+
4398
+ const currentRow = _slamNearestRow(current);
4399
+ if (currentRow) {
4400
+ const pose = currentRow.p.map(Number);
4401
+ const sourcePoint = _slamSourcePoint(currentRow);
4402
+ const point = _slamDisplayPoint(sourcePoint);
4403
+ const projected = project(point);
4404
+ if (Number.isFinite(projected[0]) && Number.isFinite(projected[1])) {
4405
+ ctx.fillStyle = '#f8fafc';
4406
+ ctx.beginPath();
4407
+ ctx.arc(projected[0], projected[1], 4, 0, Math.PI * 2);
4408
+ ctx.fill();
4409
+ ctx.strokeStyle = '#06b6d4';
4410
+ ctx.lineWidth = 2;
4411
+ ctx.stroke();
4412
+ }
4413
+ const axisLength = 0.08;
4414
+ const colors = ['rgb(248,90,90)', 'rgb(80,220,130)', 'rgb(90,150,255)'];
4415
+ [[1, 0, 0], [0, 1, 0], [0, 0, 1]].forEach((vector, index) => {
4416
+ const rotated = _slamRotateVector(pose, vector);
4417
+ const displayVector = _slamDisplayPoint(rotated);
4418
+ line(point, [
4419
+ point[0] + displayVector[0] * axisLength,
4420
+ point[1] + displayVector[1] * axisLength,
4421
+ point[2] + displayVector[2] * axisLength,
4422
+ ], colors[index], 2);
4423
+ });
4424
+
4425
+ // Simplified UMI gripper model. The recorded pose frame is the S80M
4426
+ // camera frame; measured on real data the device axes sit at
4427
+ // +X = camera right (~world +Y), +Y = gripper up (~world +Z, the
4428
+ // camera is mounted inverted) and +Z = gripper forward (~world +X,
4429
+ // the optical axis). So the body extends along local +Z, the two
4430
+ // plates separate along local X, and each plate is 0.20m long
4431
+ // (forward) by 0.05m tall (up) with a 0.12m maximum opening.
4432
+ const grip = _slamGripperState(currentRow);
4433
+ if (grip) {
4434
+ const toDisplay = local => {
4435
+ const rotated = _slamRotateVector(pose, local);
4436
+ return _slamDisplayPoint([
4437
+ sourcePoint[0] + rotated[0],
4438
+ sourcePoint[1] + rotated[1],
4439
+ sourcePoint[2] + rotated[2],
4440
+ ]);
4441
+ };
4442
+ const plateLength = 0.20;
4443
+ const plateHeight = 0.05;
4444
+ const maxHalfGap = 0.06;
4445
+ const baseOffset = 0.10;
4446
+ const halfGap = maxHalfGap * (1 - grip.percent / 100);
4447
+ const jawColor = grip.gripped
4448
+ ? 'rgba(248,90,90,0.95)' : 'rgba(88,168,232,0.95)';
4449
+ const jawFill = grip.gripped
4450
+ ? 'rgba(248,90,90,0.16)' : 'rgba(88,168,232,0.12)';
4451
+ const relativeCorners = [
4452
+ [0, -plateHeight / 2, -plateLength / 2],
4453
+ [0, -plateHeight / 2, plateLength / 2],
4454
+ [0, plateHeight / 2, plateLength / 2],
4455
+ [0, plateHeight / 2, -plateLength / 2],
4456
+ ];
4457
+
4458
+ for (const sign of [1, -1]) {
4459
+ const centerX = sign * halfGap;
4460
+ const corners = relativeCorners.map(corner => project(toDisplay([
4461
+ centerX + corner[0],
4462
+ corner[1],
4463
+ baseOffset + corner[2],
4464
+ ])));
4465
+ if (corners.every(item => Number.isFinite(item[0]) && Number.isFinite(item[1]))) {
4466
+ ctx.fillStyle = jawFill;
4467
+ ctx.strokeStyle = jawColor;
4468
+ ctx.lineWidth = 2;
4469
+ ctx.beginPath();
4470
+ ctx.moveTo(corners[0][0], corners[0][1]);
4471
+ corners.slice(1).forEach(item => ctx.lineTo(item[0], item[1]));
4472
+ ctx.closePath();
4473
+ ctx.fill();
4474
+ ctx.stroke();
4475
+ }
4476
+ line(
4477
+ toDisplay([centerX, 0, baseOffset - plateLength / 2]),
4478
+ toDisplay([centerX, 0, baseOffset + plateLength / 2]),
4479
+ jawColor, 1);
4480
+ }
4481
+
4482
+ // Rear crossbar plus the two supports that tie each plate back to
4483
+ // the body, so the opening stays readable when it is small.
4484
+ line(toDisplay([-maxHalfGap, 0, baseOffset]),
4485
+ toDisplay([maxHalfGap, 0, baseOffset]),
4486
+ 'rgba(148,163,184,0.8)', 1.5);
4487
+ for (const sign of [1, -1]) {
4488
+ for (const cornerZ of [-plateLength / 2, plateLength / 2]) {
4489
+ line(
4490
+ toDisplay([sign * halfGap, 0, baseOffset + cornerZ]),
4491
+ toDisplay([0, 0, baseOffset + cornerZ]),
4492
+ 'rgba(148,163,184,0.8)', 1);
4493
+ }
4494
+ }
4495
+ }
4496
+ }
4497
+ }
4498
+
4499
+ function initSlamDrag(entry) {
4500
+ if (!entry || !entry.canvas) return;
4501
+ const canvas = entry.canvas;
4502
+ canvas.style.cursor = 'grab';
4503
+ canvas.addEventListener('mousedown', event => {
4504
+ if (event.button !== 0 && event.button !== 1) return;
4505
+ entry.dragging = true;
4506
+ entry.dragMode = event.button === 1 ? 'pan' : 'rotate';
4507
+ entry.lastX = event.clientX;
4508
+ entry.lastY = event.clientY;
4509
+ canvas.style.cursor = entry.dragMode === 'pan' ? 'move' : 'grabbing';
4510
+ event.preventDefault();
4511
+ });
4512
+ window.addEventListener('mousemove', event => {
4513
+ if (!entry.dragging) return;
4514
+ const dx = event.clientX - entry.lastX;
4515
+ const dy = event.clientY - entry.lastY;
4516
+ if (entry.dragMode === 'pan' && entry._lastCam) {
4517
+ const distance = Math.max(0.2, Math.hypot(
4518
+ entry._lastCam.eye[0] - entry._lastCam.centroid[0],
4519
+ entry._lastCam.eye[1] - entry._lastCam.centroid[1],
4520
+ entry._lastCam.eye[2] - entry._lastCam.centroid[2]));
4521
+ const worldPerPixel = distance / Math.max(1, entry._lastCam.f);
4522
+ const pan = entry.pan || (entry.pan = [0, 0, 0]);
4523
+ for (let i = 0; i < 3; i++) {
4524
+ pan[i] += (-dx * entry._lastCam.right[i]
4525
+ + dy * entry._lastCam.up[i]) * worldPerPixel;
4526
+ }
4527
+ } else {
4528
+ entry.rotY += dx * 0.01;
4529
+ entry.rotX = Math.max(-1.2, Math.min(1.2, entry.rotX + dy * 0.01));
4530
+ }
4531
+ entry.lastX = event.clientX;
4532
+ entry.lastY = event.clientY;
4533
+ renderSlamTile(entry);
4534
+ });
4535
+ window.addEventListener('mouseup', () => {
4536
+ if (!entry.dragging) return;
4537
+ entry.dragging = false;
4538
+ entry.dragMode = '';
4539
+ canvas.style.cursor = 'grab';
4540
+ });
4541
+ canvas.addEventListener('contextmenu', event => event.preventDefault());
4542
+ canvas.addEventListener('wheel', event => {
4543
+ event.preventDefault();
4544
+ entry.zoom = Math.min(6, Math.max(0.3,
4545
+ (entry.zoom || 1) * Math.exp(-event.deltaY * 0.0015)));
4546
+ renderSlamTile(entry);
4547
+ }, { passive: false });
4548
+ }
4549
+
4550
  async function loadHand3D(episodeId, sessionToken = null) {
4551
  try {
4552
  const signal = getMediaLoadSignal();
4553
+ const res = await fetch(
4554
+ `/api/v1/video/${episodeId}/hand-3d`,
4555
+ signal ? { signal, cache: 'no-store' } : { cache: 'no-store' });
4556
  if (!isCurrentPlaybackSession(episodeId, sessionToken)) return; // 丢弃过期响应
4557
  if (!res.ok) return;
4558
  const data = await res.json();
 
4972
  for (const slot of slots) {
4973
  const valid = slot.valid || [];
4974
  if (valid.length < 4) continue;
4975
+ // Ignore isolated depth outliers when choosing the fixed preview
4976
+ // scale. A single bad landmark in #3's first frame was metres
4977
+ // away from the hand and permanently made later frames tiny.
4978
+ const robustSpan = values => {
4979
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
4980
+ if (!sorted.length) return 0;
4981
+ const quantile = fraction => sorted[Math.floor(
4982
+ (sorted.length - 1) * fraction)];
4983
+ return quantile(0.95) - quantile(0.05);
4984
+ };
4985
+ spans.push(Math.max(
4986
+ robustSpan(valid.map(p => p[0])),
4987
+ robustSpan(valid.map(p => p[1])),
4988
+ robustSpan(valid.map(p => p[2])),
4989
+ ));
4990
  }
4991
  const measured = spans.length
4992
  ? spans.reduce((sum, value) => sum + value, 0) / spans.length : 0;
 
5018
  return scale;
5019
  }
5020
 
5021
+ // A few D435 frames contain landmark coordinates that jump metres away from
5022
+ // the wrist. Keep those points out of the 3D preview only. The parquet/API
5023
+ // values remain untouched, and a real hand landmark cannot be this far from
5024
+ // its wrist in the camera coordinate system.
5025
+ const HAND3D_WORLD_DISPLAY_MAX_HAND_RADIUS = 0.35;
5026
+
5027
+ function _filterDepthWorldDisplayOutliers(slots) {
5028
+ slots.forEach(slot => {
5029
+ const points = slot.pts || [];
5030
+ const wrist = points[0] || points.find(Boolean);
5031
+ if (!wrist) return;
5032
+
5033
+ let validCount = 0;
5034
+ let compactCount = 0;
5035
+ const maxRadius = HAND3D_WORLD_DISPLAY_MAX_HAND_RADIUS;
5036
+ const keep = points.map(point => {
5037
+ if (!point) return false;
5038
+ validCount += 1;
5039
+ const dx = point[0] - wrist[0];
5040
+ const dy = point[1] - wrist[1];
5041
+ const dz = point[2] - wrist[2];
5042
+ const isCompact = Math.hypot(dx, dy, dz) <= maxRadius;
5043
+ if (isCompact) compactCount += 1;
5044
+ return isCompact;
5045
+ });
5046
+
5047
+ // Do not blank a sparse hand just because it has one questionable
5048
+ // point. Only apply the filter when a usable compact skeleton remains.
5049
+ if (compactCount < 4 || compactCount === validCount) return;
5050
+ slot.pts = points.map((point, index) => point && keep[index] ? point : null);
5051
+ slot.fin = slot.pts.map(Boolean);
5052
+ slot.valid = slot.pts.filter(Boolean);
5053
+ });
5054
+ }
5055
+
5056
  function _hand3dJointRadius(proj, index, tip = false, styleScale = 1) {
5057
  if (!proj[index] || !Number.isFinite(proj[index][0])
5058
  || !Number.isFinite(proj[index][1])) return 0;
 
5341
  slots.forEach(s => all.push(...s.valid));
5342
  }
5343
  if (sourceData && sourceData.worldMode) {
5344
+ _filterDepthWorldDisplayOutliers(slots);
5345
+ all.length = 0;
5346
+ slots.forEach(s => all.push(...s.valid));
5347
  _normalizeDepthWorldDisplayScale(slots, entry, w, h);
5348
  all.length = 0;
5349
  slots.forEach(s => all.push(...s.valid));
 
5547
  标注高亮共用;闭包在调用时遍历,脚本加载时 tile 列表为空无影响) */
5548
  setOnFrameChange((f) => {
5549
  for (const entry of currentHand3dTiles) renderHand3DTile(entry, f);
5550
+ for (const entry of currentSlamTiles) renderSlamTile(entry, f);
5551
  });
5552
 
5553
  /* 图像素材(深度图/手套热力图)的逐帧 rAF 刷新循环(见 _startImageTileRaf) */
processor/web/static/workflow-studio/.vite/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "index.html": {
3
- "file": "assets/index-KixdmCX9.js",
4
  "name": "index",
5
  "src": "index.html",
6
  "isEntry": true,
 
1
  {
2
  "index.html": {
3
+ "file": "assets/index-CcWpS4e6.js",
4
  "name": "index",
5
  "src": "index.html",
6
  "isEntry": true,
processor/web/static/workflow-studio/assets/{index-KixdmCX9.js → index-CcWpS4e6.js} RENAMED
The diff for this file is too large to render. See raw diff
 
processor/web/static/workflow-studio/index.html CHANGED
@@ -6,7 +6,7 @@
6
  <title>Workflow Studio — EgoData</title>
7
  <script src="/static/iconify-preload.js"></script>
8
  <script src="/static/iconify-icon.min.js"></script>
9
- <script type="module" crossorigin src="/static/workflow-studio/assets/index-KixdmCX9.js"></script>
10
  <link rel="stylesheet" crossorigin href="/static/workflow-studio/assets/index-Byx6G-Zk.css">
11
  </head>
12
  <body class="bg-gray-950 text-gray-200">
 
6
  <title>Workflow Studio — EgoData</title>
7
  <script src="/static/iconify-preload.js"></script>
8
  <script src="/static/iconify-icon.min.js"></script>
9
+ <script type="module" crossorigin src="/static/workflow-studio/assets/index-CcWpS4e6.js"></script>
10
  <link rel="stylesheet" crossorigin href="/static/workflow-studio/assets/index-Byx6G-Zk.css">
11
  </head>
12
  <body class="bg-gray-950 text-gray-200">
processor/web/templates/index.html CHANGED
@@ -564,8 +564,8 @@
564
 
565
  <script src="/static/js/media-cache.js?v=202609081530"></script>
566
  <script src="/static/js/depth-renderer.js?v=202609081530"></script>
567
- <script src="/static/js/player.js?v=202609081530"></script>
568
- <script src="/static/js/hand-overlay.js?v=202609081530"></script>
569
  <script src="/static/js/heatmap.js?v=202609081530"></script>
570
  <script src="/static/js/annotations.js?v=202609081530"></script>
571
  <script src="/static/js/slice-preview.js?v=202609081530"></script>
 
564
 
565
  <script src="/static/js/media-cache.js?v=202609081530"></script>
566
  <script src="/static/js/depth-renderer.js?v=202609081530"></script>
567
+ <script src="/static/js/player.js?v=202609091912-tactile-chunk-prefetch"></script>
568
+ <script src="/static/js/hand-overlay.js?v=202609081630"></script>
569
  <script src="/static/js/heatmap.js?v=202609081530"></script>
570
  <script src="/static/js/annotations.js?v=202609081530"></script>
571
  <script src="/static/js/slice-preview.js?v=202609081530"></script>
processor/web/workflow-studio/src/components/WorkflowCanvas/NodePalette.tsx CHANGED
@@ -8,7 +8,7 @@ const LEGACY_INPUT_TYPES = ['rgb_camera', 'fisheye_camera'];
8
  // 新工作流始终从这组稳定的采集端分类开始。真实设备名称由工作流卡片
9
  // 内部的 source_key 控件从项目上传/心跳数据中选择,不作为调色板卡片。
10
  const FIXED_INPUT_TYPES = [
11
- 'glove_sensor', 'mono_camera', 'rgbd_camera', 'stereo_camera', 'stereo_rgbd_camera',
12
  ];
13
  // MediaPipe Hand is retained as a backend/old-workflow compatibility node;
14
  // the current hand modules are exposed through the canonical process cards.
 
8
  // 新工作流始终从这组稳定的采集端分类开始。真实设备名称由工作流卡片
9
  // 内部的 source_key 控件从项目上传/心跳数据中选择,不作为调色板卡片。
10
  const FIXED_INPUT_TYPES = [
11
+ 'gripper_device', 'glove_sensor', 'mono_camera', 'rgbd_camera', 'stereo_camera', 'stereo_rgbd_camera',
12
  ];
13
  // MediaPipe Hand is retained as a backend/old-workflow compatibility node;
14
  // the current hand modules are exposed through the canonical process cards.
processor/web/workflow-studio/src/components/WorkflowCanvas/WorkflowCanvas.tsx CHANGED
@@ -40,7 +40,13 @@ export function WorkflowCanvas() {
40
  }
41
  const sourceKeys = deviceSource?.source_keys || [];
42
  const sourceConfig = deviceSource
43
- ? (deviceSource.input_type === 'stereo_camera'
 
 
 
 
 
 
44
  || deviceSource.input_type === 'stereo_rgbd_camera')
45
  ? {
46
  source_keys: sourceKeys.join(','),
 
40
  }
41
  const sourceKeys = deviceSource?.source_keys || [];
42
  const sourceConfig = deviceSource
43
+ ? (deviceSource.input_type === 'gripper_device'
44
+ ? {
45
+ source_keys: sourceKeys.join(','),
46
+ source_key: deviceSource.source_key || sourceKeys[0] || '',
47
+ position: deviceSource.source_key || sourceKeys[0] || '',
48
+ }
49
+ : deviceSource.input_type === 'stereo_camera'
50
  || deviceSource.input_type === 'stereo_rgbd_camera')
51
  ? {
52
  source_keys: sourceKeys.join(','),
processor/web/workflow-studio/src/components/WorkflowCanvas/nodes/WorkflowNode.tsx CHANGED
@@ -11,6 +11,7 @@ const CAMERA_TYPES = [
11
  'mono_camera', 'rgbd_camera', 'rgb_camera', 'fisheye_camera',
12
  'stereo_camera', 'stereo_rgbd_camera',
13
  ];
 
14
  const DEPTH_PROCESS_TYPES = ['rgbd_to_3d_bare_hand', 'rgbd_to_3d_black_glove'];
15
 
16
  const DEVICE_CATEGORY_LABELS: Record<string, string> = {
@@ -19,6 +20,7 @@ const DEVICE_CATEGORY_LABELS: Record<string, string> = {
19
  mono_rgb: 'RGB Camera',
20
  stereo_rgb: 'Stereo RGB Camera',
21
  glove_sensor: 'Glove Sensor',
 
22
  };
23
 
24
  function cameraCategoryLabel(data: WorkflowNodeData): string {
@@ -30,6 +32,7 @@ function cameraCategoryLabel(data: WorkflowNodeData): string {
30
  if (data.nodeType === 'rgbd_camera') return DEVICE_CATEGORY_LABELS.rgbd_camera;
31
  if (data.nodeType === 'mono_camera' || data.nodeType === 'rgb_camera'
32
  || data.nodeType === 'fisheye_camera') return DEVICE_CATEGORY_LABELS.mono_rgb;
 
33
  const byType = data.device_type ? DEVICE_CATEGORY_LABELS[String(data.device_type)] : '';
34
  if (byType) return byType;
35
  const stored = String(data.device_display_name || '');
@@ -40,7 +43,7 @@ function cameraCategoryLabel(data: WorkflowNodeData): string {
40
  export const WorkflowNodeComponent = memo(function WorkflowNodeComponent({ data, selected, id }: NodeProps) {
41
  const d = data as unknown as WorkflowNodeData;
42
  const hdrColor = d.color || '#475569';
43
- const isCamera = CAMERA_TYPES.includes(d.nodeType);
44
  // 画布节点悬停说明:与侧边栏一致(功能 + 可连接性)
45
  const desc = getNodeType(d.nodeType)?.description;
46
  const topField = getTopConfigField(d);
@@ -145,7 +148,9 @@ export const WorkflowNodeComponent = memo(function WorkflowNodeComponent({ data,
145
  config.position = value;
146
  if (selectedSource) {
147
  config.device_name = selectedSource.name;
148
- if (selectedSource.input_type === 'stereo_camera'
 
 
149
  || selectedSource.input_type === 'stereo_rgbd_camera') {
150
  config.source_keys = selectedSource.source_keys.join(',');
151
  } else {
@@ -316,6 +321,7 @@ function sourceMatchesCameraType(
316
  if (nodeType === 'rgbd_camera') return category === 'rgbd_camera';
317
  if (nodeType === 'stereo_camera') return category === 'stereo_rgb';
318
  if (nodeType === 'glove_sensor') return category === 'glove_sensor';
 
319
  if (storedDeviceType && String(storedDeviceType) === 'rgbd_camera') {
320
  return category === 'rgbd_camera';
321
  }
@@ -326,7 +332,7 @@ function sourceMatchesCameraType(
326
 
327
  function getTopConfigField(data: WorkflowNodeData): ConfigField | null {
328
  const schema = data.configSchema || [];
329
- if (CAMERA_TYPES.includes(data.nodeType)) {
330
  return schema.find((field) => field.name === 'source_key') || {
331
  name: 'source_key', type: 'string', label: 'Source key',
332
  default: data.config?.source_key || data.config?.position || '',
 
11
  'mono_camera', 'rgbd_camera', 'rgb_camera', 'fisheye_camera',
12
  'stereo_camera', 'stereo_rgbd_camera',
13
  ];
14
+ const DEVICE_INPUT_TYPES = [...CAMERA_TYPES, 'gripper_device'];
15
  const DEPTH_PROCESS_TYPES = ['rgbd_to_3d_bare_hand', 'rgbd_to_3d_black_glove'];
16
 
17
  const DEVICE_CATEGORY_LABELS: Record<string, string> = {
 
20
  mono_rgb: 'RGB Camera',
21
  stereo_rgb: 'Stereo RGB Camera',
22
  glove_sensor: 'Glove Sensor',
23
+ gripper_device: 'UMI Gripper',
24
  };
25
 
26
  function cameraCategoryLabel(data: WorkflowNodeData): string {
 
32
  if (data.nodeType === 'rgbd_camera') return DEVICE_CATEGORY_LABELS.rgbd_camera;
33
  if (data.nodeType === 'mono_camera' || data.nodeType === 'rgb_camera'
34
  || data.nodeType === 'fisheye_camera') return DEVICE_CATEGORY_LABELS.mono_rgb;
35
+ if (data.nodeType === 'gripper_device') return DEVICE_CATEGORY_LABELS.gripper_device;
36
  const byType = data.device_type ? DEVICE_CATEGORY_LABELS[String(data.device_type)] : '';
37
  if (byType) return byType;
38
  const stored = String(data.device_display_name || '');
 
43
  export const WorkflowNodeComponent = memo(function WorkflowNodeComponent({ data, selected, id }: NodeProps) {
44
  const d = data as unknown as WorkflowNodeData;
45
  const hdrColor = d.color || '#475569';
46
+ const isCamera = DEVICE_INPUT_TYPES.includes(d.nodeType);
47
  // 画布节点悬停说明:与侧边栏一致(功能 + 可连接性)
48
  const desc = getNodeType(d.nodeType)?.description;
49
  const topField = getTopConfigField(d);
 
148
  config.position = value;
149
  if (selectedSource) {
150
  config.device_name = selectedSource.name;
151
+ if (selectedSource.input_type === 'gripper_device') {
152
+ config.source_keys = selectedSource.source_keys.join(',');
153
+ } else if (selectedSource.input_type === 'stereo_camera'
154
  || selectedSource.input_type === 'stereo_rgbd_camera') {
155
  config.source_keys = selectedSource.source_keys.join(',');
156
  } else {
 
321
  if (nodeType === 'rgbd_camera') return category === 'rgbd_camera';
322
  if (nodeType === 'stereo_camera') return category === 'stereo_rgb';
323
  if (nodeType === 'glove_sensor') return category === 'glove_sensor';
324
+ if (nodeType === 'gripper_device') return category === 'gripper_device';
325
  if (storedDeviceType && String(storedDeviceType) === 'rgbd_camera') {
326
  return category === 'rgbd_camera';
327
  }
 
332
 
333
  function getTopConfigField(data: WorkflowNodeData): ConfigField | null {
334
  const schema = data.configSchema || [];
335
+ if (DEVICE_INPUT_TYPES.includes(data.nodeType)) {
336
  return schema.find((field) => field.name === 'source_key') || {
337
  name: 'source_key', type: 'string', label: 'Source key',
338
  default: data.config?.source_key || data.config?.position || '',
processor/web/workflow-studio/src/components/WorkflowCanvas/nodes/registry.ts CHANGED
@@ -51,6 +51,14 @@ const CANONICAL_PORTS: Record<string, Pick<NodeTypeDescriptor, 'inputs' | 'outpu
51
  glove_sensor: {
52
  inputs: [], outputs: [{ key: 'sensor_data', label: 'Glove Sensor Data' }],
53
  },
 
 
 
 
 
 
 
 
54
  mediapipe_hand: {
55
  inputs: [{ key: 'video', label: 'RGB Video' }],
56
  outputs: [{ key: 'hand_keypoints', label: 'Hand 2D' }],
@@ -251,6 +259,19 @@ const BUILTIN: NodeTypeDescriptor[] = [
251
  outputs: [{ key: 'sensor_data', label: 'Glove Sensor Data' }],
252
  defaultConfig: { source_key: '', device: 'SenseGlove', hand: 'both', fps: 60 },
253
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  // ── Process ──
255
  {
256
  type: 'mediapipe_hand', category: 'process', label: 'MediaPipe Hand',
 
51
  glove_sensor: {
52
  inputs: [], outputs: [{ key: 'sensor_data', label: 'Glove Sensor Data' }],
53
  },
54
+ gripper_device: {
55
+ inputs: [], outputs: [
56
+ { key: 'rgb_video', label: 'RGB Video' },
57
+ { key: 'gripper_state', label: 'ESP Gripper State' },
58
+ { key: 'slam_trajectory', label: 'SLAM Trajectory' },
59
+ { key: 'tactile_force_matrices', label: 'Left and Right Force Matrices' },
60
+ ],
61
+ },
62
  mediapipe_hand: {
63
  inputs: [{ key: 'video', label: 'RGB Video' }],
64
  outputs: [{ key: 'hand_keypoints', label: 'Hand 2D' }],
 
259
  outputs: [{ key: 'sensor_data', label: 'Glove Sensor Data' }],
260
  defaultConfig: { source_key: '', device: 'SenseGlove', hand: 'both', fps: 60 },
261
  },
262
+ {
263
+ type: 'gripper_device', category: 'input', label: 'UMI Gripper',
264
+ icon: 'ant-design:robot-outlined', color: MODULE_COLORS.input,
265
+ description: 'Composite acquisition source. Publishes one gripper RGB video, ESP32 grip state, SLAM trajectory, and paired left/right 250×250×3 force matrices to downstream workflow nodes.',
266
+ inputs: [],
267
+ outputs: [
268
+ { key: 'rgb_video', label: 'RGB Video' },
269
+ { key: 'gripper_state', label: 'ESP Gripper State' },
270
+ { key: 'slam_trajectory', label: 'SLAM Trajectory' },
271
+ { key: 'tactile_force_matrices', label: 'Left and Right Force Matrices' },
272
+ ],
273
+ defaultConfig: { source_key: '', fps: 30 },
274
+ },
275
  // ── Process ──
276
  {
277
  type: 'mediapipe_hand', category: 'process', label: 'MediaPipe Hand',
processor/web/workflow-studio/src/store/workflowStore.ts CHANGED
@@ -110,11 +110,12 @@ const DEVICE_CATEGORY_LABELS: Record<string, string> = {
110
  mono_rgb: 'RGB Camera',
111
  stereo_rgb: 'Stereo RGB Camera',
112
  glove_sensor: 'Glove Sensor',
 
113
  };
114
 
115
  const CAMERA_INPUT_TYPES = new Set([
116
  'mono_camera', 'rgbd_camera', 'rgb_camera', 'fisheye_camera',
117
- 'stereo_camera', 'stereo_rgbd_camera',
118
  ]);
119
 
120
  function cameraSourceKeys(node: Node<WorkflowNodeData>): string[] {
@@ -236,6 +237,14 @@ function _migrateWorkflowGraph(graph: { nodes?: Node<WorkflowNodeData>[]; edges?
236
  { key: 'video_right', label: 'Right RGB Video' },
237
  { key: 'depth', label: 'Depth' },
238
  ];
 
 
 
 
 
 
 
 
239
  } else if (nodeType === 'rgb_to_2d_bare_hand'
240
  || nodeType === 'rgb_to_2d_black_glove') {
241
  data.inputs = [{ key: 'video', label: 'RGB Video' }];
@@ -264,6 +273,9 @@ function _migrateWorkflowGraph(graph: { nodes?: Node<WorkflowNodeData>[]; edges?
264
  : '';
265
  if (resultOutput && sourceHandle === 'result') {
266
  nextHandle = resultOutput;
 
 
 
267
  } else if (['rgb_to_2d_bare_hand', 'rgb_to_2d_black_glove'].includes(sourceType)
268
  && sourceHandle.startsWith('hand_3d')) {
269
  nextHandle = sourceHandle.replace(/^hand_3d/, 'hand_keypoints');
@@ -281,12 +293,24 @@ function _migrateWorkflowGraph(graph: { nodes?: Node<WorkflowNodeData>[]; edges?
281
  };
282
  });
283
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  // Existing local drafts may be loaded without a round trip through the API.
285
  // Keep their RGB-D graph equivalent to the server migration: an RGB-D input
286
  // card feeds both typed inputs when the old graph already had the video edge.
287
- const edgeKeys = new Set(edges.map((edge) =>
288
  `${edge.source}|${edge.target}|${edge.targetHandle || ''}`));
289
- for (const edge of [...edges]) {
290
  if (edge.targetHandle !== 'video'
291
  || !['rgbd_camera', 'stereo_rgbd_camera']
292
  .includes(sourceTypes.get(edge.source) || '')
@@ -296,7 +320,7 @@ function _migrateWorkflowGraph(graph: { nodes?: Node<WorkflowNodeData>[]; edges?
296
  }
297
  const key = `${edge.source}|${edge.target}|depth`;
298
  if (edgeKeys.has(key)) continue;
299
- edges.push({
300
  ...edge,
301
  id: `xy-edge__${edge.source}depth-${edge.target}depth`,
302
  sourceHandle: 'depth',
@@ -304,7 +328,7 @@ function _migrateWorkflowGraph(graph: { nodes?: Node<WorkflowNodeData>[]; edges?
304
  });
305
  edgeKeys.add(key);
306
  }
307
- return { nodes, edges };
308
  }
309
 
310
  /** 节点数据补齐:用注册表描述符填充缺失的 label/icon/color/端口/configSchema。
@@ -320,10 +344,11 @@ function _hydrateNode(node: Node<WorkflowNodeData>): Node<WorkflowNodeData> {
320
  'rgbd_to_3d_bare_hand', 'rgb_to_2d_bare_hand',
321
  'rgbd_to_3d_black_glove', 'rgb_to_2d_black_glove', 'annotation', 'ai_annotation',
322
  'human_review', 'ai_quality_review', 'lerobot_export', 'hdf5_export',
 
323
  ]);
324
  const controlledPorts = new Set([
325
  'rgb_camera', 'mono_camera', 'fisheye_camera', 'rgbd_camera',
326
- 'stereo_camera', 'stereo_rgbd_camera', 'glove_sensor', 'mediapipe_hand', 'annotation',
327
  'ai_annotation', 'human_review', 'ai_quality_review', 'lerobot_export',
328
  'hdf5_export',
329
  'rgbd_to_3d_bare_hand', 'rgb_to_2d_bare_hand',
@@ -414,7 +439,7 @@ export function isConnectionValid(
414
 
415
  // 视频类端口兼容:stereo_camera 输出 video_left/video_right,
416
  // mediapipe_hand 输入是 video —— 同属视频数据,应允许连接。
417
- const videoKeys = ['video', 'video_left', 'video_right'];
418
  if (videoKeys.includes(inp.key) && videoKeys.includes(out.key)) return true;
419
 
420
  return out.key === inp.key; // typed ports must match
 
110
  mono_rgb: 'RGB Camera',
111
  stereo_rgb: 'Stereo RGB Camera',
112
  glove_sensor: 'Glove Sensor',
113
+ gripper_device: 'UMI Gripper',
114
  };
115
 
116
  const CAMERA_INPUT_TYPES = new Set([
117
  'mono_camera', 'rgbd_camera', 'rgb_camera', 'fisheye_camera',
118
+ 'stereo_camera', 'stereo_rgbd_camera', 'gripper_device',
119
  ]);
120
 
121
  function cameraSourceKeys(node: Node<WorkflowNodeData>): string[] {
 
237
  { key: 'video_right', label: 'Right RGB Video' },
238
  { key: 'depth', label: 'Depth' },
239
  ];
240
+ } else if (nodeType === 'gripper_device') {
241
+ data.inputs = [];
242
+ data.outputs = [
243
+ { key: 'rgb_video', label: 'RGB Video' },
244
+ { key: 'gripper_state', label: 'ESP Gripper State' },
245
+ { key: 'slam_trajectory', label: 'SLAM Trajectory' },
246
+ { key: 'tactile_force_matrices', label: 'Left and Right Force Matrices' },
247
+ ];
248
  } else if (nodeType === 'rgb_to_2d_bare_hand'
249
  || nodeType === 'rgb_to_2d_black_glove') {
250
  data.inputs = [{ key: 'video', label: 'RGB Video' }];
 
273
  : '';
274
  if (resultOutput && sourceHandle === 'result') {
275
  nextHandle = resultOutput;
276
+ } else if (sourceType === 'gripper_device'
277
+ && ['tactile_left', 'tactile_right'].includes(sourceHandle)) {
278
+ nextHandle = 'tactile_force_matrices';
279
  } else if (['rgb_to_2d_bare_hand', 'rgb_to_2d_black_glove'].includes(sourceType)
280
  && sourceHandle.startsWith('hand_3d')) {
281
  nextHandle = sourceHandle.replace(/^hand_3d/, 'hand_keypoints');
 
293
  };
294
  });
295
 
296
+ // If both historical tactile ports targeted the same input, their merged
297
+ // handle represents one connection and must not produce a duplicate edge.
298
+ const seenGripperEdges = new Set<string>();
299
+ const dedupedEdges = edges.filter((edge) => {
300
+ if (sourceTypes.get(edge.source) !== 'gripper_device'
301
+ || edge.sourceHandle !== 'tactile_force_matrices') return true;
302
+ const key = `${edge.source}|${edge.sourceHandle}|${edge.target}|${edge.targetHandle || ''}`;
303
+ if (seenGripperEdges.has(key)) return false;
304
+ seenGripperEdges.add(key);
305
+ return true;
306
+ });
307
+
308
  // Existing local drafts may be loaded without a round trip through the API.
309
  // Keep their RGB-D graph equivalent to the server migration: an RGB-D input
310
  // card feeds both typed inputs when the old graph already had the video edge.
311
+ const edgeKeys = new Set(dedupedEdges.map((edge) =>
312
  `${edge.source}|${edge.target}|${edge.targetHandle || ''}`));
313
+ for (const edge of [...dedupedEdges]) {
314
  if (edge.targetHandle !== 'video'
315
  || !['rgbd_camera', 'stereo_rgbd_camera']
316
  .includes(sourceTypes.get(edge.source) || '')
 
320
  }
321
  const key = `${edge.source}|${edge.target}|depth`;
322
  if (edgeKeys.has(key)) continue;
323
+ dedupedEdges.push({
324
  ...edge,
325
  id: `xy-edge__${edge.source}depth-${edge.target}depth`,
326
  sourceHandle: 'depth',
 
328
  });
329
  edgeKeys.add(key);
330
  }
331
+ return { nodes, edges: dedupedEdges };
332
  }
333
 
334
  /** 节点数据补齐:用注册表描述符填充缺失的 label/icon/color/端口/configSchema。
 
344
  'rgbd_to_3d_bare_hand', 'rgb_to_2d_bare_hand',
345
  'rgbd_to_3d_black_glove', 'rgb_to_2d_black_glove', 'annotation', 'ai_annotation',
346
  'human_review', 'ai_quality_review', 'lerobot_export', 'hdf5_export',
347
+ 'gripper_device',
348
  ]);
349
  const controlledPorts = new Set([
350
  'rgb_camera', 'mono_camera', 'fisheye_camera', 'rgbd_camera',
351
+ 'stereo_camera', 'stereo_rgbd_camera', 'glove_sensor', 'gripper_device', 'mediapipe_hand', 'annotation',
352
  'ai_annotation', 'human_review', 'ai_quality_review', 'lerobot_export',
353
  'hdf5_export',
354
  'rgbd_to_3d_bare_hand', 'rgb_to_2d_bare_hand',
 
439
 
440
  // 视频类端口兼容:stereo_camera 输出 video_left/video_right,
441
  // mediapipe_hand 输入是 video —— 同属视频数据,应允许连接。
442
+ const videoKeys = ['video', 'rgb_video', 'video_left', 'video_right'];
443
  if (videoKeys.includes(inp.key) && videoKeys.includes(out.key)) return true;
444
 
445
  return out.key === inp.key; // typed ports must match
processor/web/workflow-studio/src/types/workflow.ts CHANGED
@@ -104,7 +104,7 @@ export interface DeviceInputSource {
104
  /** Standardized UI name; name remains the raw compatibility identifier. */
105
  display_name?: string;
106
  kind?: string;
107
- device_type?: 'rgbd_camera' | 'stereo_rgbd_camera' | 'mono_rgb' | 'stereo_rgb' | 'glove_sensor' | string;
108
  lens?: string;
109
  label?: string;
110
  input_type: string;
 
104
  /** Standardized UI name; name remains the raw compatibility identifier. */
105
  display_name?: string;
106
  kind?: string;
107
+ device_type?: 'rgbd_camera' | 'stereo_rgbd_camera' | 'mono_rgb' | 'stereo_rgb' | 'glove_sensor' | 'gripper_device' | string;
108
  lens?: string;
109
  label?: string;
110
  input_type: string;