Spaces:
Running
Running
Download processor/app/processing/modules/black_glove_hand.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 65.3 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/modules/black_glove_hand.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/processing/modules/black_glove_hand.py
-
curl -L -o black_glove_hand.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/modules/black_glove_hand.py
65.3 kB
| """Black-glove hand skeleton module. | |
| This module is deliberately separate from ``RGB_TO_2D_BlackGlove``. Every | |
| video input is processed independently by the black-glove detector: | |
| * mono: one RGB video -> one 2D keypoint parquet + skeleton video; | |
| * stereo: left and right RGB videos -> two independent outputs and an | |
| optional side-by-side preview; | |
| * depth, when compatible depth streams exist, is sampled from the matching | |
| RGB view and lifted into that camera's coordinates, with one | |
| ``hand_3d/<source>.parquet`` artifact per device. | |
| This workflow is the depth-based ``RGB-D_3D_BlackGlove`` workflow. The | |
| separate ``RGB_TO_2D_BlackGlove`` module owns RGB-only estimated preview data. | |
| There is no stereo triangulation. Left/right output slots are kept by the | |
| detector track id and the D435 3D stage adds label-aware fixed-slot state; | |
| there is no cross-camera matching. The detector implementation and D435 | |
| tracking helper are bundled under ``app/processing/black_glove``; this | |
| workflow adapter owns the artifact contract, video rendering, and depth path. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Any, Callable | |
| import numpy as np | |
| from app.processing import ArtifactRef, JobContext, ProcessingModule, field | |
| from app.processing.hand_render import draw_demo_style, hand_style_scale | |
| from app.processing.black_glove.d435_tracking import ( | |
| BAND_HALF_M, BAND_MIN_VALID, AlphaBetaHandSlots, gate_observations, | |
| ) | |
| from app.lerobot_v21 import DepthVideoReader | |
| from app.processing.registry import register | |
| from app.processing.theme import HAND3D_COLOR | |
| _BLACK_GLOVE_ROOT = Path(__file__).resolve().parents[1] / "black_glove" | |
| _DEFAULT_WEIGHTS = _BLACK_GLOVE_ROOT / "weights" / "yolov8m-worldv2.pt" | |
| def _safe_key(value: str | None, fallback: str) -> str: | |
| return re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or fallback)) | |
| def _load_glove_detector(): | |
| """Load the bundled detector lazily while keeping worker startup light.""" | |
| from app.processing.black_glove.glove_detector import GloveDetector | |
| return GloveDetector | |
| def _detector_config(config: dict[str, Any]) -> dict[str, Any]: | |
| # Existing workflow graphs may already contain the first adapter's | |
| # defaults (0.5/1/0.2/10/0.2). Migrate that exact untouched bundle so a | |
| # user does not have to rebuild every saved workflow. A partially custom | |
| # configuration is respected field-by-field below. | |
| legacy_defaults = ( | |
| float(config.get("movement_thresh", float("nan"))) == 0.5 and | |
| int(config.get("skip_timeout", -1)) == 1 and | |
| float(config.get("box_alpha", float("nan"))) == 0.2 and | |
| float(config.get("freq_min", float("nan"))) == 10.0 and | |
| float(config.get("beta", float("nan"))) == 0.2 | |
| ) | |
| if legacy_defaults: | |
| config = { | |
| **config, | |
| "movement_thresh": 1.5, | |
| "skip_timeout": 3, | |
| "box_alpha": 0.7, | |
| "freq_min": 5.0, | |
| "beta": 0.05, | |
| } | |
| # The previous stability profile was too aggressive for black gloves: | |
| # it rejected low-confidence but usable hands and froze valid fast | |
| # motions. Normalize that exact profile back to the high-recall settings, | |
| # including already-saved workflow graphs. | |
| over_filtered_profile = ( | |
| abs(float(config.get("det_conf", 0.08)) - 0.08) < 1e-6 | |
| and abs(float(config.get("movement_thresh", 2.0)) - 2.0) < 1e-6 | |
| and int(config.get("skip_timeout", 4)) == 4 | |
| and abs(float(config.get("pose_conf_thr", 0.4) or 0.4) - 0.4) < 1e-6 | |
| and config.get("hold_translate", False) is False | |
| ) | |
| if over_filtered_profile: | |
| config = { | |
| **config, | |
| "det_conf": 0.05, | |
| "movement_thresh": 1.5, | |
| "skip_timeout": 3, | |
| "freeze_max": 15, | |
| "pose_conf_thr": 0.15, | |
| "hold_translate": True, | |
| } | |
| # 2026-08-26 远距离/手背修复档:旧默认 bundle(imgsz 320 + | |
| # new_track_conf 0.25 + pose_conf_thr 0.3 且未设 lost_timeout) | |
| # 整体升到远距离档,与 tools(1)(1) hand_3d_s80c 实测参数一致; | |
| # 部分自定义的配置按字段保留不动。兼容两代旧快照:8-24 更早的 | |
| # 保存配置没有 new_track_conf/pose_conf_thr 键(缺键=旧默认, | |
| # 直接升级);8-26 前的完整默认 bundle 按显式旧值识别。 | |
| legacy_far_defaults = ( | |
| int(config.get("imgsz", 640)) == 320 | |
| and config.get("lost_timeout") is None | |
| and ( | |
| ("new_track_conf" not in config | |
| and "pose_conf_thr" not in config) | |
| or (abs(float(config.get("new_track_conf", 0.1)) - 0.25) < 1e-6 | |
| and abs(float(config.get("pose_conf_thr", 0.15) or 0.15) | |
| - 0.3) < 1e-6) | |
| ) | |
| ) | |
| if legacy_far_defaults: | |
| config = { | |
| **config, | |
| "imgsz": 640, | |
| "new_track_conf": 0.1, | |
| "pose_conf_thr": 0.15, | |
| "lost_timeout": 8, | |
| } | |
| # 运动门控旧默认对(3px/10 帧):EMA 平滑框只跟手速 ~30%,3px 门控 | |
| # 在快动时几乎不触发、慢动时又长期冻结,实测造成"骨架跟手延迟感"。 | |
| # 旧默认组合整体收紧为 1.5px/3 帧(000003 快扫段实测有效)。 | |
| legacy_gate_defaults = ( | |
| abs(float(config.get("movement_thresh", 1.5)) - 3.0) < 1e-6 | |
| and int(config.get("skip_timeout", 3)) == 10 | |
| ) | |
| if legacy_gate_defaults: | |
| config = { | |
| **config, | |
| "movement_thresh": 1.5, | |
| "skip_timeout": 3, | |
| } | |
| weights = Path(str(config.get("weights_path") or _DEFAULT_WEIGHTS)) | |
| pose_conf_thr = config.get("pose_conf_thr", 0.15) | |
| match_contain_thr = config.get("match_contain_thr", 0.7) | |
| return { | |
| "weights": str(weights.expanduser()), | |
| "num_hands": max(1, min(2, int(config.get("max_hands", 2)))), | |
| "device": str(config.get("device", "auto")), | |
| "pose_device": str(config.get("pose_device", "auto")), | |
| "det_conf": float(config.get("det_conf", 0.05)), | |
| "imgsz": int(config.get("imgsz", 640)), | |
| "use_tracker": bool(config.get("use_tracker", True)), | |
| # Keep the detector defaults in sync with the validated standalone | |
| # black-glove pipeline. Refreshing RTMPose on every tiny box wobble | |
| # makes the output noisier; the tracker should gate pose inference and | |
| # retain the last good pose briefly when a detector box is missed. | |
| "movement_thresh": max(0.0, float(config.get("movement_thresh", 1.5))), | |
| "skip_timeout": max(1, int(config.get("skip_timeout", 3))), | |
| "box_alpha": min(1.0, max(0.0, float(config.get("box_alpha", 0.7)))), | |
| "use_oe": bool(config.get("smooth", True)), | |
| "oe_freq_min": float(config.get("freq_min", 5.0)), | |
| "oe_beta": float(config.get("beta", 0.05)), | |
| "freeze_max": int(config.get("freeze_max", 15)), | |
| "pose_conf_thr": (None if pose_conf_thr is None | |
| or float(pose_conf_thr) <= 0 | |
| else float(pose_conf_thr)), | |
| "pose_backend": str(config.get("pose_backend", "rtmpose")), | |
| "pose_model": (str(config.get("pose_model")) | |
| if config.get("pose_model") else None), | |
| "pose_box_raw": bool(config.get("pose_box_raw", False)), | |
| "hold_translate": bool(config.get("hold_translate", True)), | |
| # 低置信闪框不能创建新轨迹;已有轨迹仍允许用低置信框维持。 | |
| # 远距离档(S80C 实测):0.1 而非 0.25——离线统计 93% 的 | |
| # world 框 conf<0.25,旧门会挡死远手重捕获。 | |
| "new_track_conf": max(0.0, float(config.get("new_track_conf", 0.1))), | |
| # 丢失容忍(远距离档 8 帧:远手/手背框闪烁时 3 帧即死会持续丢手)。 | |
| "lost_timeout": max(1, int(config.get("lost_timeout", 8))), | |
| # 低置信 hold 放行上限(手背/握拳修复:无限 hold 会把骨架永久 | |
| # 冻在旧姿势)。 | |
| "hold_max": max(1, int(config.get("hold_max", 12))), | |
| # 新框入场确认帧数(拦单帧背景假框/闪框,低 conf 下必须)。 | |
| "spawn_confirm": max(1, int(config.get("spawn_confirm", 2))), | |
| # 跨手碎片框拒收占比(0 关):防"框飘到另一只手上"。 | |
| "match_contain_thr": ( | |
| None if match_contain_thr is None | |
| or float(match_contain_thr) <= 0 | |
| else min(1.0, float(match_contain_thr))), | |
| } | |
| def _make_detector(config: dict[str, Any]): | |
| cls = _load_glove_detector() | |
| cfg = _detector_config(config) | |
| return cls(**cfg) | |
| def _transcode_to_h264(src: Path, dst: Path) -> bool: | |
| 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", | |
| "-movflags", "+faststart", "-an", str(dst)], | |
| check=True, capture_output=True, timeout=900, | |
| ) | |
| return dst.exists() and dst.stat().st_size > 0 | |
| except Exception as exc: | |
| print(f"[black_glove] H.264 transcode skipped: {exc}") | |
| return False | |
| def _find_depth_sources(ctx: JobContext, video_refs: list, | |
| config: dict[str, Any]): | |
| """Return one depth sampler source per RGB device. | |
| A multi-camera batch may contain different depth resolutions and different | |
| calibration files. Never choose the first depth directory globally: | |
| pair each RGB ref through the same slot/token matcher used by Hand 3D. | |
| """ | |
| import cv2 | |
| try: | |
| from app.processing.modules.depth_hand_3d import ( | |
| _find_device_pairs, _load_demo, _pair_calibration, | |
| ) | |
| demo = _load_demo() | |
| if demo is None: | |
| return {}, ["black_glove: depth demo not found"] | |
| configured = str(config.get("depth_camera") or "").strip() | |
| requested_rgb = str(config.get("depth_source") or "").strip() | |
| sources: dict[str, tuple] = {} | |
| failures: list[str] = [] | |
| pairs = _find_device_pairs(ctx, video_refs) | |
| for pair in pairs: | |
| source_key = str(pair.get("rgb_source") or "") | |
| depth_name = str(pair.get("depth_source") or "") | |
| if configured and depth_name != configured: | |
| continue | |
| if requested_rgb and source_key != requested_rgb: | |
| continue | |
| depth_dir = pair.get("depth_dir") | |
| depth_video = pair.get("depth_video") | |
| ref = pair.get("rgb_ref") | |
| video_path = ctx.resolve(ref) | |
| if (depth_dir is None and depth_video is None) or not depth_name: | |
| failures.append(f"{source_key}: depth pair missing") | |
| continue | |
| if video_path is None or not video_path.exists(): | |
| failures.append(f"{source_key}: video artifact missing") | |
| continue | |
| depth_pngs = sorted(depth_dir.glob("*.png")) if depth_dir else [] | |
| depth_reader = None | |
| if depth_video is not None: | |
| try: | |
| depth_reader = DepthVideoReader(depth_video) | |
| first = depth_reader.read() | |
| except (OSError, RuntimeError, ValueError) as exc: | |
| failures.append(f"{source_key}: depth video unreadable ({exc})") | |
| first = None | |
| finally: | |
| if depth_reader is not None: | |
| depth_reader.close() | |
| else: | |
| first = (cv2.imread(str(depth_pngs[0]), cv2.IMREAD_UNCHANGED) | |
| if depth_pngs else None) | |
| if first is None: | |
| failures.append(f"{source_key}: depth frame unreadable") | |
| continue | |
| cap = cv2.VideoCapture(str(video_path)) | |
| if not cap.isOpened(): | |
| failures.append(f"{source_key}: video cannot be opened") | |
| continue | |
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 0 | |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 0 | |
| cap.release() | |
| try: | |
| calib, calib_source = _pair_calibration( | |
| pair, demo, (height, width), first.shape[:2]) | |
| aligner = demo.LiveAligner( | |
| calib["color_intrinsics"], calib["depth_to_color"], | |
| calib["depth_intrinsics"], | |
| fill_passes=max(1, min(3, int(config.get("depth_fill", 1)))), | |
| ) | |
| except (OSError, ValueError, TypeError, KeyError, RuntimeError) as exc: | |
| failures.append(f"{source_key}: calibration unavailable ({exc})") | |
| continue | |
| sources[source_key] = (depth_dir, depth_pngs, depth_video, aligner, | |
| depth_name, calib_source) | |
| return sources, failures | |
| except Exception as exc: | |
| print(f"[black_glove] depth path disabled: {exc}") | |
| return {}, [f"black_glove: depth path disabled ({exc})"] | |
| class _DepthSampler: | |
| # Keep this in sync with the D435 Hand 3D lift path. The aligned depth | |
| # image can contain background pixels at a glove landmark (especially on | |
| # the glove boundary), so a single unbounded sample can produce a valid | |
| # but completely wrong camera-space Z. | |
| _BAND_HALF_MM = BAND_HALF_M * 1000.0 | |
| _BAND_MIN_VALID = BAND_MIN_VALID | |
| def __init__(self, source): | |
| if len(source) == 5: | |
| (self.depth_dir, self.depth_pngs, self.aligner, | |
| self.depth_name, self.calib_source) = source | |
| self.depth_video = None | |
| else: | |
| (self.depth_dir, self.depth_pngs, self.depth_video, self.aligner, | |
| self.depth_name, self.calib_source) = source | |
| self.depth_reader = (DepthVideoReader(self.depth_video) | |
| if self.depth_video is not None else None) | |
| # Recordings in the current batches start at 000001, while some | |
| # exporters use 000000. Detect the convention once so frame 0 is not | |
| # silently paired with the wrong depth image. | |
| self.frame_offset = ( | |
| 0 if self.depth_reader is not None | |
| else 0 if (self.depth_dir / "000000.png").exists() else 1 | |
| ) | |
| def close(self) -> None: | |
| if self.depth_reader is not None: | |
| self.depth_reader.close() | |
| self.depth_reader = None | |
| def frame_xyz(self, frame_index: int, keypoints_px: list[list[float]], | |
| video_w: int, video_h: int) -> np.ndarray: | |
| xyz, _measured, _zc = self.frame_xyz_with_meta( | |
| frame_index, keypoints_px, video_w, video_h) | |
| return xyz | |
| def frame_xyz_with_meta(self, frame_index: int, | |
| keypoints_px: list[list[float]], | |
| video_w: int, video_h: int): | |
| """Return xyz plus measured-point mask and raw hand-depth centre.""" | |
| import cv2 | |
| empty = np.full((len(keypoints_px), 3), np.nan, np.float32) | |
| empty_mask = np.zeros(len(keypoints_px), dtype=bool) | |
| if self.depth_reader is not None: | |
| depth = self.depth_reader.read() | |
| else: | |
| # Prefer the detected filename convention. Never blindly use a | |
| # positional fallback: one missing middle depth frame would shift | |
| # every later RGB/depth pair by one frame. | |
| expected = frame_index + self.frame_offset | |
| named = self.depth_dir / f"{expected:06d}.png" | |
| depth_path = named if named.exists() else None | |
| if depth_path is None and 0 <= frame_index < len(self.depth_pngs): | |
| candidate = self.depth_pngs[frame_index] | |
| if not candidate.stem.isdigit() or int(candidate.stem) == expected: | |
| depth_path = candidate | |
| depth = (cv2.imread(str(depth_path), cv2.IMREAD_UNCHANGED) | |
| if depth_path is not None else None) | |
| if depth is None or depth.shape[:2] != (self.aligner.dh, self.aligner.dw): | |
| return empty, empty_mask, None | |
| aligned = self.aligner.align_depth_to_color(depth) | |
| scale_x = self.aligner.cw / max(1.0, float(video_w)) | |
| scale_y = self.aligner.ch / max(1.0, float(video_h)) | |
| uv = np.asarray(keypoints_px, np.float32).reshape(-1, 2) | |
| uv_aligned = uv * [scale_x, scale_y] | |
| # Match Hand 3D's two-pass lift: estimate the hand's depth centre | |
| # first, then resample only inside that depth band. This removes a | |
| # background sample such as 0.8 m -> 2.7 m which otherwise becomes a | |
| # long 3D bone segment. | |
| z_mm = self.aligner.sample_points(aligned, uv_aligned) | |
| raw_valid = np.isfinite(z_mm) & (z_mm > 0) | |
| measured = raw_valid.copy() | |
| zc_mm = None | |
| if int(raw_valid.sum()) >= self._BAND_MIN_VALID: | |
| zc_mm = float(np.median(z_mm[raw_valid])) | |
| band = (zc_mm - self._BAND_HALF_MM, | |
| zc_mm + self._BAND_HALF_MM) | |
| band_z = self.aligner.sample_points( | |
| aligned, uv_aligned, band=band) | |
| band_valid = np.isfinite(band_z) & (band_z > 0) | |
| measured = band_valid.copy() | |
| # Missing/out-of-band landmarks are completed at the measured | |
| # hand centre, as in Hand 3D's complete=True path. X/Y still | |
| # come from the 2D landmark, so the projected skeleton remains | |
| # aligned with the RGB image. | |
| z_mm = np.where(band_valid, band_z, zc_mm).astype(np.float32) | |
| fx, fy, cx, cy = (self.aligner.fx_c, self.aligner.fy_c, | |
| self.aligner.cx_c, self.aligner.cy_c) | |
| xyz = np.full((len(uv), 3), np.nan, np.float32) | |
| valid = np.isfinite(z_mm) & (z_mm > 0) | |
| z = z_mm[valid] / 1000.0 | |
| u, v = uv_aligned[valid, 0], uv_aligned[valid, 1] | |
| xyz[valid, 0] = ((u - cx) / fx * z).astype(np.float32) | |
| xyz[valid, 1] = ((v - cy) / fy * z).astype(np.float32) | |
| xyz[valid, 2] = z.astype(np.float32) | |
| return xyz, measured & valid, ( | |
| None if zc_mm is None else float(zc_mm) / 1000.0) | |
| class _DepthTemporalSmoother: | |
| """Smooth lifted 3D points without inventing missing observations. | |
| The standalone D435 pipeline keeps a short-lived 3D slot state and | |
| reinitializes it after a long gap. The workflow used to write every | |
| depth-lifted frame directly to parquet, so depth noise and a stale filter | |
| state could leak into the 3D preview. This small, device-agnostic layer | |
| keeps the same semantics for any depth camera: valid observations are | |
| smoothed, missing frames stay missing, and a label/long-gap/large-jump | |
| transition starts a fresh filter. | |
| """ | |
| def __init__(self, fps: float, config: dict[str, Any]): | |
| from app.processing.black_glove.filters import OneEuroFilter | |
| self._filter_type = OneEuroFilter | |
| self._fps = max(float(fps or 30.0), 1.0) | |
| self._freq_min = max(float(config.get("depth_freq_min", 3.0)), 0.01) | |
| self._beta = max(float(config.get("depth_beta", 0.3)), 0.0) | |
| self._dcutoff = max(float(config.get("depth_dcutoff", 1.0)), 0.01) | |
| self._max_lost = max(1, int(config.get("depth_max_lost", 15))) | |
| self._filters: list[list[list[OneEuroFilter]] | None] = [None, None] | |
| self._labels: list[str | None] = [None, None] | |
| self._last_frame: list[int | None] = [None, None] | |
| self._centres: list[np.ndarray | None] = [None, None] | |
| def _new_filters(self): | |
| return [ | |
| [self._filter_type(self._freq_min, self._beta, self._dcutoff) | |
| for _ in range(3)] | |
| for _ in range(21) | |
| ] | |
| def _reset(self, slot: int, label: str, frame_index: int, | |
| centre: np.ndarray): | |
| self._filters[slot] = self._new_filters() | |
| self._labels[slot] = label or None | |
| self._last_frame[slot] = frame_index | |
| self._centres[slot] = centre.copy() | |
| def apply(self, rows: list[dict[str, Any]], fps: float | None = None): | |
| if fps: | |
| self._fps = max(float(fps), 1.0) | |
| for row in rows: | |
| frame_index = int(row.get("frame_index", 0)) | |
| ts_ms = frame_index * 1000.0 / self._fps | |
| for slot in (0, 1): | |
| prefix = f"hand_{slot}" | |
| if not row.get(f"{prefix}_present"): | |
| continue | |
| raw = row.get(f"{prefix}_landmarks_3d") | |
| if raw is None: | |
| continue | |
| xyz = np.asarray(raw, dtype=np.float32).reshape(-1, 3) | |
| if xyz.shape != (21, 3): | |
| continue | |
| valid = np.isfinite(xyz).all(axis=1) | |
| if int(valid.sum()) < 4: | |
| continue | |
| centre = np.nanmedian(np.where(valid[:, None], xyz, np.nan), axis=0) | |
| label = str(row.get(f"{prefix}_handedness") or "") | |
| last = self._last_frame[slot] | |
| jump = (self._centres[slot] is not None and | |
| float(np.linalg.norm(centre - self._centres[slot])) > 0.35) | |
| must_reset = ( | |
| self._filters[slot] is None or | |
| (self._labels[slot] not in (None, "", label) | |
| and label) or | |
| (last is not None and frame_index - last > self._max_lost) or | |
| jump | |
| ) | |
| if must_reset: | |
| self._reset(slot, label, frame_index, centre) | |
| filters = self._filters[slot] | |
| if filters is None: | |
| continue | |
| smoothed = xyz.copy() | |
| for point_index in np.flatnonzero(valid): | |
| for axis in range(3): | |
| smoothed[point_index, axis] = filters[point_index][axis]( | |
| float(xyz[point_index, axis]), ts_ms) | |
| row[f"{prefix}_landmarks_3d"] = smoothed.tolist() | |
| self._labels[slot] = label or self._labels[slot] | |
| self._last_frame[slot] = frame_index | |
| self._centres[slot] = np.nanmedian( | |
| np.where(np.isfinite(smoothed), smoothed, np.nan), axis=0) | |
| def _row_for_hands(frame_index: int, hands: list, width: int, height: int, | |
| depth_sampler: _DepthSampler | None = None, | |
| rgb_tracker=None, timestamp_s: float = 0.0) -> dict: | |
| row: dict[str, Any] = {"frame_index": int(frame_index)} | |
| for slot in range(2): | |
| hand = hands[slot] if slot < len(hands) else None | |
| if hand is None: | |
| row.update({ | |
| f"hand_{slot}_present": False, | |
| f"hand_{slot}_2d_present": False, | |
| f"hand_{slot}_keypoints": None, | |
| f"hand_{slot}_handedness": None, | |
| f"hand_{slot}_confidence": None, | |
| f"hand_{slot}_depth_mm": None, | |
| f"hand_{slot}_landmarks_3d": None, | |
| f"hand_{slot}_landmarks_3d_local": None, | |
| f"hand_{slot}_world_position": None, | |
| f"hand_{slot}_coordinate_frame": None, | |
| f"hand_{slot}_tracking_source": None, | |
| f"hand_{slot}_depth_source": None, | |
| f"hand_{slot}_state": "absent", | |
| f"hand_{slot}_propagated": False, | |
| f"hand_{slot}_depth_measured": None, | |
| f"hand_{slot}_depth_center_m": None, | |
| }) | |
| continue | |
| px = np.asarray(hand.landmarks, np.float32).reshape(21, 2) | |
| norm = np.column_stack((px[:, 0] / max(1, width), | |
| px[:, 1] / max(1, height), | |
| np.zeros(21, np.float32))) | |
| norm[:, :2] = np.clip(norm[:, :2], 0.0, 1.0) | |
| hand_label = str(getattr(hand, "label", "") or "") | |
| if rgb_tracker is not None: | |
| hand_label = rgb_tracker.stabilize_handedness( | |
| f"hand_{slot}", hand_label) | |
| rgb_estimate = None | |
| if depth_sampler: | |
| xyz, depth_measured, depth_center_m = \ | |
| depth_sampler.frame_xyz_with_meta( | |
| frame_index, px.tolist(), width, height) | |
| elif rgb_tracker is not None: | |
| local = rgb_tracker.local_from_image(norm) | |
| local = (rgb_tracker.orient_palm_facing( | |
| local, hand_label) | |
| if local is not None else None) | |
| rgb_estimate = (rgb_tracker.update( | |
| f"hand_{slot}", norm, local, timestamp_s, | |
| preserve_model_geometry=True) | |
| if local is not None else None) | |
| xyz = (np.asarray(rgb_estimate["landmarks_3d"], np.float32) | |
| if rgb_estimate is not None else | |
| np.full((21, 3), np.nan, np.float32)) | |
| else: | |
| xyz = np.full((21, 3), np.nan, np.float32) | |
| depth_mm = (xyz[:, 2] * 1000.0).tolist() if depth_sampler else None | |
| local_xyz = (np.asarray( | |
| rgb_estimate.get("landmarks_3d_local"), np.float32).tolist() | |
| if rgb_estimate is not None else None) | |
| world_position = (np.asarray( | |
| rgb_estimate.get("position"), np.float32).tolist() | |
| if rgb_estimate is not None else None) | |
| row.update({ | |
| f"hand_{slot}_present": True, | |
| f"hand_{slot}_2d_present": True, | |
| f"hand_{slot}_keypoints": norm.tolist(), | |
| f"hand_{slot}_handedness": hand_label, | |
| f"hand_{slot}_confidence": float(getattr(hand, "score", 0.0)), | |
| f"hand_{slot}_depth_mm": depth_mm, | |
| f"hand_{slot}_landmarks_3d": xyz.tolist(), | |
| f"hand_{slot}_landmarks_3d_local": local_xyz, | |
| f"hand_{slot}_world_position": world_position, | |
| f"hand_{slot}_coordinate_frame": ( | |
| str(rgb_estimate.get("coordinate_frame")) | |
| if rgb_estimate is not None else None), | |
| f"hand_{slot}_tracking_source": ( | |
| str(rgb_estimate.get("tracking_source")) | |
| if rgb_estimate is not None else None), | |
| f"hand_{slot}_depth_source": "depth" if depth_sampler else None, | |
| f"hand_{slot}_state": "real", | |
| f"hand_{slot}_propagated": False, | |
| f"hand_{slot}_depth_measured": ( | |
| depth_measured.tolist() if depth_sampler else None), | |
| f"hand_{slot}_depth_center_m": ( | |
| depth_center_m if depth_sampler else None), | |
| }) | |
| return row | |
| def _same_wholesale_observation(previous, current, threshold: float = 0.08) -> bool: | |
| """Return whether two wholesale 3D observations are mutually consistent.""" | |
| if previous is None: | |
| return False | |
| a = np.asarray(previous, np.float64).reshape(21, 3) | |
| b = np.asarray(current, np.float64).reshape(21, 3) | |
| ok = np.isfinite(a).all(axis=1) & np.isfinite(b).all(axis=1) | |
| if int(ok.sum()) < 4: | |
| return False | |
| return float(np.median(np.linalg.norm(a[ok] - b[ok], axis=1))) <= threshold | |
| def _apply_d435_tracking(rows: list[dict], config: dict[str, Any]) -> dict[str, int]: | |
| """Add v1.0-style 3D gating, slot tracking and explicit frame states. | |
| The detector remains the existing YOLO/RTMPose detector. This stage only | |
| operates on its 2D/depth output and never changes detector coordinates. | |
| ``real`` means the current frame supplied a usable observation; | |
| ``propagated`` means the 3D value is an alpha-beta prediction; ``absent`` | |
| means there is no usable observation or bounded prediction. | |
| """ | |
| tracker = AlphaBetaHandSlots( | |
| max_lost=max(1, int(config.get("propagate_max", | |
| config.get("depth_max_lost", 15)))), | |
| alpha=float(config.get("ab_alpha", 0.5)), | |
| beta=float(config.get("ab_beta", 0.1)), | |
| ) | |
| wholesale_previous: list[np.ndarray | None] = [None, None] | |
| wholesale_streak = [0, 0] | |
| gate_streak = [np.zeros(21, dtype=np.int32), | |
| np.zeros(21, dtype=np.int32)] | |
| gate_labels = ["", ""] | |
| slot_zc: list[float | None] = [None, None] | |
| slot_zc_labels = ["", ""] | |
| stats = {"real": 0, "propagated": 0, "absent": 0} | |
| # Keep immutable observations so a reverse pass can fill a leading gap | |
| # without treating a forward prediction as training data. | |
| observations = [] | |
| for source in rows: | |
| frame_observations = [] | |
| for slot in (0, 1): | |
| prefix = f"hand_{slot}" | |
| label = str(source.get(f"{prefix}_handedness") or "") | |
| detected = bool(source.get(f"{prefix}_2d_present")) | |
| points = None | |
| raw = source.get(f"{prefix}_landmarks_3d") | |
| if raw is not None: | |
| try: | |
| candidate = np.asarray(raw, np.float64).reshape(21, 3) | |
| if int(np.isfinite(candidate).all(axis=1).sum()) >= 4: | |
| points = candidate.copy() | |
| except (TypeError, ValueError): | |
| points = None | |
| # v1.0 M5: the per-frame depth median can jump when the set of | |
| # valid glove pixels changes. Stabilize only completed points at | |
| # a slot-level z centre; measured points remain untouched. | |
| measured = source.get(f"{prefix}_depth_measured") | |
| if points is not None and measured is not None: | |
| try: | |
| measured_mask = np.asarray(measured, dtype=bool).reshape(21) | |
| except (TypeError, ValueError): | |
| measured_mask = np.zeros(21, dtype=bool) | |
| measured_mask &= np.isfinite(points).all(axis=1) | |
| if int(measured_mask.sum()) >= BAND_MIN_VALID: | |
| zf = source.get(f"{prefix}_depth_center_m") | |
| if zf is None or not np.isfinite(float(zf)): | |
| zf = float(np.median(points[measured_mask, 2])) | |
| else: | |
| zf = float(zf) | |
| if slot_zc_labels[slot] != label or slot_zc[slot] is None: | |
| slot_zc[slot] = zf | |
| else: | |
| slot_zc[slot] = 0.5 * slot_zc[slot] + 0.5 * zf | |
| missing = np.isfinite(points).all(axis=1) & ~measured_mask | |
| if missing.any() and slot_zc[slot] > 0: | |
| old_z = points[missing, 2].copy() | |
| scale = slot_zc[slot] / np.maximum(old_z, 1.0e-6) | |
| points[missing, 0] *= scale | |
| points[missing, 1] *= scale | |
| points[missing, 2] = slot_zc[slot] | |
| slot_zc_labels[slot] = label | |
| frame_observations.append((detected, points, label)) | |
| observations.append(frame_observations) | |
| for row_index, row in enumerate(rows): | |
| frame_index = int(row.get("frame_index", 0)) | |
| for slot in (0, 1): | |
| prefix = f"hand_{slot}" | |
| detected_2d, points, label = observations[row_index][slot] | |
| prediction = tracker.predict(slot, frame_index) | |
| state = "absent" | |
| output = None | |
| if detected_2d and points is not None: | |
| gated, wholesale = gate_observations( | |
| points, prediction, | |
| gate=float(config.get("depth_gate_m", 0.15)), | |
| ) | |
| if wholesale: | |
| if _same_wholesale_observation( | |
| wholesale_previous[slot], gated) or wholesale_streak[slot] >= 2: | |
| # The old slot state is stale. Re-seed only after a | |
| # second agreeing frame so one bad depth frame cannot | |
| # teleport the entire hand. | |
| tracker.reset(slot, label) | |
| tracker.observe(slot, label, points, frame_index) | |
| wholesale_previous[slot] = None | |
| wholesale_streak[slot] = 0 | |
| output = tracker.predict(slot, frame_index) | |
| state = "real" | |
| else: | |
| wholesale_previous[slot] = gated | |
| wholesale_streak[slot] += 1 | |
| output = prediction | |
| state = "propagated" if output is not None else "absent" | |
| else: | |
| if gate_labels[slot] != label: | |
| gate_streak[slot][:] = 0 | |
| gate_labels[slot] = label | |
| finite_points = np.isfinite(points).all(axis=1) | |
| gated_finite = np.isfinite(gated).all(axis=1) | |
| latched = finite_points & ~gated_finite | |
| gate_streak[slot][latched] += 1 | |
| gate_streak[slot][~latched] = 0 | |
| forgive_after = max(1, int(config.get("gate_forgive", 5))) | |
| forgive = (gate_streak[slot] >= forgive_after | |
| ) & finite_points | |
| observation_for_tracker = np.asarray(gated, np.float64).copy() | |
| if forgive.any(): | |
| # Re-admit recovered joints instead of leaving a gate | |
| # permanently locked. | |
| observation_for_tracker[forgive] = points[forgive] | |
| gate_streak[slot][forgive] = 0 | |
| tracker.observe(slot, label, observation_for_tracker, | |
| frame_index) | |
| wholesale_previous[slot] = None | |
| wholesale_streak[slot] = 0 | |
| output = tracker.predict(slot, frame_index) | |
| state = "real" | |
| else: | |
| # No current usable observation: predict before incrementing | |
| # lost count, exactly as the v1.0 live path does. | |
| tracker.mark_lost(slot) | |
| output = prediction | |
| state = "propagated" if output is not None else "absent" | |
| wholesale_previous[slot] = None | |
| wholesale_streak[slot] = 0 | |
| gate_streak[slot][:] = 0 | |
| row[f"{prefix}_state"] = state | |
| row[f"{prefix}_propagated"] = state == "propagated" | |
| if state != "real": | |
| stable_label = tracker.slot_label(slot) | |
| if stable_label: | |
| row[f"{prefix}_handedness"] = stable_label | |
| if state == "absent" or output is None: | |
| row[f"{prefix}_present"] = False | |
| row[f"{prefix}_landmarks_3d"] = None | |
| row[f"{prefix}_tracking_source"] = "none" | |
| else: | |
| row[f"{prefix}_present"] = True | |
| row[f"{prefix}_landmarks_3d"] = np.asarray( | |
| output, np.float32).reshape(21, 3).tolist() | |
| row[f"{prefix}_tracking_source"] = ( | |
| "yolo_rtmpose_depth_real" if state == "real" | |
| else "d435_alpha_beta_propagated") | |
| # Offline processing has the whole sequence available. A reverse pass | |
| # fills a bounded leading portion of a gap that the forward-only pass | |
| # could not reach, while real observations always remain authoritative. | |
| reverse = AlphaBetaHandSlots( | |
| max_lost=tracker.max_lost, alpha=tracker.alpha, beta=tracker.beta) | |
| for row_index in range(len(rows) - 1, -1, -1): | |
| row = rows[row_index] | |
| frame_index = int(row.get("frame_index", row_index)) | |
| for slot in (0, 1): | |
| prefix = f"hand_{slot}" | |
| detected, points, label = observations[row_index][slot] | |
| if detected and points is not None: | |
| gated, wholesale = gate_observations( | |
| points, reverse.predict(slot, frame_index), | |
| gate=float(config.get("depth_gate_m", 0.15)), | |
| ) | |
| reverse.observe(slot, label, points if wholesale else gated, | |
| frame_index) | |
| continue | |
| prediction = reverse.predict(slot, frame_index) | |
| reverse.mark_lost(slot) | |
| if (str(row.get(f"{prefix}_state") or "absent") == "absent" | |
| and prediction is not None): | |
| row[f"{prefix}_present"] = True | |
| row[f"{prefix}_propagated"] = True | |
| row[f"{prefix}_state"] = "propagated" | |
| row[f"{prefix}_landmarks_3d"] = np.asarray( | |
| prediction, np.float32).reshape(21, 3).tolist() | |
| stable_label = reverse.slot_label(slot) | |
| if stable_label: | |
| row[f"{prefix}_handedness"] = stable_label | |
| row[f"{prefix}_tracking_source"] = "d435_alpha_beta_propagated" | |
| for row in rows: | |
| for slot in (0, 1): | |
| state = str(row.get(f"hand_{slot}_state") or "absent") | |
| stats[state] += 1 | |
| return stats | |
| def _flat63(value) -> list[float]: | |
| """Normalize one 21x3 value to the hand_3d parquet contract.""" | |
| if value is None: | |
| return np.full(63, np.nan, np.float32).tolist() | |
| try: | |
| arr = np.asarray(value) | |
| if arr.dtype == object: | |
| arr = np.asarray([np.asarray(point, dtype=np.float32) | |
| for point in value], dtype=np.float32) | |
| arr = arr.astype(np.float32).reshape(21, 3) | |
| except (TypeError, ValueError): | |
| return np.full(63, np.nan, np.float32).tolist() | |
| return arr.reshape(63).tolist() | |
| def _hand_3d_rows(rows: list[dict], fps: float = 30.0, | |
| config: dict[str, Any] | None = None) -> list[dict]: | |
| """Convert depth or RGB-estimated rows to the existing Hand 3D schema.""" | |
| config = config or {} | |
| _apply_d435_tracking(rows, config) | |
| if bool(config.get("depth_smooth", True)): | |
| _DepthTemporalSmoother(fps, config).apply(rows, fps) | |
| output = [] | |
| for source in rows: | |
| row = {"frame_index": int(source["frame_index"])} | |
| for slot in (0, 1): | |
| prefix = f"hand_{slot}" | |
| present = bool(source.get(f"{prefix}_present")) | |
| state = str(source.get(f"{prefix}_state") or | |
| ("real" if present else "absent")) | |
| row.update({ | |
| f"{prefix}_present": present, | |
| f"{prefix}_2d_present": bool( | |
| source.get(f"{prefix}_2d_present", present)), | |
| f"{prefix}_landmarks_3d": _flat63( | |
| source.get(f"{prefix}_landmarks_3d")), | |
| f"{prefix}_landmarks_3d_local": _flat63( | |
| source.get(f"{prefix}_landmarks_3d_local")), | |
| f"{prefix}_world_position": ( | |
| list(source.get(f"{prefix}_world_position")) | |
| if source.get(f"{prefix}_world_position") is not None | |
| else None), | |
| f"{prefix}_coordinate_frame": source.get( | |
| f"{prefix}_coordinate_frame"), | |
| f"{prefix}_tracking_source": source.get( | |
| f"{prefix}_tracking_source"), | |
| f"{prefix}_depth_source": source.get( | |
| f"{prefix}_depth_source"), | |
| f"{prefix}_keypoints": _flat63( | |
| source.get(f"{prefix}_keypoints")), | |
| f"{prefix}_label": str( | |
| source.get(f"{prefix}_handedness") or ""), | |
| f"{prefix}_confidence": float( | |
| source.get(f"{prefix}_confidence") or 0.0), | |
| f"{prefix}_reprojection_error": float("nan"), | |
| f"{prefix}_gesture": "", | |
| f"{prefix}_fingers": -1, | |
| f"{prefix}_state": state, | |
| f"{prefix}_propagated": bool( | |
| source.get(f"{prefix}_propagated", state == "propagated")), | |
| }) | |
| output.append(row) | |
| return output | |
| def _draw_hands(frame, hands) -> None: | |
| for hand in hands: | |
| points = np.asarray(hand.landmarks, np.float32).reshape(21, 2) | |
| draw_demo_style(frame, points.tolist()) | |
| import cv2 | |
| label = str(getattr(hand, "label", "") or "Hand") | |
| score = float(getattr(hand, "score", 0.0)) | |
| x, y = int(points[:, 0].min()), int(points[:, 1].min()) | |
| scale = hand_style_scale(points) | |
| cv2.putText(frame, f"glove {label} {score:.2f}", | |
| (max(0, x), max(16, y - 6)), | |
| cv2.FONT_HERSHEY_SIMPLEX, max(0.28, 0.45 * scale), | |
| (0, 220, 255), max(1, int(round(scale))), | |
| cv2.LINE_AA) | |
| def _stable_slot_hands(hands: list, slot_by_track: dict, | |
| slot_last_seen: list[int], slot_last_centres: list, | |
| frame_index: int, | |
| slot_labels: list[str] | None = None, | |
| slot_velocities: list | None = None) -> list: | |
| """Keep a disappearing hand in its output slot. | |
| ``GloveDetector`` supplies a useful tracker id in ``DetectedHand.index``, | |
| but that id can be recreated when a glove is briefly occluded. Therefore | |
| it is only a soft hint. Existing slots are assigned jointly by predicted | |
| image position first, then by track id and the latched handedness label. | |
| This is important when two hands cross: trusting the detector list or a | |
| newly-created id at that exact frame swaps the two output columns and | |
| consequently swaps the RGB 3D hands as well. | |
| """ | |
| ordered = [None, None] | |
| if not hands: | |
| return ordered | |
| slot_labels = slot_labels if slot_labels is not None else ["", ""] | |
| if slot_velocities is None: | |
| slot_velocities = [None, None] | |
| from itertools import combinations, permutations | |
| def _centre(hand) -> np.ndarray: | |
| points = np.asarray(hand.landmarks, dtype=np.float32).reshape(21, 2) | |
| return np.nanmedian(points, axis=0).astype(np.float32) | |
| def _key(hand, fallback_index: int): | |
| raw_key = getattr(hand, "index", None) | |
| try: | |
| return int(raw_key) | |
| except (TypeError, ValueError): | |
| return ("position", fallback_index) | |
| candidates = [] | |
| used_keys = set() | |
| for fallback_index, hand in enumerate(hands[:2]): | |
| key = _key(hand, fallback_index) | |
| if key in used_keys: | |
| key = ("position", fallback_index) | |
| used_keys.add(key) | |
| candidates.append((key, hand, _centre(hand), | |
| str(getattr(hand, "label", "") or ""))) | |
| active_slots = [s for s in (0, 1) if slot_last_centres[s] is not None] | |
| assignments: list[tuple[int, int]] = [] # (candidate index, slot) | |
| if active_slots: | |
| def _predicted(slot: int) -> np.ndarray: | |
| centre = np.asarray(slot_last_centres[slot], dtype=np.float32) | |
| velocity = slot_velocities[slot] | |
| if velocity is None: | |
| return centre | |
| gap = min(3, max(1, int(frame_index - slot_last_seen[slot]))) | |
| return centre + np.asarray(velocity, dtype=np.float32) * gap | |
| def _cost(candidate_index: int, slot: int) -> float: | |
| key, _hand, centre, label = candidates[candidate_index] | |
| distance = float(np.linalg.norm(centre - _predicted(slot))) | |
| mapped = slot_by_track.get(key) | |
| # Track IDs and handedness are deliberately weak tie-breakers. | |
| # Position continuity must win when an ID is recreated or two | |
| # hands cross in the image. | |
| if mapped == slot: | |
| distance -= 30.0 | |
| elif mapped in (0, 1): | |
| distance += 30.0 | |
| stable = str(slot_labels[slot] or "") | |
| if label and stable and label != stable: | |
| distance += 20.0 | |
| return distance | |
| count = min(len(candidates), len(active_slots)) | |
| best = None | |
| for slot_subset in combinations(active_slots, count): | |
| for candidate_order in permutations(range(len(candidates)), count): | |
| total = sum(_cost(ci, slot) | |
| for slot, ci in zip(slot_subset, candidate_order)) | |
| # Prefer keeping more recently observed slots when costs tie. | |
| key = (total, tuple(-slot_last_seen[s] for s in slot_subset), | |
| tuple(candidate_order)) | |
| if best is None or key < best[0]: | |
| best = (key, slot_subset, candidate_order) | |
| if best is not None: | |
| _, slots, candidate_order = best | |
| assignments.extend(zip(candidate_order, slots)) | |
| used_candidates = {ci for ci, _slot in assignments} | |
| used_slots = {slot for _ci, slot in assignments} | |
| free_slots = [s for s in (0, 1) if s not in used_slots] | |
| for ci, (_key_value, _hand, _centre_value, _label) in enumerate(candidates): | |
| if ci in used_candidates or not free_slots: | |
| continue | |
| slot = free_slots.pop(0) | |
| assignments.append((ci, slot)) | |
| else: | |
| # Initial order is kept for backward compatibility with existing | |
| # batches. Once initialized, the geometric assignment above takes | |
| # over and prevents later left/right swaps. | |
| assignments = [(ci, ci) for ci in range(min(2, len(candidates)))] | |
| for ci, slot in assignments: | |
| key, hand, centre, label = candidates[ci] | |
| old_centre = slot_last_centres[slot] | |
| old_seen = slot_last_seen[slot] | |
| if old_centre is not None and old_seen >= 0: | |
| gap = max(1, int(frame_index - old_seen)) | |
| measured_velocity = (centre - old_centre) / float(gap) | |
| previous_velocity = slot_velocities[slot] | |
| slot_velocities[slot] = measured_velocity if previous_velocity is None else ( | |
| 0.5 * np.asarray(previous_velocity, dtype=np.float32) | |
| + 0.5 * measured_velocity) | |
| slot_last_seen[slot] = int(frame_index) | |
| slot_last_centres[slot] = centre | |
| # Latch handedness once per persistent output slot. The detector's | |
| # per-frame geometric label is allowed to flip during a palm/back | |
| # transition, but that must not rename a healthy 3D track. | |
| if not slot_labels[slot] and label: | |
| slot_labels[slot] = label | |
| stable = str(slot_labels[slot] or label or "") | |
| if stable: | |
| try: | |
| hand.label = stable | |
| except AttributeError: | |
| pass | |
| # Remove stale IDs that previously pointed at this slot. A recreated | |
| # detector ID is then associated with the slot without causing a | |
| # one-frame swap. | |
| for old_key, old_slot in list(slot_by_track.items()): | |
| if old_slot == slot and old_key != key: | |
| slot_by_track.pop(old_key, None) | |
| slot_by_track[key] = slot | |
| ordered[slot] = hand | |
| return ordered | |
| def run_local(video_path: Path, output_dir: Path, skeleton_dir: Path, | |
| config: dict[str, Any], progress_callback: Callable[[float], None] | None, | |
| depth_sampler: _DepthSampler | None = None, | |
| keypoint_name: str = "hand_keypoints.parquet") -> dict[str, Any]: | |
| import cv2 | |
| import pandas as pd | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| skeleton_dir.mkdir(parents=True, exist_ok=True) | |
| 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 25.0 | |
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640 | |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480 | |
| detector = _make_detector(config) | |
| rgb_tracker = None | |
| if (depth_sampler is None | |
| and bool(config.get("preview_3d", True))): | |
| from app.processing.modules.rgb_world_tracking import RGBWorldTracker | |
| rgb_tracker = RGBWorldTracker( | |
| width, height, | |
| freq_min=float(config.get("freq_min", 5.0)), | |
| beta=float(config.get("beta", 0.05)), | |
| ) | |
| raw_path = skeleton_dir / f"{Path(keypoint_name).stem}_skeleton.mp4" | |
| writer = cv2.VideoWriter(str(raw_path), cv2.VideoWriter_fourcc(*"mp4v"), | |
| fps, (width, height)) | |
| if not writer.isOpened(): | |
| detector.close() | |
| cap.release() | |
| raise RuntimeError(f"Cannot create skeleton video: {raw_path}") | |
| rows: list[dict] = [] | |
| slot_by_track: dict = {} | |
| slot_last_seen = [-1, -1] | |
| slot_last_centres = [None, None] | |
| slot_velocities = [None, None] | |
| slot_labels = ["", ""] | |
| try: | |
| frame_index = 0 | |
| while True: | |
| ok, frame = cap.read() | |
| if not ok: | |
| break | |
| hands = detector.detect(frame) | |
| ordered_hands = _stable_slot_hands( | |
| hands, slot_by_track, slot_last_seen, slot_last_centres, | |
| frame_index, slot_labels, slot_velocities) | |
| rows.append(_row_for_hands( | |
| frame_index, ordered_hands, width, height, depth_sampler, rgb_tracker, | |
| frame_index / max(1.0, float(fps)))) | |
| _draw_hands(frame, [hand for hand in ordered_hands if hand is not None]) | |
| writer.write(frame) | |
| frame_index += 1 | |
| if progress_callback and total: | |
| progress_callback(min(0.98, frame_index / total)) | |
| finally: | |
| cap.release() | |
| writer.release() | |
| detector.close() | |
| if not rows: | |
| raise RuntimeError(f"No frames in video: {video_path}") | |
| parquet_path = output_dir / keypoint_name | |
| # Depth masks/centres are internal inputs to the 3D tracker, not part of | |
| # the public 2D keypoint artifact. Keep the 2D schema compact. | |
| public_rows = [ | |
| {key: value for key, value in row.items() | |
| if not (key.endswith("_depth_measured") | |
| or key.endswith("_depth_center_m"))} | |
| for row in rows | |
| ] | |
| pd.DataFrame(public_rows).to_parquet(parquet_path, index=False) | |
| h264_path = skeleton_dir / f"{Path(keypoint_name).stem}_skeleton_h264.mp4" | |
| video_path_out = h264_path if _transcode_to_h264(raw_path, h264_path) else raw_path | |
| if video_path_out != raw_path: | |
| raw_path.unlink(missing_ok=True) | |
| frames_with_hands = sum(bool(r["hand_0_present"] or r["hand_1_present"]) for r in rows) | |
| manifest = { | |
| "frames": len(rows), | |
| "frames_with_hands": frames_with_hands, | |
| "fps": fps, | |
| "width": width, | |
| "height": height, | |
| "detector": ("YOLO-World + " | |
| + str(config.get("pose_backend", "rtmpose"))), | |
| "weights": str(_detector_config(config)["weights"]), | |
| "mode": "independent_2d_per_view", | |
| "triangulation": False, | |
| "depth_lift": bool(depth_sampler), | |
| "rgb_estimated_3d": ( | |
| depth_sampler is None | |
| and bool(config.get("preview_3d", True))), | |
| "preview_3d": bool(config.get("preview_3d", True)), | |
| "depth_camera": depth_sampler.depth_name if depth_sampler else None, | |
| "skeleton_video": str(video_path_out.name), | |
| } | |
| if depth_sampler: | |
| manifest.update({ | |
| "unit": "camera_meters", | |
| "coordinate_frame": "aligned_color_camera", | |
| "world_coordinates": True, | |
| }) | |
| (output_dir / f"{Path(keypoint_name).stem}.manifest.json").write_text( | |
| json.dumps(manifest, indent=2), encoding="utf-8") | |
| if progress_callback: | |
| progress_callback(1.0) | |
| return {"parquet": parquet_path, "video": video_path_out, "manifest": manifest, | |
| "rows": rows} | |
| class BlackGloveHandModule(ProcessingModule): | |
| slug = "rgbd_to_3d_black_glove" | |
| version = "1.7" | |
| category = "process" | |
| label = "RGB-D_3D_BlackGlove" | |
| icon = "ant-design:aim-outlined" | |
| color = HAND3D_COLOR | |
| inputs = ({"key": "video", "label": "RGB Video"}, | |
| {"key": "depth", "label": "Depth"}) | |
| # 2D keypoints remain an internal artifact for rendering/tracking. The | |
| # workflow graph exposes the single downstream contract: Hand 3D. | |
| outputs = ({"key": "hand_3d", "label": "Hand 3D"},) | |
| default_config = { | |
| "mode": "auto", "max_hands": 2, "det_conf": 0.05, | |
| "device": "auto", "pose_device": "auto", "imgsz": 640, | |
| "pose_backend": "rtmpose", "pose_model": "", | |
| "smooth": True, "freq_min": 5.0, "beta": 0.05, | |
| "use_tracker": True, "movement_thresh": 1.5, "skip_timeout": 3, | |
| "box_alpha": 0.7, "freeze_max": 15, "pose_conf_thr": 0.15, | |
| "pose_box_raw": False, "hold_translate": True, "new_track_conf": 0.1, | |
| "lost_timeout": 8, "hold_max": 12, "spawn_confirm": 2, | |
| "match_contain_thr": 0.7, | |
| "depth_camera": "", | |
| "depth_fill": 1, "depth_smooth": True, "depth_freq_min": 3.0, | |
| "depth_beta": 0.3, "depth_max_lost": 15, "propagate_max": 15, | |
| "depth_gate_m": 0.15, "gate_forgive": 5, | |
| "ab_alpha": 0.5, "ab_beta": 0.1, | |
| } | |
| config_schema = ( | |
| field("mode", "select", "Input mode", "auto", options=["auto", "mono", "stereo"]), | |
| field("max_hands", "number", "Max hands", 2, min=1, max=2), | |
| field("det_conf", "number", "Glove detection confidence", 0.05, min=0.01, max=1, step=0.01), | |
| field("device", "select", "Detector device", "auto", options=["auto", "cpu", "cuda"]), | |
| field("pose_device", "select", "Pose device", "auto", options=["auto", "cpu", "cuda"]), | |
| field("pose_backend", "select", "Pose backend", "rtmpose", | |
| options=["rtmpose", "mediapipe"]), | |
| field("pose_model", "string", "MediaPipe task path (optional)", ""), | |
| field("imgsz", "number", "Detector image size", 640, min=160, max=1280, step=32), | |
| 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("use_tracker", "boolean", "Track hands between frames", True), | |
| field("movement_thresh", "number", "Pose refresh movement threshold (px)", 1.5, | |
| min=0, max=20, step=0.5), | |
| field("skip_timeout", "number", "Maximum skipped pose frames", 3, | |
| min=1, max=30, step=1), | |
| field("box_alpha", "number", "Tracking box smoothing", 0.7, | |
| min=0, max=1, step=0.05), | |
| field("pose_conf_thr", "number", "Pose confidence threshold", 0.15, | |
| min=0, max=1, step=0.05), | |
| field("pose_box_raw", "boolean", "Use raw box for pose crop", False), | |
| field("hold_translate", "boolean", "Compensate held pose motion", True), | |
| field("new_track_conf", "number", "New track confidence gate", 0.1, | |
| min=0, max=1, step=0.01), | |
| field("lost_timeout", "number", "Lost track timeout (frames)", 8, | |
| min=1, max=60, step=1), | |
| field("hold_max", "number", "Low-confidence hold limit (frames)", 12, | |
| min=1, max=120, step=1), | |
| field("spawn_confirm", "number", "New box confirm frames", 2, | |
| min=1, max=5, step=1), | |
| field("match_contain_thr", "number", "Cross-hand box reject ratio (0=off)", 0.7, | |
| min=0, max=1, step=0.05), | |
| field("depth_camera", "string", "Depth source override (optional)", ""), | |
| field("depth_source", "string", "RGB source to receive depth (optional)", ""), | |
| field("depth_fill", "number", "Depth hole-fill passes", 1, min=1, max=3), | |
| field("depth_smooth", "boolean", "Smooth lifted 3D points", True), | |
| field("depth_freq_min", "number", "3D smoothing cutoff Hz", 3.0, | |
| min=0.1, max=30, step=0.5), | |
| field("depth_beta", "number", "3D smoothing speed coefficient", 0.3, | |
| min=0, max=2, step=0.05), | |
| field("depth_max_lost", "number", "3D reset gap (frames)", 15, | |
| min=1, max=120, step=1), | |
| field("propagate_max", "number", "3D prediction limit (frames)", 15, | |
| min=1, max=60, step=1), | |
| field("depth_gate_m", "number", "3D jump gate (meters)", 0.15, | |
| min=0.03, max=1, step=0.01), | |
| field("gate_forgive", "number", "Gate recovery frames", 5, | |
| min=1, max=30, step=1), | |
| field("ab_alpha", "number", "3D alpha correction", 0.5, | |
| min=0.05, max=1, step=0.05), | |
| field("ab_beta", "number", "3D beta velocity correction", 0.1, | |
| min=0.01, max=1, step=0.01), | |
| ) | |
| execution_target = "worker" | |
| capabilities = ("black_glove", "hand_keypoints", "hand_3d", "video_overlay", | |
| "mono", "stereo", "depth_camera_meters", | |
| "no_triangulation") | |
| 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") | |
| # Only the depth artifact connected to this card may select the depth | |
| # sidecar. This keeps a multi-camera batch from borrowing another | |
| # camera's depth directory during the legacy pairing scan. | |
| depth_metadata = getattr(depth_ref, "metadata", None) or {} | |
| connected_depth_key = str( | |
| depth_ref.source_key or depth_metadata.get("depth_source") or "" | |
| ).strip() | |
| depth_config = dict(ctx.config) | |
| # A stale manually entered override must not win over the port that is | |
| # actually connected in this run. An empty connected key deliberately | |
| # leaves pairing to the single-source legacy matcher. | |
| depth_config["depth_camera"] = connected_depth_key | |
| depth_config["depth_source"] = "" | |
| video_refs.sort(key=lambda r: (r.source_key or "", r.path or "")) | |
| mode = str(ctx.config.get("mode") or "auto") | |
| if mode == "mono": | |
| video_refs = video_refs[:1] | |
| elif mode == "stereo": | |
| video_refs = video_refs[:2] | |
| depth_sources, depth_failures = _find_depth_sources( | |
| ctx, video_refs, depth_config) | |
| if not depth_sources: | |
| ctx.skip("Black Glove Hand Skeleton requires a matching depth stream") | |
| # Keep only RGB views that have their own depth pair. Never fall back | |
| # to RGB-estimated 3D in this module; that is the separate | |
| # Black Hand RGB3D workflow. | |
| video_refs = [ref for ref in video_refs | |
| if str(ref.source_key or "") in depth_sources] | |
| if not video_refs: | |
| ctx.skip("No RGB/depth pair available for Black Glove Hand Skeleton") | |
| output_kp = ctx.output_root / "hand_keypoints" | |
| output_video = ctx.output_root / "skeleton" | |
| out: dict[str, ArtifactRef] = {} | |
| results = [] | |
| world_count = 0 | |
| for index, ref in enumerate(video_refs): | |
| path = ctx.resolve(ref) | |
| if not path or not path.exists(): | |
| ctx.skip(f"Video artifact missing — skipped: {ref.path}") | |
| key = _safe_key(ref.source_key, path.stem) | |
| source_key = str(ref.source_key or path.stem) | |
| depth_source = depth_sources.get(source_key) | |
| sampler = _DepthSampler(depth_source) | |
| try: | |
| result = run_local(path, output_kp, output_video, ctx.config, | |
| lambda p, i=index, n=len(video_refs): | |
| ctx.progress((i + p) / n), | |
| depth_sampler=sampler, | |
| keypoint_name=f"{key}.parquet") | |
| finally: | |
| sampler.close() | |
| # Keep the original artifact source in the manifest as well as in | |
| # ArtifactRef. The review resolver uses this when a source name | |
| # contains characters that were sanitized for the filename. | |
| result["manifest"]["source_key"] = source_key | |
| if depth_failures: | |
| result["manifest"]["processing_warnings"] = list(depth_failures) | |
| (result["parquet"].parent / f"{key}.manifest.json").write_text( | |
| json.dumps(result["manifest"], indent=2), encoding="utf-8") | |
| results.append((ref, key, result)) | |
| keypoint_handle = "hand_keypoints" if index == 0 else f"hand_keypoints#{index + 1}" | |
| out[keypoint_handle] = ctx.ref( | |
| "hand_keypoints", result["parquet"], source_key=source_key, | |
| metadata=result["manifest"]) | |
| skeleton_handle = "skeleton_video" if index == 0 else f"skeleton_video#{index + 1}" | |
| out[skeleton_handle] = ctx.ref( | |
| "video", result["video"], source_key=source_key, | |
| metadata={**result["manifest"], "skeleton": True}) | |
| import pandas as pd | |
| world_dir = ctx.output_root / "hand_3d" | |
| world_dir.mkdir(parents=True, exist_ok=True) | |
| world_path = world_dir / f"{key}.parquet" | |
| world_rows = _hand_3d_rows(result["rows"], result["manifest"]["fps"], | |
| ctx.config) | |
| pd.DataFrame(world_rows).to_parquet(world_path, index=False) | |
| state_counts = {"real": 0, "propagated": 0, "absent": 0} | |
| for world_row in world_rows: | |
| for slot in (0, 1): | |
| state = str(world_row.get(f"hand_{slot}_state") or "absent") | |
| if state in state_counts: | |
| state_counts[state] += 1 | |
| world_manifest = { | |
| **result["manifest"], | |
| "mode": "black_glove_depth_lift", | |
| "unit": "camera_meters", | |
| "coordinate_frame": "aligned_color_camera", | |
| "world_coordinates": True, | |
| "metric_3d_available": True, | |
| "box_overlay": False, | |
| "prediction_for_preview_only": True, | |
| "real_only_export_default": True, | |
| "state_counts": state_counts, | |
| "calib_source": depth_source[5], | |
| "method": ("YOLO-World + " | |
| + str(ctx.config.get("pose_backend", "rtmpose")) | |
| + " 2D keypoints + " | |
| "per-device depth alignment, 3x3 median depth-band lift, " | |
| "hand-centre completion, depth jump gating, alpha-beta " | |
| "slot prediction, and reset-aware temporal smoothing"), | |
| } | |
| (world_dir / f"{key}.manifest.json").write_text( | |
| json.dumps(world_manifest, indent=2), encoding="utf-8") | |
| hand_handle = "hand_3d" if world_count == 0 else "hand_3d#right" | |
| out[hand_handle] = ctx.ref( | |
| "hand_3d", world_path, source_key=source_key, | |
| metadata=world_manifest) | |
| world_count += 1 | |
| if depth_failures: | |
| print("[black_glove] processing warnings: " | |
| + "; ".join(depth_failures)) | |
| # A stereo preview is only a display artifact. It does not combine | |
| # detections or create any cross-camera coordinates. | |
| if len(results) == 2: | |
| try: | |
| import cv2 | |
| left, right = results[0][2]["video"], results[1][2]["video"] | |
| cap_l, cap_r = cv2.VideoCapture(str(left)), cv2.VideoCapture(str(right)) | |
| w = int(cap_l.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640 | |
| h = int(cap_l.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480 | |
| fps = cap_l.get(cv2.CAP_PROP_FPS) or 25.0 | |
| raw = output_video / "black_glove_stereo_side_by_side.mp4" | |
| writer = cv2.VideoWriter(str(raw), cv2.VideoWriter_fourcc(*"mp4v"), | |
| fps, (w * 2, h)) | |
| while True: | |
| ok_l, fl = cap_l.read(); ok_r, fr = cap_r.read() | |
| if not ok_l or not ok_r: | |
| break | |
| writer.write(cv2.hconcat([fl, fr])) | |
| writer.release(); cap_l.release(); cap_r.release() | |
| h264 = output_video / "black_glove_stereo_side_by_side_h264.mp4" | |
| combined = h264 if _transcode_to_h264(raw, h264) else raw | |
| if combined != raw: | |
| raw.unlink(missing_ok=True) | |
| out["stereo_preview"] = ctx.ref( | |
| "video", combined, source_key="stereo", | |
| metadata={"skeleton": True, "triangulation": False, | |
| "independent_views": True}) | |
| except Exception as exc: | |
| print(f"[black_glove] stereo preview skipped: {exc}") | |
| return out | |