Spaces:
Running
Running
Download processor/app/processing/modules/stereo_triangulate.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 33.6 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/modules/stereo_triangulate.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/processing/modules/stereo_triangulate.py
-
curl -L -o stereo_triangulate.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/modules/stereo_triangulate.py
33.6 kB
| """手部骨骼 — 手部 3D 骨骼识别(一体化模块,v1.3 world 方案)。 | |
| v1.3 起**不再做双目三角化**(米制 3D 效果不佳:鱼眼近距畸变 + 标定兜底 + | |
| 2D 抖动经三角化放大)。改为与 stereo_hands_demo_v1.0 同方案: | |
| 每路(左/右)独立 HandLandmarker 检测 → 2D 关键点 + 手部模型相对 3D | |
| + handedness;One-Euro 平滑(默认 5.0/0.05,槽位 + handedness 身份自愈); | |
| 再由本模块内置的 RGBWorldTracker 用 PnP/图像尺度估计相机相对位置, | |
| 并输出共享相机空间的 3D 骨架。关节角度基于手部模型坐标,渲染为左右 | |
| 目并排的 2D 五指分色骨架 + 角度数字(demo 风格,原画面无矫正)。 | |
| 数据先输出到 worker 临时目录的 hand_3d/hand_world.parquet,完成阶段合并到 | |
| 对应 episode parquet,列结构与统一协议 | |
| 3D 产物兼容(hand_0/1_present/label/landmarks_3d/confidence/ | |
| gesture/fingers);landmarks_3d 语义 = camera_relative。无深度时 unit | |
| 为 rgb_estimated_meters,明确表示 RGB 估计而非深度真值。 | |
| 无标定依赖;标定文件与三角化代码全部移除(历史可查 git / | |
| Temp/code_backup_20260814/)。 | |
| 参考: | |
| - stereo_hands_demo_v1.0:检测 + 平滑 + world 角度 + 渲染方案 | |
| - 21 关键点(MediaPipe 标准):Wrist(0)、拇指(1-4)、食指(5-8)、 | |
| 中指(9-12)、无名指(13-16)、小指(17-20) | |
| """ | |
| import json | |
| from pathlib import Path | |
| import numpy as np | |
| from app.processing import ProcessingModule, JobContext, ArtifactRef, field | |
| from app.processing.hand_render import FINGERS, draw_demo_style | |
| from app.processing.hand_slot_assignment import ( | |
| StableHandSlotAssigner, | |
| clip_normalized_xy, | |
| ) | |
| from app.processing.registry import register | |
| from app.processing.theme import HAND3D_COLOR | |
| _BLACK_GLOVE_ROOT = Path(__file__).resolve().parents[1] / "black_glove" | |
| def _resolve_hand_landmarker_model() -> Path: | |
| """Locate the bundled MediaPipe model across the supported layouts. | |
| The worker ships the model with its internal black-glove/D435 support | |
| package so the platform does not depend on sibling demo repositories. | |
| """ | |
| candidates = ( | |
| Path(__file__).resolve().parents[3] / "models" / "hand_landmarker.task", | |
| _BLACK_GLOVE_ROOT / "hand_landmarker.task", | |
| ) | |
| for candidate in candidates: | |
| if candidate.is_file() and candidate.stat().st_size > 0: | |
| return candidate | |
| searched = ", ".join(str(p) for p in candidates) | |
| raise FileNotFoundError(f"MediaPipe hand model not found; searched: {searched}") | |
| HAND_LANDMARKER_MODEL = _resolve_hand_landmarker_model() | |
| def _joint_specs(): | |
| specs = [] | |
| for finger, (ids, _c) in FINGERS.items(): | |
| a, b, c, d = ids | |
| names = ("CMC", "MCP", "IP") if finger == "Thumb" else ("MCP", "PIP", "DIP") | |
| specs.append((finger, names[0], a, 0, b)) | |
| specs.append((finger, names[1], b, a, c)) | |
| specs.append((finger, names[2], c, b, d)) | |
| return specs | |
| JOINT_SPECS = _joint_specs() | |
| def _angle_between(p_prev, p_vertex, p_next): | |
| """顶点处两条骨段的夹角(度)。180° = 伸直。""" | |
| v1 = np.asarray(p_prev, dtype=np.float64) - np.asarray(p_vertex, dtype=np.float64) | |
| v2 = np.asarray(p_next, dtype=np.float64) - np.asarray(p_vertex, dtype=np.float64) | |
| n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) | |
| if n1 < 1e-9 or n2 < 1e-9: | |
| return 0.0 | |
| cos = float(np.dot(v1, v2) / (n1 * n2)) | |
| return float(np.degrees(np.arccos(max(-1.0, min(1.0, cos))))) | |
| def _finger_gesture_from_world(world_pts) -> tuple[str, int, dict]: | |
| """world 3D 21 点 → (label, bitmask, 关节角度表)。角度基于 world 坐标 | |
| (视角无关,与 stereo_hands_demo 一致)。""" | |
| angles = {(f, j): _angle_between(world_pts[p], world_pts[v], world_pts[n]) | |
| for f, j, v, p, n in JOINT_SPECS} | |
| extended: list[str] = [] | |
| mask = 0 | |
| bits = {"Thumb": 1, "Index": 2, "Middle": 4, "Ring": 8, "Pinky": 16} | |
| for finger in FINGERS: | |
| if finger == "Thumb": | |
| ok = angles.get((finger, "MCP"), 0) > 145 and angles.get((finger, "IP"), 0) > 150 | |
| else: | |
| ok = angles.get((finger, "PIP"), 0) > 150 and angles.get((finger, "DIP"), 0) > 140 | |
| if ok: | |
| extended.append(finger.lower()) | |
| mask |= bits[finger] | |
| label = "fist" if not extended else "open:" + ",".join(extended) | |
| return label, mask, angles | |
| class _OneEuroFilter: | |
| """单值 One-Euro 自适应低通滤波器(与 demo 同款公式)。""" | |
| def __init__(self, freq_min=5.0, beta=0.05, dcutoff=1.0): | |
| self.freq_min = freq_min | |
| self.beta = beta | |
| self.dcutoff = dcutoff | |
| self._prev_x = None | |
| self._prev_dx = 0.0 | |
| self._prev_ts = None | |
| def _alpha(cutoff, dt): | |
| tau = 1.0 / (2.0 * np.pi * cutoff) if cutoff > 1e-9 else 0.0 | |
| return dt / (dt + tau) if tau > 0 else 1.0 | |
| def __call__(self, x, ts_ms): | |
| if self._prev_x is None or self._prev_ts is None: | |
| self._prev_x, self._prev_dx, self._prev_ts = x, 0.0, ts_ms | |
| return x | |
| dt = (ts_ms - self._prev_ts) / 1000.0 | |
| if dt <= 1e-9: | |
| return self._prev_x | |
| dx = (x - self._prev_x) / dt | |
| alpha_d = self._alpha(self.dcutoff, dt) | |
| dx_hat = alpha_d * dx + (1.0 - alpha_d) * self._prev_dx | |
| fc = self.freq_min + self.beta * abs(dx_hat) | |
| alpha = self._alpha(fc, dt) | |
| x_hat = alpha * x + (1.0 - alpha) * self._prev_x | |
| self._prev_x, self._prev_dx, self._prev_ts = x_hat, dx_hat, ts_ms | |
| return x_hat | |
| class _HandSmoother: | |
| """双手关键点平滑(2D 归一化 x/y + world x/y/z),槽位身份自愈。 | |
| 槽位手消失重出现、或 handedness 标签变化(双手换槽)时重置该槽位 | |
| 全部滤波 —— 修复按 (手序号, 关节) 存状态导致双手换位时滤波历史 | |
| 串扰、又抖又飘的问题。 | |
| """ | |
| def __init__(self, freq_min=5.0, beta=0.05): | |
| self._freq_min = freq_min | |
| self._beta = beta | |
| self._filters: dict = {} # (slot, j, axis, kind) -> _OneEuroFilter | |
| self._prev_labels: dict = {} | |
| self._prev_present: dict = {} | |
| def smooth_frame(self, hands: list, ts_ms: float) -> None: | |
| """hands: [(label, kp2d [[x,y,z]...21], world [[x,y,z]...21]), ...]""" | |
| for slot, item in enumerate(hands): | |
| if item is None: | |
| self._prev_present[slot] = False | |
| continue | |
| label, kp2d, world = item | |
| if not self._prev_present.get(slot, False) \ | |
| or self._prev_labels.get(slot) != label: | |
| self._filters = {k: v for k, v in self._filters.items() | |
| if k[0] != slot} | |
| self._prev_labels[slot] = label | |
| self._prev_present[slot] = True | |
| for j, p in enumerate(kp2d): | |
| for ax in (0, 1): | |
| key = (slot, j, ax, "pix") | |
| f = self._filters.get(key) | |
| if f is None: | |
| f = self._filters[key] = _OneEuroFilter(self._freq_min, | |
| self._beta) | |
| p[ax] = f(p[ax], ts_ms) | |
| for j, p in enumerate(world): | |
| for ax in (0, 1, 2): | |
| key = (slot, j, ax, "w") | |
| f = self._filters.get(key) | |
| if f is None: | |
| f = self._filters[key] = _OneEuroFilter(self._freq_min, | |
| self._beta) | |
| p[ax] = f(p[ax], ts_ms) | |
| for slot in list(self._prev_present): | |
| if slot >= len(hands): | |
| self._prev_present[slot] = False | |
| def _detect_hands(video_path: Path, cfg: dict, progress_callback=None): | |
| """单路视频检测(HandLandmarker + RGB 相机相对 3D + 手势)→ DataFrame。 | |
| 列:frame_index / hand_0/1_keypoints(归一化 2D [21,3])/ | |
| hand_0/1_world_landmarks [21,3] / handedness / confidence / | |
| hand_0/1_gesture / hand_0/1_fingers。 | |
| """ | |
| import cv2 | |
| import mediapipe as mp | |
| import pandas as pd | |
| from app.processing.modules.rgb_world_tracking import RGBWorldTracker | |
| from mediapipe.tasks.python import BaseOptions | |
| from mediapipe.tasks.python import vision as mp_vision | |
| max_hands = max(1, min(2, int(cfg.get("max_hands", 2)))) | |
| min_detection = float(cfg.get("min_detection_conf", 0.1)) | |
| min_presence = float(cfg.get("min_presence_conf", 0.1)) | |
| min_tracking = float(cfg.get("min_tracking_conf", 0.5)) | |
| smooth = bool(cfg.get("smooth", True)) | |
| freq_min = float(cfg.get("freq_min", 5.0)) | |
| beta = float(cfg.get("beta", 0.05)) | |
| # device 配置映射到 MediaPipe delegate:auto/cuda:0 → 先试官方 GPU | |
| # delegate(实测 2× 加速、检出零损失、关键点中位差 ~1.2px),任何失败 | |
| # 回退 CPU —— 工作流永不因 GPU 问题挂掉。 | |
| requested_device = str(cfg.get("device", "auto")) | |
| landmarker = None | |
| if requested_device in ("auto", "cuda:0"): | |
| try: | |
| landmarker = mp_vision.HandLandmarker.create_from_options( | |
| mp_vision.HandLandmarkerOptions( | |
| base_options=BaseOptions( | |
| model_asset_path=str(HAND_LANDMARKER_MODEL), | |
| delegate=BaseOptions.Delegate.GPU), | |
| running_mode=mp_vision.RunningMode.VIDEO, | |
| num_hands=max_hands, | |
| min_hand_detection_confidence=min_detection, | |
| min_hand_presence_confidence=min_presence, | |
| min_tracking_confidence=min_tracking, | |
| )) | |
| except Exception as exc: | |
| print(f"[stereo_hand] GPU delegate unavailable, fallback CPU: {exc}") | |
| if landmarker is None: | |
| landmarker = mp_vision.HandLandmarker.create_from_options( | |
| mp_vision.HandLandmarkerOptions( | |
| base_options=BaseOptions(model_asset_path=str(HAND_LANDMARKER_MODEL)), | |
| running_mode=mp_vision.RunningMode.VIDEO, | |
| num_hands=max_hands, | |
| min_hand_detection_confidence=min_detection, | |
| min_hand_presence_confidence=min_presence, | |
| min_tracking_confidence=min_tracking, | |
| )) | |
| cap = cv2.VideoCapture(str(video_path)) | |
| if not cap.isOpened(): | |
| raise RuntimeError(f"Cannot open video: {video_path}") | |
| total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0 | |
| fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 | |
| rgb_tracker = RGBWorldTracker( | |
| int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1, | |
| int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 1, | |
| freq_min=float(cfg.get("position_freq_min", 5.0)), | |
| beta=float(cfg.get("position_beta", 0.05)), | |
| ) | |
| smoother = _HandSmoother(freq_min, beta) if smooth else None | |
| slot_assigner = StableHandSlotAssigner(2) | |
| rows: list[dict] = [] | |
| frame_index = 0 | |
| ts_ms = 0 | |
| while True: | |
| ok, frame = cap.read() | |
| if not ok: | |
| break | |
| rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) | |
| result = landmarker.detect_for_video(mp_image, ts_ms) | |
| ts_ms += int(1000.0 / max(1.0, fps)) | |
| candidates = [] | |
| for i, lms in enumerate(result.hand_landmarks[:2]): | |
| kp = [[float(p.x), float(p.y), float(p.z)] for p in lms[:21]] | |
| wld = ([[float(p.x), float(p.y), float(p.z)] | |
| for p in result.hand_world_landmarks[i][:21]] | |
| if i < len(result.hand_world_landmarks) else None) | |
| raw_label = str(result.handedness[i][0].category_name) \ | |
| if i < len(result.handedness) else "" | |
| score = float(result.handedness[i][0].score) \ | |
| if i < len(result.handedness) else 0.0 | |
| center = np.mean(np.asarray(kp, dtype=np.float32)[:, :2], axis=0) | |
| candidates.append({ | |
| "label": raw_label, | |
| "center": center, | |
| "payload": (kp, wld, raw_label, score), | |
| }) | |
| ordered = slot_assigner.assign(candidates) | |
| slots: list = [] | |
| hands_to_smooth: list = [] | |
| for candidate in ordered: | |
| if candidate is None: | |
| slots.append(None) | |
| hands_to_smooth.append(None) | |
| continue | |
| kp, wld, raw_label, score = candidate["payload"] | |
| # Slot identity is already stabilized by image-space tracking. | |
| # Do not let a transient MediaPipe handedness flip rename the | |
| # slot and move the whole hand to the opposite display side. | |
| label = slot_assigner.stable_label( | |
| len(slots), str(candidate.get("label") or raw_label)) | |
| slots.append((kp, wld, label, score)) | |
| hands_to_smooth.append( | |
| (label, kp, wld or [[0.0, 0.0, 0.0]] * 21)) | |
| if smoother is not None: | |
| smoother.smooth_frame(hands_to_smooth, ts_ms) | |
| row: dict = {"frame_index": frame_index} | |
| for si in range(2): | |
| if si < len(slots) and slots[si] is not None: | |
| kp, wld, label, score = slots[si] | |
| kp = clip_normalized_xy(kp).tolist() | |
| # Keep Hand3D's RGB fallback identical to Black Hand RGB3D: | |
| # derive the local estimated model from 2D image geometry and | |
| # the shared anatomical depth prior. MediaPipe's optional world | |
| # landmark orientation uses a different convention and made the | |
| # bare-hand and black-glove previews face differently. | |
| local = RGBWorldTracker.black_glove_reference_local(kp) | |
| local = (RGBWorldTracker.orient_palm_facing(local, label) | |
| if local is not None else None) | |
| # Keep the RGB hand model's local geometry stable. PnP still | |
| # estimates the wrist's camera-relative position, but its | |
| # frame-by-frame rotation is not applied to the joints. | |
| preserve_model_geometry = True | |
| tracking = (rgb_tracker.update( | |
| f"slot_{si}", kp, local, | |
| frame_index / max(1.0, fps), | |
| preserve_model_geometry=preserve_model_geometry) | |
| if local is not None else None) | |
| gesture_points = local if local is not None else np.zeros((21, 3)) | |
| label_g, mask, angles = _finger_gesture_from_world(gesture_points) | |
| row.update({ | |
| f"hand_{si}_keypoints": kp, | |
| # Keep this legacy column name so downstream consumers | |
| # remain compatible; its value is now a camera-relative | |
| # RGB estimate rather than hand-local MediaPipe points. | |
| f"hand_{si}_world_landmarks": ( | |
| tracking["landmarks_3d"].tolist() | |
| if tracking is not None else None), | |
| f"hand_{si}_landmarks_3d_local": ( | |
| tracking["landmarks_3d_local"].tolist() | |
| if tracking is not None else ( | |
| local.tolist() if local is not None else None)), | |
| f"hand_{si}_world_position": ( | |
| tracking["position"].tolist() | |
| if tracking is not None else None), | |
| f"hand_{si}_coordinate_frame": ( | |
| tracking["coordinate_frame"] if tracking else None), | |
| f"hand_{si}_tracking_source": ( | |
| tracking["tracking_source"] if tracking else None), | |
| f"hand_{si}_depth_source": "rgb_estimate" if tracking else None, | |
| f"hand_{si}_reprojection_error": ( | |
| tracking["reprojection_error"] if tracking else None), | |
| f"hand_{si}_handedness": label, | |
| f"hand_{si}_confidence": score, | |
| f"hand_{si}_gesture": label_g, | |
| f"hand_{si}_fingers": mask, | |
| }) | |
| else: | |
| row.update({ | |
| f"hand_{si}_keypoints": None, | |
| f"hand_{si}_world_landmarks": None, | |
| f"hand_{si}_landmarks_3d_local": None, | |
| f"hand_{si}_world_position": None, | |
| f"hand_{si}_coordinate_frame": None, | |
| f"hand_{si}_tracking_source": None, | |
| f"hand_{si}_depth_source": None, | |
| f"hand_{si}_reprojection_error": None, | |
| f"hand_{si}_handedness": None, | |
| f"hand_{si}_confidence": None, | |
| f"hand_{si}_gesture": "", | |
| f"hand_{si}_fingers": -1, | |
| }) | |
| rows.append(row) | |
| frame_index += 1 | |
| if progress_callback and total: | |
| progress_callback(min(0.99, frame_index / total)) | |
| cap.release() | |
| output = pd.DataFrame(rows) | |
| output.attrs["rgb_tracking"] = { | |
| "mode": "rgb_estimated_3d", | |
| "unit": "rgb_estimated_meters", | |
| "coordinate_frame": "camera_relative", | |
| "metric_3d_available": False, | |
| "method": ("MediaPipe RGB landmarks + hand-model PnP/image-scale " | |
| "wrist placement; stable local hand geometry"), | |
| "orientation_mode": "stable_local_model_wrist_pnp_only", | |
| "camera_model": "approximate_pinhole", | |
| } | |
| return output | |
| def _rgb_frame_rows(det_df) -> list[dict]: | |
| """Convert RGB detector rows into the canonical Hand 3D row schema.""" | |
| def _flat(value, size: int): | |
| if value is None: | |
| return None | |
| try: | |
| arr = np.asarray(value, dtype=np.float32).reshape(-1) | |
| except (TypeError, ValueError): | |
| return None | |
| return arr.tolist() if arr.size == size else None | |
| rows: list[dict] = [] | |
| for _, r in det_df.iterrows(): | |
| row = {"frame_index": int(r["frame_index"])} | |
| for si in ("hand_0", "hand_1"): | |
| wld = _flat(r.get(f"{si}_world_landmarks"), 63) | |
| local = _flat(r.get(f"{si}_landmarks_3d_local"), 63) | |
| position = _flat(r.get(f"{si}_world_position"), 3) | |
| kp = _flat(r.get(f"{si}_keypoints"), 63) | |
| if kp is not None: | |
| kp = clip_normalized_xy(np.asarray(kp, dtype=np.float32) | |
| .reshape(21, 3)).reshape(-1).tolist() | |
| present = wld is not None | |
| present_2d = kp is not None | |
| row.update({ | |
| f"{si}_present": present, | |
| f"{si}_2d_present": present_2d, | |
| f"{si}_landmarks_3d": wld if present else np.full( | |
| 63, np.nan, dtype=np.float32).tolist(), | |
| f"{si}_landmarks_3d_local": local if local is not None | |
| else np.full(63, np.nan, dtype=np.float32).tolist(), | |
| f"{si}_world_position": position if position is not None | |
| else np.full(3, np.nan, dtype=np.float32).tolist(), | |
| f"{si}_coordinate_frame": str( | |
| r.get(f"{si}_coordinate_frame") or ""), | |
| f"{si}_tracking_source": str( | |
| r.get(f"{si}_tracking_source") or ""), | |
| f"{si}_depth_source": str( | |
| r.get(f"{si}_depth_source") or ""), | |
| f"{si}_keypoints": kp if present_2d else np.full( | |
| 63, np.nan, dtype=np.float32).tolist(), | |
| f"{si}_label": str(r.get(f"{si}_handedness") or "") | |
| if present_2d else "", | |
| f"{si}_reprojection_error": float( | |
| r.get(f"{si}_reprojection_error") | |
| if r.get(f"{si}_reprojection_error") is not None | |
| else float("nan")), | |
| f"{si}_gesture": str(r.get(f"{si}_gesture") or "") | |
| if present_2d else "", | |
| f"{si}_fingers": int(r.get(f"{si}_fingers") or 0) | |
| if present_2d else -1, | |
| f"{si}_confidence": float(r.get(f"{si}_confidence") or float("nan")) | |
| if present_2d else float("nan"), | |
| }) | |
| rows.append(row) | |
| return rows | |
| def _transcode_to_h264(src: Path, dst: Path) -> bool: | |
| """mp4v 临时文件 → H.264(ffmpeg 缺失时保留原文件)。 | |
| 短 GOP(-g 25,每秒 1 个关键帧)+ 关闭场景切换插帧:浏览器逐帧 | |
| 步进/多路同步纠偏时,seek 只需解码 ≤24 帧即到目标帧 —— 默认 | |
| GOP(250 ≈ 10s)下 2560×800 大画面每次 seek 要补解最多 249 帧, | |
| 是骨骼视频"卡顿/与原图不同步"的根因。 | |
| """ | |
| import shutil | |
| import subprocess | |
| exe = shutil.which("ffmpeg") | |
| if not exe: | |
| return False | |
| try: | |
| subprocess.run( | |
| [exe, "-y", "-loglevel", "error", "-i", str(src), | |
| "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", | |
| "-g", "25", "-keyint_min", "25", "-sc_threshold", "0", | |
| "-movflags", "+faststart", "-an", str(dst)], | |
| check=True, capture_output=True, timeout=3600, | |
| ) | |
| return dst.exists() and dst.stat().st_size > 0 | |
| except Exception: | |
| return False | |
| _draw_demo_style = draw_demo_style | |
| class StereoTriangulateModule(ProcessingModule): | |
| slug = "rgbd_to_3d_bare_hand" | |
| version = "1.8" | |
| category = "process" | |
| label = "RGB-D_3D_BareHand" | |
| icon = "ant-design:deployment-unit-outlined" | |
| color = HAND3D_COLOR | |
| inputs = ({"key": "video", "label": "RGB Video"}, | |
| {"key": "depth", "label": "Depth"}) | |
| # One canonical output for downstream nodes. Stereo right-view data is | |
| # kept as an internal auxiliary file, not a second connection port. | |
| outputs = ({"key": "hand_3d", "label": "Hand 3D"},) | |
| default_config = {"mode": "auto", | |
| "max_hands": 2, "min_detection_conf": 0.1, | |
| "min_presence_conf": 0.1, | |
| "min_tracking_conf": 0.5, | |
| "smooth": True, "freq_min": 5.0, "beta": 0.05, | |
| "depth_camera": "", | |
| "det_conf": 0.4, "track_conf": 0.4, | |
| "fill": 1, "propagate_max": 15} | |
| config_schema = ( | |
| # relative 保留为旧 JSON 的兼容值,但不再作为前端选项。这个 | |
| # RGB-D 模块没有深度输入时只跳过,不生成估计 3D。 | |
| field("mode", "select", "Mode", "auto", | |
| options=["auto", "depth"]), | |
| field("max_hands", "number", "Max hands", 2, min=1, max=2), | |
| field("min_detection_conf", "number", "Detection confidence", 0.1, | |
| min=0, max=1, step=0.05), | |
| field("min_presence_conf", "number", "Presence confidence", 0.1, | |
| min=0, max=1, step=0.05), | |
| field("min_tracking_conf", "number", "Tracking confidence", 0.5, | |
| min=0, max=1, step=0.05), | |
| field("smooth", "boolean", "One-Euro smoothing", True), | |
| field("freq_min", "number", "Smoothing cutoff Hz", 5.0, | |
| min=1, max=60, step=1), | |
| field("beta", "number", "Smoothing speed coefficient", 0.05, | |
| min=0, max=2, step=0.05), | |
| field("depth_camera", "string", "Depth source override (optional)", ""), | |
| field("det_conf", "number", "Depth-path detection confidence", 0.4, | |
| min=0.3, max=1, step=0.05), | |
| field("track_conf", "number", "Depth-path tracking confidence", 0.4, | |
| min=0.3, max=1, step=0.05), | |
| field("fill", "number", "Depth hole-fill passes", 1, min=1, max=3), | |
| field("propagate_max", "number", "Depth propagation cap (frames)", 15, | |
| min=5, max=120), | |
| ) | |
| execution_target = "worker" | |
| capabilities = ("hand_3d", "stereo", "world_landmarks", | |
| "d435_depth", "camera_meters") | |
| def run(self, ctx: JobContext) -> dict[str, ArtifactRef] | None: | |
| video_refs = [r for key, r in ctx.incoming.items() | |
| if r.kind == "video" | |
| and (key == "video" or key.startswith("video#"))] | |
| if not video_refs: | |
| ctx.skip("RGB Video is not connected — skipped") | |
| depth_refs = [r for key, r in ctx.incoming.items() | |
| if r.kind == "depth" | |
| and (key == "depth" or key.startswith("depth#"))] | |
| if not depth_refs: | |
| ctx.skip("Depth is not connected — skipped") | |
| depth_ref = depth_refs[0] | |
| depth_path = ctx.resolve(depth_ref) | |
| if not depth_path or not depth_path.exists(): | |
| ctx.skip("Connected Depth is unavailable — skipped") | |
| # The connected depth artifact is authoritative. Passing its source | |
| # key prevents the helper's historical directory scan from selecting a | |
| # different camera's depth stream when several cameras are uploaded. | |
| metric_config = dict(ctx.config) | |
| depth_metadata = getattr(depth_ref, "metadata", None) or {} | |
| connected_depth_key = str( | |
| depth_ref.source_key or depth_metadata.get("depth_source") or "" | |
| ).strip() | |
| if connected_depth_key: | |
| metric_config["depth_camera"] = connected_depth_key | |
| from app.processing.modules.depth_hand_3d import run_depth_hand_3d | |
| out, reason = run_depth_hand_3d( | |
| ctx, video_refs, metric_config, allow_rgb_fallback=False) | |
| if out is not None: | |
| return out | |
| ctx.skip(f"Metric depth processing unavailable ({reason}) — skipped") | |
| video_refs.sort(key=lambda r: (r.source_key or "") or str(r.path)) | |
| left_ref = video_refs[0] | |
| left_path = ctx.resolve(left_ref) | |
| if not left_path: | |
| ctx.skip("Video artifact missing — skipped") | |
| import pandas as pd | |
| cfg = { | |
| "max_hands": int(ctx.config.get("max_hands", 2)), | |
| "min_detection_conf": float(ctx.config.get("min_detection_conf", 0.1)), | |
| "min_presence_conf": float(ctx.config.get("min_presence_conf", 0.1)), | |
| "min_tracking_conf": float(ctx.config.get("min_tracking_conf", 0.5)), | |
| "smooth": bool(ctx.config.get("smooth", True)), | |
| "freq_min": float(ctx.config.get("freq_min", 5.0)), | |
| "beta": float(ctx.config.get("beta", 0.05)), | |
| } | |
| # 数据集规范:hand_0/1 = 两只实体手,左目为规范视图(主数据); | |
| # 右目检测结果单独保存(辅助视角,同样进 LeRobot 导出,_rcam 列)。 | |
| def _frame_rows(det_df) -> list[dict]: | |
| return _rgb_frame_rows(det_df) | |
| if len(video_refs) >= 2: | |
| right_ref = video_refs[1] | |
| right_path = ctx.resolve(right_ref) | |
| if not right_path: | |
| ctx.skip("Video artifact missing — skipped") | |
| left_df = _detect_hands(left_path, cfg, ctx.progress) | |
| right_df = _detect_hands(right_path, cfg, ctx.progress) | |
| else: | |
| # 单目(无深度或深度不可用):单路 RGB 相机相对 3D, | |
| # 不产右目产物与并排渲染。 | |
| right_ref = None | |
| right_path = None | |
| left_df = _detect_hands(left_path, cfg, ctx.progress) | |
| right_df = None | |
| rows = _frame_rows(left_df) | |
| right_rows = _frame_rows(right_df) if right_df is not None else [] | |
| if not rows: | |
| ctx.skip("No frames — skipped") | |
| out_dir = ctx.output_root / "hand_3d" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| out_path = out_dir / "hand_world.parquet" | |
| pd.DataFrame(rows).to_parquet(out_path, index=False) | |
| # 右目单独一份:hand_3d_right/ 子目录,不干扰审核页/AI 标注对 | |
| # hand_3d/*.parquet 的通配读取;渲染视频两路都画(与数据解耦)。 | |
| right_out_dir = ctx.output_root / "hand_3d_right" | |
| right_out_path = right_out_dir / "hand_world_right.parquet" | |
| if right_df is not None: | |
| right_out_dir.mkdir(parents=True, exist_ok=True) | |
| pd.DataFrame(right_rows).to_parquet(right_out_path, index=False) | |
| # ── 渲染:demo 风格左右目并排(原始画面 + 2D 骨架 + 角度数字)── | |
| render_video = None | |
| try: | |
| if right_path is None: | |
| raise ValueError("mono view: no stereo side-by-side render") | |
| import cv2 | |
| def kp_px(df_row, slot, w, h): | |
| kp = df_row.get(f"{slot}_keypoints") | |
| if not kp: | |
| return None | |
| return [[float(x) * w, float(y) * h] for x, y, _z in kp] | |
| cap_l = cv2.VideoCapture(str(left_path)) | |
| cap_r = cv2.VideoCapture(str(right_path)) | |
| W = int(cap_l.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280 | |
| H = int(cap_l.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 800 | |
| fps = cap_l.get(cv2.CAP_PROP_FPS) or 25.0 | |
| raw_out = out_dir / "stereo_hands.mp4" | |
| writer = cv2.VideoWriter(str(raw_out), | |
| cv2.VideoWriter_fourcc(*"mp4v"), | |
| fps, (W * 2, H)) | |
| i = 0 | |
| while True: | |
| ok_l, fl = cap_l.read() | |
| ok_r, fr = cap_r.read() | |
| if not ok_l or not ok_r: | |
| break | |
| lrow = left_df.iloc[i] if i < len(left_df) else None | |
| rrow = right_df.iloc[i] if i < len(right_df) else None | |
| for im, row in ((fl, lrow), (fr, rrow)): | |
| if row is not None: | |
| for slot in ("hand_0", "hand_1"): | |
| px = kp_px(row, slot, W, H) | |
| if not px: | |
| continue | |
| _draw_demo_style(im, px) | |
| writer.write(cv2.hconcat([fl, fr])) | |
| i += 1 | |
| writer.release() | |
| cap_l.release() | |
| cap_r.release() | |
| if raw_out.exists() and raw_out.stat().st_size > 0: | |
| h264 = out_dir / "stereo_hands_h264.mp4" | |
| if _transcode_to_h264(raw_out, h264): | |
| raw_out.unlink(missing_ok=True) | |
| render_video = h264 | |
| except Exception as exc: | |
| print(f"[stereo_hand] render skipped: {exc}") | |
| tracking_meta = dict((left_df.attrs.get("rgb_tracking") or {})) | |
| manifest = { | |
| "frames": len(rows), | |
| "left_source": left_ref.source_key, | |
| "right_source": right_ref.source_key if right_ref else None, | |
| "right_parquet": str(right_out_path) if right_df is not None else None, | |
| "views": len(video_refs), | |
| "detector": "hand_landmarker", | |
| "mode": tracking_meta.get("mode", "rgb_estimated_3d"), | |
| # RGB-only placement is shared by hands in the same camera frame, | |
| # but it is still an estimate because no depth sensor was used. | |
| "unit": tracking_meta.get("unit", "rgb_estimated_meters"), | |
| "coordinate_frame": tracking_meta.get("coordinate_frame", | |
| "camera_relative"), | |
| "metric_3d_available": bool( | |
| tracking_meta.get("metric_3d_available", False)), | |
| "method": tracking_meta.get("method", "RGB hand pose estimate"), | |
| "camera_model": tracking_meta.get("camera_model", | |
| "approximate_pinhole"), | |
| "smoothing": { | |
| "enabled": cfg["smooth"], | |
| "freq_min": cfg["freq_min"], | |
| "beta": cfg["beta"], | |
| }, | |
| "render_video": str(render_video) if render_video else "", | |
| } | |
| if depth_fallback_reason: | |
| manifest["depth_fallback_reason"] = depth_fallback_reason | |
| # 旧审核页/导出器兼容历史字段。 | |
| manifest["d435_fallback_reason"] = depth_fallback_reason | |
| (out_dir / "hand_world.manifest.json").write_text( | |
| json.dumps(manifest, indent=2), encoding="utf-8") | |
| if right_df is not None and right_ref is not None: | |
| # Keep the per-view artifact self-describing. The parent manifest | |
| # describes the stereo pair and therefore mentions both sources; | |
| # it must not be the only source declaration used to resolve the | |
| # right parquet in review APIs. | |
| right_manifest = { | |
| **manifest, | |
| "source_key": right_ref.source_key, | |
| "camera": right_ref.source_key, | |
| "view": "right", | |
| "artifact": "hand_world_right", | |
| } | |
| (right_out_dir / "hand_world_right.manifest.json").write_text( | |
| json.dumps(right_manifest, indent=2), encoding="utf-8") | |
| source_key = str(left_ref.source_key or "stereo") | |
| out: dict[str, ArtifactRef] = { | |
| "hand_3d": ctx.ref("hand_3d", out_path, source_key=source_key, | |
| metadata=manifest), | |
| } | |
| # The right-view parquet remains available inside the processed run | |
| # for diagnostics, but is intentionally not exposed as a workflow | |
| # output. Downstream modules consume one canonical Hand 3D stream. | |
| if render_video: | |
| out["render_video"] = ctx.ref( | |
| "video", render_video, source_key=source_key, | |
| metadata={"skeleton": True, "stereo_side_by_side": True}) | |
| return out | |