Spaces:
Running
Running
sync processor from GitLab ac3323f: 深度格式兼容(legacy_mm10+逐批次覆盖)+ UMI Gripper 模块与测试
Browse files- processor/app/api/projects.py +35 -0
- processor/app/artifact_resolver.py +11 -2
- processor/app/device_naming.py +9 -0
- processor/app/lerobot_v21.py +26 -3
- processor/app/main.py +1 -0
- processor/app/processing/batch.py +13 -2
- processor/app/processing/modules/gripper_device.py +191 -0
- processor/app/project_dataset.py +120 -4
- processor/app/routes/ingestion.py +16 -0
- processor/app/routes/session.py +57 -12
- processor/app/routes/video.py +686 -14
- processor/app/workflow_dispatch.py +9 -6
- processor/app/workflow_instantiate.py +21 -4
- processor/app/workflow_types.py +44 -0
- processor/scripts/ensure_stack_linux.sh +5 -2
- processor/tests/test_gripper_device.py +109 -0
- processor/tests/test_project_dataset.py +93 -0
- processor/tests/test_tactile_decode.py +27 -0
- processor/web/static/js/hand-overlay.js +23 -0
- processor/web/static/js/player.js +1286 -70
- processor/web/static/workflow-studio/.vite/manifest.json +1 -1
- processor/web/static/workflow-studio/assets/{index-KixdmCX9.js → index-CcWpS4e6.js} +0 -0
- processor/web/static/workflow-studio/index.html +1 -1
- processor/web/templates/index.html +2 -2
- processor/web/workflow-studio/src/components/WorkflowCanvas/NodePalette.tsx +1 -1
- processor/web/workflow-studio/src/components/WorkflowCanvas/WorkflowCanvas.tsx +7 -1
- processor/web/workflow-studio/src/components/WorkflowCanvas/nodes/WorkflowNode.tsx +9 -3
- processor/web/workflow-studio/src/components/WorkflowCanvas/nodes/registry.ts +21 -0
- processor/web/workflow-studio/src/store/workflowStore.ts +32 -7
- 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 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 == "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"] =
|
|
|
|
|
|
|
|
|
|
| 923 |
cameras = dict(target.get("cameras") or {})
|
| 924 |
cameras.update(dict(source_info.get("cameras") or {}))
|
| 925 |
-
target["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"] =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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(
|
| 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
|
| 394 |
)
|
| 395 |
longest_hits = [
|
| 396 |
p for p in prefix_hits
|
| 397 |
if any(
|
| 398 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
| 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":
|
| 651 |
-
"project_name":
|
| 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
|
|
|
|
|
|
|
| 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 =
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 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 |
-
|
| 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
|
| 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
|
|
|
|
| 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"]
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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")
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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
|
| 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
|
| 821 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
}
|
| 823 |
|
| 824 |
-
function _restoreHand3DFull(cache, sourceKey) {
|
| 825 |
-
const saved = _hand3dFullCache.get(
|
| 826 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, {
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 ||
|
| 877 |
const payload = await res.json();
|
| 878 |
-
if (
|
|
|
|
|
|
|
| 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 |
-
|
| 886 |
-
|
|
|
|
|
|
|
| 887 |
window.EgoMediaCache.put(persistentKey, {
|
| 888 |
-
frames: payload.frames || [],
|
|
|
|
|
|
|
| 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'
|
|
|
|
| 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,
|
|
|
|
| 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 (
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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
|
| 1469 |
await pickVideoUrl(episodeAtMount, source.source_key);
|
| 1470 |
-
|
| 1471 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 1494 |
_refreshDepthTilesAt(frame);
|
| 1495 |
-
if (
|
| 1496 |
-
|
| 1497 |
-
|
| 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 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
|
|
|
|
|
|
|
| 1675 |
|
| 1676 |
-
|
| 1677 |
-
|
| 1678 |
-
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
-
|
| 1685 |
-
|
|
|
|
|
|
|
| 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'
|
|
|
|
| 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 |
-
|
| 2090 |
-
|
| 2091 |
-
|
| 2092 |
-
|
| 2093 |
-
|
| 2094 |
-
|
| 2095 |
-
|
| 2096 |
-
|
| 2097 |
-
|
| 2098 |
-
|
| 2099 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 === '
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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,
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 3809 |
-
|
| 3810 |
-
|
| 3811 |
-
|
| 3812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 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=
|
| 568 |
-
<script src="/static/js/hand-overlay.js?v=
|
| 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 === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 === '
|
|
|
|
|
|
|
| 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 (
|
| 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(
|
| 288 |
`${edge.source}|${edge.target}|${edge.targetHandle || ''}`));
|
| 289 |
-
for (const edge of [...
|
| 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 |
-
|
| 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;
|