File size: 18,928 Bytes
d646dd7
 
 
95f5290
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
"""RGB-only hand-to-camera tracking used by the Hand Skeleton module.

This module is deliberately self-contained.  It does not import the legacy
``Python`` project or any code outside ``processor``.

MediaPipe gives us two useful pieces of information from an RGB frame:

* normalized image landmarks (the 2D observation), and
* a hand-local 3D landmark model (the shape used as the PnP object model).

The PnP solution gives a stable camera-relative placement when the model and
the image points are good enough.  A scale-from-image fallback keeps the hand
visible when PnP is temporarily ill-conditioned.  The result is intentionally
named ``rgb_estimated_meters``: it is a camera-relative estimate, not a depth
sensor measurement and must not be presented as metric ground truth.
"""

from __future__ import annotations

from dataclasses import dataclass

import cv2
import numpy as np


# MediaPipe image landmarks expose ``z`` as an optional image-space hint.
# Some RGB/black-glove detections provide a valid 21x3 array but set every
# image-space z to zero.  Feeding that planar model to PnP produces a hand
# whose joints have identical depth, so keep a small anatomical depth prior
# as a visual/pose-estimation fallback.  This is deliberately relative and
# must not be interpreted as measured depth.
_RGB_FALLBACK_Z_PROFILE = np.array([
    0.000,                         # wrist
    0.004,  0.009,  0.014,  0.018,  # thumb
    0.000,  0.006,  0.012,  0.017,  # index
    0.000,  0.007,  0.014,  0.020,  # middle
    0.000,  0.006,  0.012,  0.017,  # ring
    0.000,  0.005,  0.010,  0.014,  # pinky
], dtype=np.float64)


def _cohere_thumb_depth_direction(points: np.ndarray) -> np.ndarray:
    """Keep the RGB thumb on the same relative depth side as the fingers.

    RGB image landmarks do not provide reliable metric Z.  Depending on the
    detector/model, the thumb's image-space Z hint can have the opposite sign
    from the four finger chains.  That produces a visually mirrored thumb in
    the 3D preview even though the 2D keypoints are correct.  Only the thumb
    chain's relative Z is corrected; its X/Y image geometry and all measured
    depth paths remain untouched.
    """
    out = np.asarray(points, dtype=np.float64).copy()
    finger_z = out[[8, 12, 16, 20], 2]
    thumb_z = out[[1, 2, 3, 4], 2]
    finger_median = float(np.median(finger_z))
    thumb_median = float(np.median(thumb_z))
    if (abs(finger_median) > 1e-5 and abs(thumb_median) > 1e-5
            and finger_median * thumb_median < 0.0):
        out[1:5, 2] *= -1.0
    return out


def camera_matrix(width: int, height: int, focal_scale: float = 0.9) -> np.ndarray:
    """Return a useful pinhole approximation when no camera calibration exists."""
    w = max(1, int(width))
    h = max(1, int(height))
    focal = max(1.0, float(focal_scale) * max(w, h))
    return np.array([[focal, 0.0, w / 2.0],
                     [0.0, focal, h / 2.0],
                     [0.0, 0.0, 1.0]], dtype=np.float64)


class _OneEuro:
    """Small vector One-Euro filter for camera position."""

    def __init__(self, freq_min: float = 5.0, beta: float = 0.05,
                 dcutoff: float = 1.0):
        self.freq_min = max(0.1, float(freq_min))
        self.beta = max(0.0, float(beta))
        self.dcutoff = max(0.1, float(dcutoff))
        self.prev_x: np.ndarray | None = None
        self.prev_dx = np.zeros(3, dtype=np.float64)
        self.prev_ts: float | None = None

    @staticmethod
    def _alpha(cutoff: float, dt: float) -> float:
        tau = 1.0 / (2.0 * np.pi * max(1e-9, cutoff))
        return float(dt / (dt + tau))

    def __call__(self, value: np.ndarray, timestamp_s: float) -> np.ndarray:
        x = np.asarray(value, dtype=np.float64)
        if self.prev_x is None or self.prev_ts is None:
            self.prev_x = x.copy()
            self.prev_ts = float(timestamp_s)
            self.prev_dx.fill(0.0)
            return x.copy()
        dt = max(1e-5, float(timestamp_s) - self.prev_ts)
        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
        cutoff = self.freq_min + self.beta * np.abs(dx_hat)
        alpha = np.array([self._alpha(float(c), dt) for c in cutoff])
        filtered = alpha * x + (1.0 - alpha) * self.prev_x
        self.prev_x = filtered
        self.prev_dx = dx_hat
        self.prev_ts = float(timestamp_s)
        return filtered.copy()


