File size: 5,847 Bytes
25e8396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ed2aa1
 
 
 
25e8396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ed2aa1
 
25e8396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# SPDX-FileCopyrightText: © 2026 Tenstorrent Inc.
# SPDX-License-Identifier: Apache-2.0
"""Render the predicted MANO mesh on the *original* image.

Projection convention derived from ``hamer/utils/renderer.py`` upstream.
The upstream applies a 180° X-rotation to vertices (trimesh) *and* then
renders with pyrender/OpenGL which does a y-flip (NDC y-up → image y-down).
Those two y-negations cancel.  Our equivalent — without pyrender — is to
project the MANO vertices **directly** without any rotation:

    proj_x = focal * (vx + tx) / (vz + tz) + W/2
    proj_y = focal * (vy + ty) / (vz + tz) + H/2

where (tx, ty, tz) = cam_crop_to_full(pred_cam, ...).  Applying only the
rotation (without the matching y-flip) inverts every vertex's y relative
to the hand center, making a downward-facing palm appear upward.

Triangles are rasterized as alpha-blended polygons with simple diffuse
shading from face-normal · view-vector — no ``pyrender`` / ``trimesh``
dependency.  The shading is enough to make finger curl readable instead of
the flat silhouette we used to ship.
"""
from __future__ import annotations

from pathlib import Path
from typing import Optional, Tuple

import numpy as np
import torch

# matplotlib is imported lazily inside ``overlay_mesh`` so the projection
# helpers (``cam_crop_to_full`` / ``project_mesh``) stay importable in a
# runtime that ships no matplotlib (the serving image).

FOCAL_LENGTH = 5000.0
MODEL_IMAGE_SIZE = 256


def cam_crop_to_full(
    cam_bbox: np.ndarray,
    box_center: np.ndarray,
    box_size: float,
    img_w: int,
    img_h: int,
    focal: float,
) -> np.ndarray:
    """Replicates ``hamer.utils.renderer.cam_crop_to_full`` on numpy.

    Returns the 3-D camera translation ``(tx, ty, tz)`` in original-image
    pixel-scaled world coordinates.
    """
    s, tx_b, ty_b = float(cam_bbox[0]), float(cam_bbox[1]), float(cam_bbox[2])
    cx, cy = float(box_center[0]), float(box_center[1])
    bs = box_size * s + 1e-9
    tz = 2.0 * focal / bs
    tx = 2.0 * (cx - img_w / 2.0) / bs + tx_b
    ty = 2.0 * (cy - img_h / 2.0) / bs + ty_b
    return np.array([tx, ty, tz], dtype=np.float64)


def project_mesh(
    verts: torch.Tensor,
    cam_bbox: torch.Tensor,
    box_center: Tuple[float, float],
    box_size: float,
    img_w: int,
    img_h: int,
    is_right: bool = True,
) -> Tuple[np.ndarray, np.ndarray]:
    """Project MANO vertices to ORIGINAL-image pixel coords.

    For a LEFT hand the upstream pipeline runs the (right-hand) model on a
    horizontally-mirrored input crop, then flips ``cam.x`` and ``verts.x``
    back before rendering — see ``demo.py`` lines 136-138 / 178-179.
    Reproduce that here.

    Returns ``(verts_2d, verts_camera_z)`` — the second array is per-vertex
    camera-space z (positive = depth in front of the camera) used for
    back-to-front face sorting in rendering.
    """
    focal = FOCAL_LENGTH / MODEL_IMAGE_SIZE * max(img_w, img_h)
    cam_bbox_np = cam_bbox.detach().cpu().numpy().copy()
    if not is_right:
        cam_bbox_np[1] *= -1.0
    cam_t = cam_crop_to_full(cam_bbox_np, np.array(box_center), box_size, img_w, img_h, focal)

    v = verts.detach().cpu().numpy().astype(np.float64).copy()
    if not is_right:
        v[:, 0] *= -1.0
    # Direct projection — no rotation applied (upstream's 180° X rotation and
    # pyrender's NDC y-flip cancel; applying only the rotation inverts y).
    v_cam = v + cam_t
    z = v_cam[:, 2]
    proj = focal * v_cam[:, :2] / z[:, None]
    proj[:, 0] += img_w / 2.0
    proj[:, 1] += img_h / 2.0
    return proj, z


def overlay_mesh(
    ax,
    image_uint8: np.ndarray,
    verts_2d: np.ndarray,
    verts_z: np.ndarray,
    faces: np.ndarray,
    color=(0.20, 0.85, 1.00),
    alpha: float = 0.65,
    is_right: bool = True,
) -> None:
    """Rasterize the projected mesh on ``image_uint8`` with face-normal
    diffuse shading + back-to-front depth ordering."""
    from matplotlib.collections import PolyCollection  # noqa: WPS433 (visualisation only)

    h, w = image_uint8.shape[:2]
    ax.imshow(image_uint8)
    ax.set_xlim(0, w)
    ax.set_ylim(h, 0)
    ax.set_aspect("equal")
    ax.axis("off")

    tri_z = verts_z[faces].mean(axis=1)               # (F,) camera z per face
    tris2d = verts_2d[faces]                          # (F, 3, 2)

    # Cull triangles whose mean z is behind the camera or way off-screen.
    inside = (
        (tris2d[..., 0] > -50).all(-1)
        & (tris2d[..., 0] < w + 50).all(-1)
        & (tris2d[..., 1] > -50).all(-1)
        & (tris2d[..., 1] < h + 50).all(-1)
        & (tri_z > 0.05)
    )
    tris2d, tri_z = tris2d[inside], tri_z[inside]

    # Winding: without the 180° X rotation, projecting v directly means
    # vy > 0 maps to larger proj_y (lower in image).  A right-hand
    # front-facing triangle that is CCW in 3D world projects to CW in
    # y-down image (cross_z > 0).  The x-flip for left hands reverses
    # winding, so front-facing left-hand triangles have cross_z < 0.
    e1 = tris2d[:, 1] - tris2d[:, 0]
    e2 = tris2d[:, 2] - tris2d[:, 0]
    cross_z = e1[:, 0] * e2[:, 1] - e1[:, 1] * e2[:, 0]
    winding_sign = 1.0 if is_right else -1.0
    facing = np.clip(winding_sign * cross_z / (np.abs(cross_z).max() + 1e-9), -1, 1)
    diffuse = 0.55 + 0.45 * facing

    # Painter's order: render farthest faces first.
    order = np.argsort(-tri_z)
    tris2d = tris2d[order]
    diffuse = diffuse[order]

    facecolors = np.empty((len(tris2d), 4), dtype=np.float32)
    facecolors[:, 0] = color[0] * diffuse
    facecolors[:, 1] = color[1] * diffuse
    facecolors[:, 2] = color[2] * diffuse
    facecolors[:, 3] = alpha

    coll = PolyCollection(
        tris2d,
        facecolors=facecolors,
        edgecolors=(0.05, 0.10, 0.15, 0.06),
        linewidths=0.25,
    )
    ax.add_collection(coll)