@dataclass
class _Track:
    position: np.ndarray | None = None
    timestamp_s: float | None = None
    velocity: np.ndarray = None  # type: ignore[assignment]
    position_filter: _OneEuro | None = None

    def __post_init__(self):
        if self.velocity is None:
            self.velocity = np.zeros(3, dtype=np.float64)


class RGBWorldTracker:
    """Estimate hand camera-relative 3D from one RGB stream."""

    def __init__(self, width: int, height: int, *, camera_matrix_=None,
                 distortion=None, focal_scale: float = 0.9,
                 freq_min: float = 5.0, beta: float = 0.05,
                 max_depth: float = 2.5, min_depth: float = 0.20):
        self.width = max(1, int(width))
        self.height = max(1, int(height))
        self.K = (np.asarray(camera_matrix_, dtype=np.float64).reshape(3, 3)
                  if camera_matrix_ is not None else
                  camera_matrix(self.width, self.height, focal_scale))
        self.dist = (np.asarray(distortion, dtype=np.float64).reshape(-1, 1)
                     if distortion is not None else np.zeros((5, 1), np.float64))
        self.freq_min = float(freq_min)
        self.beta = float(beta)
        self.max_depth = float(max_depth)
        self.min_depth = float(min_depth)
        self._tracks: dict[str, _Track] = {}
        # RGB handedness can flip briefly during fast motion or self-occlusion.
        # Keep a small per-track vote state so the preview root anchor does
        # not jump from the left hand slot to the right hand slot.
        self._hand_labels: dict[str, dict[str, object]] = {}

    def stabilize_handedness(self, key: str, label: str | None,
                             votes_required: int = 3) -> str:
        """Return a short-term latched left/right label for one RGB track."""
        value = str(label or "").strip().lower()
        if value not in {"left", "right"}:
            state = self._hand_labels.get(str(key))
            return str(state["stable"]).title() if state else ""
        track_key = str(key)
        state = self._hand_labels.setdefault(
            track_key, {"stable": value, "candidate": value, "count": 0})
        stable = str(state["stable"])
        if value == stable:
            state["candidate"] = value
            state["count"] = 0
        else:
            if state.get("candidate") == value:
                state["count"] = int(state.get("count", 0)) + 1
            else:
                state["candidate"] = value
                state["count"] = 1
            if int(state["count"]) >= max(1, int(votes_required)):
                state["stable"] = value
                state["candidate"] = value
                state["count"] = 0
        return str(state["stable"]).title()

    @staticmethod
    def normalize_local_world(points) -> np.ndarray | None:
        """Center MediaPipe world landmarks on the wrist.

        The Y/Z flips put the points in the display convention used by the
        project: X right, Y up, Z forward.  The returned model remains in the
        original hand scale (approximately metres), which is useful for PnP.
        """
        arr = np.asarray(points, dtype=np.float64)
        if arr.shape != (21, 3) or not np.isfinite(arr).all():
            return None
        out = arr - arr[0]
        # MediaPipe can return a correctly shaped all-zero world model when
        # the optional world-landmark estimate is unavailable.  It is not a
        # valid PnP object model; returning None lets the caller construct the
        # independent RGB/image-scale model instead.
        if (float(np.max(np.abs(out))) <= 1e-6
                or float(np.ptp(out[:, 2])) <= 1e-5):
            return None
        out[:, 1] *= -1.0
        out[:, 2] *= -1.0
        # MediaPipe world landmarks are still an RGB-relative estimate here,
        # not a depth-sensor measurement.  Keep its thumb depth direction
        # consistent with the four finger chains for the shared preview.
        return _cohere_thumb_depth_direction(out)

    @staticmethod
    def orient_palm_facing(points, handedness: str | None) -> np.ndarray:
        """Orient an RGB-relative model toward its semantic palm side.

        RGB landmarks do not contain a reliable absolute palm/back signal.
        For the shared estimated-3D convention, keep the four finger chains
        on one forward depth side of the wrist.  This prevents an RGB/PnP
        mirror solution from making the fingers point away from the hand
        while preserving every landmark's X/Y image geometry.  This is a
        display/estimated-3D convention, not a depth-sensor measurement.
        """
        out = np.asarray(points, dtype=np.float64).copy()
        if out.shape != (21, 3) or not np.isfinite(out).all():
            return out
        # Keep the handedness argument in this shared API because callers
        # already have it and future calibration can use it.  It does not
        # change the relative-Z sign: left and right hands use the same
        # camera-relative depth convention.
        _ = str(handedness or "").strip().lower()

        # Select one stable RGB convention: fingertips lie on the positive
        # relative-Z side of the wrist/palm.  This is the direction used by
        # the fallback anatomical prior and removes the mirrored PnP choice.
        finger_depth = float(np.median(out[[8, 12, 16, 20], 2]) - out[0, 2])
        if finger_depth < -1e-6:
            out[:, 2] *= -1.0
        return _cohere_thumb_depth_direction(out)

    @staticmethod
    def local_from_image(points_2d) -> np.ndarray | None:
        """Build a non-planar relative hand model when World Landmarks fail.

        The 2D detector's ``p.z`` is not guaranteed to be populated.  When
        it is all zero/constant, using it as depth creates a flat skeleton.
        Preserve a real image-space z signal when available; otherwise use a
        small topology-based prior so PnP and the right-side preview retain a
        relative third dimension.  This remains RGB-estimated 3D, not depth
        ground truth.
        """
        arr = np.asarray(points_2d, dtype=np.float64)
        if arr.shape != (21, 3) or not np.isfinite(arr[:, :2]).all():
            return None
        out = np.zeros((21, 3), dtype=np.float64)
        out[:, 0] = (arr[:, 0] - arr[0, 0]) * 0.22
        out[:, 1] = -(arr[:, 1] - arr[0, 1]) * 0.22
        image_z = (-(arr[:, 2] - arr[0, 2]) * 0.08
                   if np.isfinite(arr[:, 2]).all() else None)
        if image_z is not None and float(np.ptp(image_z)) > 1e-5:
            out[:, 2] = image_z
        else:
            image_span = max(
                float(np.ptp(arr[:, 0])), float(np.ptp(arr[:, 1])), 1e-3)
            # Scale the prior with the observed hand size, while keeping it
            # close to the x/y model scale and bounded for distant hands.
            prior_scale = float(np.clip(image_span / 0.14, 0.55, 1.45))
            out[:, 2] = _RGB_FALLBACK_Z_PROFILE * prior_scale
        return _cohere_thumb_depth_direction(out)

    @staticmethod
    def black_glove_reference_local(points_2d) -> np.ndarray | None:
        """Build the same RGB local model used by Black Glove RGB3D.

        The bare-hand MediaPipe path also exposes ``p.z`` and world
        landmarks, but those values use a different local orientation from
        the YOLO/RTMPose black-glove path. Feeding that optional z hint made
        the two RGB workflows render different palm directions. The
        black-glove reference derives depth from the shared anatomical prior
        and observed 2D geometry only.
        """
        arr = np.asarray(points_2d, dtype=np.float64).copy()
        if arr.shape != (21, 3):
            return None
        arr[:, 2] = 0.0
        return RGBWorldTracker.local_from_image(arr)

    def _pixels(self, points_2d) -> np.ndarray | None:
        arr = np.asarray(points_2d, dtype=np.float64)
        if arr.shape != (21, 3) or not np.isfinite(arr[:, :2]).all():
            return None
        return arr[:, :2] * np.array([self.width, self.height], dtype=np.float64)

    def _image_position(self, pixels: np.ndarray,
                        local: np.ndarray) -> np.ndarray | None:
        # Estimate hand depth from the median ratio of corresponding 3D and
        # image bone lengths.  This remains useful when PnP loses an inlier.
        model_pairs = ((0, 5), (0, 9), (0, 13), (0, 17),
                       (5, 9), (9, 13), (13, 17),
                       (5, 8), (9, 12), (13, 16), (17, 20))
        ratios = []
        for a, b in model_pairs:
            model_len = float(np.linalg.norm(local[a] - local[b]))
            image_len = float(np.linalg.norm(pixels[a] - pixels[b]))
            if model_len > 1e-5 and image_len > 1.0:
                ratios.append(model_len / image_len)
        if len(ratios) < 3:
            return None
        depth = float(np.median(ratios) * self.K[0, 0])
        depth = float(np.clip(depth, self.min_depth, self.max_depth))
        u, v = pixels[0]
        x = (u - self.K[0, 2]) * depth / self.K[0, 0]
        y_down = (v - self.K[1, 2]) * depth / self.K[1, 1]
        return np.array([x, -y_down, depth], dtype=np.float64)

    def _pnp(self, pixels: np.ndarray, local: np.ndarray):
        valid = np.isfinite(local).all(axis=1) & np.isfinite(pixels).all(axis=1)
        if int(valid.sum()) < 6:
            return None
        object_points = np.ascontiguousarray(local[valid], dtype=np.float64)
        image_points = np.ascontiguousarray(pixels[valid], dtype=np.float64)
        try:
            ok, rvec, tvec, inliers = cv2.solvePnPRansac(
                object_points, image_points, self.K, self.dist,
                iterationsCount=80, reprojectionError=8.0,
                confidence=0.98,
                flags=cv2.SOLVEPNP_EPNP)
        except Exception:
            ok = False
            rvec = None
            tvec = None
            inliers = None
        if not ok:
            try:
                ok, rvec, tvec = cv2.solvePnP(
                    object_points, image_points, self.K, self.dist,
                    flags=cv2.SOLVEPNP_SQPNP)
                inliers = None
            except Exception:
                return None
        if not ok or rvec is None or tvec is None:
            return None
        try:
            rotation, _ = cv2.Rodrigues(rvec)
            projected, _ = cv2.projectPoints(object_points, rvec, tvec,
                                              self.K, self.dist)
            error = float(np.mean(np.linalg.norm(
                projected.reshape(-1, 2) - image_points, axis=1)))
        except Exception:
            return None
        if not np.isfinite(error):
            return None
        return rotation, np.asarray(tvec, dtype=np.float64).reshape(3), error, inliers

    def update(self, key: str, points_2d, local_points,
               timestamp_s: float, *, preserve_model_geometry: bool = True) -> dict | None:
        """Estimate camera-relative hand points.

        RGB-only 3D is intentionally model-geometry-preserving by default.
        PnP is retained for the camera-relative wrist position/depth estimate,
        but its frame-by-frame rotation is not applied to the hand joints.
        This avoids mirrored or rapidly changing PnP rotations becoming
        visible after the frontend fixes the wrist root.
        """
        pixels = self._pixels(points_2d)
        local = np.asarray(local_points, dtype=np.float64)
        if pixels is None or local.shape != (21, 3) or not np.isfinite(local).all():
            return None
        track = self._tracks.setdefault(
            str(key), _Track(position_filter=_OneEuro(self.freq_min, self.beta)))

        pnp = self._pnp(pixels, local)
        source = "rgb_image_scale"
        reprojection_error = None
        rotation = np.eye(3, dtype=np.float64)
        position = None
        if pnp is not None:
            rotation, tvec, reprojection_error, _inliers = pnp
            if reprojection_error <= 18.0 and self.min_depth <= tvec[2] <= self.max_depth:
                position = np.array([tvec[0], -tvec[1], tvec[2]], dtype=np.float64)
                source = "rgb_pose_pnp"

        if position is None:
            position = self._image_position(pixels, local)
            if position is None and track.position is not None:
                previous_timestamp = (track.timestamp_s
                                      if track.timestamp_s is not None
                                      else float(timestamp_s))
                dt = max(0.0, float(timestamp_s) - previous_timestamp)
                position = track.position + track.velocity * min(dt, 0.10)
                source = "rgb_prediction"
            if position is None:
                return None

        if track.position_filter is None:
            track.position_filter = _OneEuro(self.freq_min, self.beta)
        filtered = track.position_filter(position, float(timestamp_s))
        if track.position is not None and track.timestamp_s is not None:
            dt = max(1e-5, float(timestamp_s) - track.timestamp_s)
            measured_velocity = (filtered - track.position) / dt
            track.velocity = 0.7 * track.velocity + 0.3 * measured_velocity
        track.position = filtered.copy()
        track.timestamp_s = float(timestamp_s)

        # PnP tvec is in the OpenCV camera convention (Y down).  Rotate the
        # local model into that camera frame, then convert every point to the
        # display convention (Y up) so position and landmarks share a frame.
        camera_points = (rotation @ local.T).T + np.array(
            [filtered[0], -filtered[1], filtered[2]], dtype=np.float64)
        landmarks = camera_points.copy()
        landmarks[:, 1] *= -1.0
        if source != "rgb_pose_pnp" or preserve_model_geometry:
            landmarks = local + filtered

        return {
            "landmarks_3d": landmarks.astype(np.float32),
            "landmarks_3d_local": local.astype(np.float32),
            "position": filtered.astype(np.float32),
            "tracking_source": source,
            "coordinate_frame": "camera_relative",
            "unit": "rgb_estimated_meters",
            "metric_3d_available": False,
            "reprojection_error": (float(reprojection_error)
                                    if reprojection_error is not None else None),
        }