multimodalart HF Staff commited on
Commit
accd864
·
verified ·
1 Parent(s): f15d14c

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/004_sugar_box.glb filter=lfs diff=lfs merge=lfs -text
37
+ examples/006_mustard_bottle.glb filter=lfs diff=lfs merge=lfs -text
38
+ examples/019_pitcher_base.glb filter=lfs diff=lfs merge=lfs -text
39
+ examples/035_power_drill.glb filter=lfs diff=lfs merge=lfs -text
40
+ examples/mustard_bottle_rgb.png filter=lfs diff=lfs merge=lfs -text
41
+ examples/pitcher_base_rgb.png filter=lfs diff=lfs merge=lfs -text
42
+ examples/power_drill_rgb.png filter=lfs diff=lfs merge=lfs -text
43
+ examples/sugar_box_rgb.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,45 @@
1
  ---
2
- title: Foundationpose 6dof
3
- emoji: 👁
4
  colorFrom: green
5
- colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.27.0
8
- python_version: '3.12'
9
  app_file: app.py
 
 
 
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FoundationPose 6-DoF
3
+ emoji: 📦
4
  colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.27.0
 
8
  app_file: app.py
9
+ short_description: 6-DoF pose of novel objects from RGB-D plus a CAD model
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 45m
12
  pinned: false
13
+ license: other
14
  ---
15
 
16
+ # FoundationPose — 6-DoF pose estimation of novel objects
17
+
18
+ [FoundationPose](https://arxiv.org/abs/2312.08344) (NVIDIA, CVPR 2024) estimates the
19
+ full 6-DoF pose of an object it has never seen during training, given
20
+
21
+ * an **RGB image**,
22
+ * the **aligned depth map**,
23
+ * the object's **CAD model**,
24
+ * the **camera intrinsics** (`fx, fy, cx, cy`), and
25
+ * a **2D bounding box** around the object.
26
+
27
+ The demo reproduces the model-based *registration* path of the reference
28
+ implementation: 252 pose hypotheses are sampled on an icosphere (42 viewpoints ×
29
+ 6 in-plane rotations), the translation is initialised from the box centre and the
30
+ median depth inside the box, every hypothesis is refined for 5 passes by the
31
+ refiner network, and the scorer network ranks them.
32
+
33
+ The published checkpoints ([`nvidia/foundationpose`](https://huggingface.co/nvidia/foundationpose))
34
+ are ONNX; they are converted to PyTorch with
35
+ [onnx2torch](https://github.com/ENOT-AutoDL/onnx2torch) at startup, so the whole
36
+ pipeline runs natively on ZeroGPU. Rendering uses
37
+ [nvdiffrast](https://github.com/NVlabs/nvdiffrast).
38
+
39
+ ## Credits and licences
40
+
41
+ * Weights: NVIDIA Open Model License — <https://huggingface.co/nvidia/foundationpose>
42
+ * Reference code: [NVlabs/FoundationPose](https://github.com/NVlabs/FoundationPose)
43
+ * Example scenes and CAD models: [YCB-Video / BOP](https://huggingface.co/datasets/bop-benchmark/ycbv),
44
+ MIT licence, © 2017 University of Washington Robotics and State Estimation Lab —
45
+ one of FoundationPose's own evaluation datasets.
app.py ADDED
@@ -0,0 +1,1023 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FoundationPose — 6-DoF pose estimation of novel objects (NVIDIA, CVPR 2024).
2
+
3
+ Model-based registration path: given an RGB-D frame, the object's CAD model,
4
+ camera intrinsics and a 2D box, sample 252 pose hypotheses on an icosphere,
5
+ refine each with the refiner network, then rank them with the scorer network.
6
+
7
+ Weights: https://huggingface.co/nvidia/foundationpose (ONNX, converted to
8
+ PyTorch at startup with onnx2torch).
9
+ Reference implementation: https://github.com/NVlabs/FoundationPose
10
+ """
11
+
12
+ import spaces # noqa: F401 (must be imported before torch / any CUDA touch)
13
+
14
+ import json
15
+ import math
16
+ import os
17
+ import time
18
+ import traceback
19
+
20
+ import cv2
21
+ import gradio as gr
22
+ import numpy as np
23
+ import torch
24
+ import torch.nn.functional as F
25
+ import trimesh
26
+ from huggingface_hub import hf_hub_download
27
+ from PIL import Image
28
+
29
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
30
+
31
+ import kornia # noqa: E402
32
+ import nvdiffrast.torch as dr # noqa: E402
33
+ from onnx2torch import convert # noqa: E402
34
+
35
+ HERE = os.path.dirname(os.path.abspath(__file__))
36
+ EX_DIR = os.path.join(HERE, "examples")
37
+ REPO_ID = "nvidia/foundationpose"
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Constants taken verbatim from the two official FoundationPose config.yml files
41
+ # (refiner: 2023-10-28-18-33-37, scorer: 2024-01-11-20-02-45).
42
+ # ---------------------------------------------------------------------------
43
+ INPUT_RESIZE = (160, 160)
44
+ REFINE_CROP_RATIO = 1.2
45
+ SCORE_CROP_RATIO = 1.1
46
+ ROT_NORMALIZER = 0.3490658503988659 # 20 degrees, in radians
47
+ GLCAM_IN_CVCAM = np.array(
48
+ [[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, -1, 0], [0, 0, 0, 1]], dtype=np.float64
49
+ )
50
+
51
+ MAX_SIDE = 1280 # inputs larger than this get downscaled (K is rescaled too)
52
+ MAX_TEX_SIZE = 2000
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Models — converted from ONNX to PyTorch and moved to CUDA at module scope so
56
+ # that ZeroGPU can pack the weights and stream them in on the first request.
57
+ # ---------------------------------------------------------------------------
58
+ print("Downloading FoundationPose ONNX weights ...", flush=True)
59
+ _refiner_path = hf_hub_download(REPO_ID, "refiner_net.onnx")
60
+ _scorer_path = hf_hub_download(REPO_ID, "score_net.onnx")
61
+
62
+ print("Converting ONNX -> PyTorch ...", flush=True)
63
+ refine_net = convert(_refiner_path).eval().to("cuda")
64
+ score_net = convert(_scorer_path).eval().to("cuda")
65
+ for _p in list(refine_net.parameters()) + list(score_net.parameters()):
66
+ _p.requires_grad_(False)
67
+ print(
68
+ f"refine_net params: {sum(p.numel() for p in refine_net.parameters()):,} | "
69
+ f"score_net params: {sum(p.numel() for p in score_net.parameters()):,}",
70
+ flush=True,
71
+ )
72
+
73
+ _GLCTX = None
74
+
75
+
76
+ def get_glctx():
77
+ """nvdiffrast CUDA raster context — created lazily inside the GPU worker."""
78
+ global _GLCTX
79
+ if _GLCTX is None:
80
+ _GLCTX = dr.RasterizeCudaContext()
81
+ return _GLCTX
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Geometry / rendering helpers (ports of FoundationPose/Utils.py)
86
+ # ---------------------------------------------------------------------------
87
+ def to_homo_torch(pts):
88
+ ones = torch.ones((*pts.shape[:-1], 1), dtype=pts.dtype, device=pts.device)
89
+ return torch.cat((pts, ones), dim=-1)
90
+
91
+
92
+ def transform_pts(pts, tf):
93
+ if len(tf.shape) >= 3 and tf.shape[-3] != pts.shape[-2]:
94
+ tf = tf[..., None, :, :]
95
+ return (tf[..., :-1, :-1] @ pts[..., None] + tf[..., :-1, -1:])[..., 0]
96
+
97
+
98
+ def transform_dirs(dirs, tf):
99
+ if len(tf.shape) >= 3 and tf.shape[-3] != dirs.shape[-2]:
100
+ tf = tf[..., None, :, :]
101
+ return (tf[..., :3, :3] @ dirs[..., None])[..., 0]
102
+
103
+
104
+ def so3_exp_map(log_rot, eps=1e-4):
105
+ """Rodrigues' formula — matches pytorch3d.transforms.so3_exp_map."""
106
+ nrms = (log_rot * log_rot).sum(1)
107
+ rot_angles = torch.clamp(nrms, eps).sqrt()
108
+ rot_angles_inv = 1.0 / rot_angles
109
+ fac1 = rot_angles_inv * rot_angles.sin()
110
+ fac2 = rot_angles_inv * rot_angles_inv * (1.0 - rot_angles.cos())
111
+ skews = torch.zeros(
112
+ (log_rot.shape[0], 3, 3), dtype=log_rot.dtype, device=log_rot.device
113
+ )
114
+ skews[:, 0, 1] = -log_rot[:, 2]
115
+ skews[:, 0, 2] = log_rot[:, 1]
116
+ skews[:, 1, 0] = log_rot[:, 2]
117
+ skews[:, 1, 2] = -log_rot[:, 0]
118
+ skews[:, 2, 0] = -log_rot[:, 1]
119
+ skews[:, 2, 1] = log_rot[:, 0]
120
+ skews_sq = torch.bmm(skews, skews)
121
+ eye = torch.eye(3, dtype=log_rot.dtype, device=log_rot.device)[None]
122
+ return fac1[:, None, None] * skews + fac2[:, None, None] * skews_sq + eye
123
+
124
+
125
+ def egocentric_delta_pose_to_pose(a_in_cam, trans_delta, rot_mat_delta):
126
+ b = torch.eye(4, dtype=torch.float, device=a_in_cam.device)[None].repeat(
127
+ len(a_in_cam), 1, 1
128
+ )
129
+ b[:, :3, 3] = a_in_cam[:, :3, 3] + trans_delta
130
+ b[:, :3, :3] = rot_mat_delta @ a_in_cam[:, :3, :3]
131
+ return b
132
+
133
+
134
+ def projection_matrix_from_intrinsics(K, height, width, znear, zfar):
135
+ """Hartley-Zisserman K -> OpenGL projection matrix ('y_down' convention)."""
136
+ w, h, nc, fc = width, height, znear, zfar
137
+ depth = float(fc - nc)
138
+ q = -(fc + nc) / depth
139
+ qn = -2 * (fc * nc) / depth
140
+ return np.array(
141
+ [
142
+ [2 * K[0, 0] / w, -2 * K[0, 1] / w, (-2 * K[0, 2] + w) / w, 0],
143
+ [0, 2 * K[1, 1] / h, (2 * K[1, 2] - h) / h, 0],
144
+ [0, 0, q, qn],
145
+ [0, 0, -1, 0],
146
+ ]
147
+ )
148
+
149
+
150
+ def depth2xyzmap_t(depth, K):
151
+ """depth (H,W) torch -> xyz map (H,W,3) torch, in camera frame."""
152
+ H, W = depth.shape[-2:]
153
+ vs, us = torch.meshgrid(
154
+ torch.arange(H, device=depth.device, dtype=torch.float),
155
+ torch.arange(W, device=depth.device, dtype=torch.float),
156
+ indexing="ij",
157
+ )
158
+ xs = (us - K[0, 2]) * depth / K[0, 0]
159
+ ys = (vs - K[1, 2]) * depth / K[1, 1]
160
+ xyz = torch.stack([xs, ys, depth], dim=-1)
161
+ xyz[depth < 0.001] = 0
162
+ return xyz
163
+
164
+
165
+ def depth2xyzmap_batch_t(depths, K):
166
+ """depths (B,H,W) -> (B,H,W,3)."""
167
+ B, H, W = depths.shape
168
+ vs, us = torch.meshgrid(
169
+ torch.arange(H, device=depths.device, dtype=torch.float),
170
+ torch.arange(W, device=depths.device, dtype=torch.float),
171
+ indexing="ij",
172
+ )
173
+ xs = (us[None] - K[0, 2]) * depths / K[0, 0]
174
+ ys = (vs[None] - K[1, 2]) * depths / K[1, 1]
175
+ xyz = torch.stack([xs, ys, depths], dim=-1)
176
+ xyz[depths < 0.001] = 0
177
+ return xyz
178
+
179
+
180
+ def _unfold(depth, radius):
181
+ """(H,W) -> patches (K,H,W) and an in-bounds mask (K,H,W)."""
182
+ k = 2 * radius + 1
183
+ d = depth[None, None]
184
+ patches = F.unfold(d, kernel_size=k, padding=radius).reshape(k * k, *depth.shape)
185
+ inb = F.unfold(
186
+ torch.ones_like(d), kernel_size=k, padding=radius
187
+ ).reshape(k * k, *depth.shape) > 0.5
188
+ return patches, inb
189
+
190
+
191
+ def erode_depth(depth, radius=2, depth_diff_thres=0.001, ratio_thres=0.8, zfar=100.0):
192
+ """Pure-torch port of FoundationPose's warp erode_depth kernel."""
193
+ patches, inb = _unfold(depth, radius)
194
+ bad = (patches < 0.001) | (patches >= zfar) | ((patches - depth[None]).abs() > depth_diff_thres)
195
+ bad_cnt = (bad & inb).sum(0).float()
196
+ total = inb.sum(0).float().clamp(min=1)
197
+ out = torch.where(bad_cnt / total > ratio_thres, torch.zeros_like(depth), depth)
198
+ out = torch.where((depth < 0.001) | (depth >= zfar), torch.zeros_like(depth), out)
199
+ return out
200
+
201
+
202
+ def bilateral_filter_depth(depth, radius=2, zfar=100.0, sigmaD=2.0, sigmaR=100000.0):
203
+ """Pure-torch port of FoundationPose's warp bilateral_filter_depth kernel."""
204
+ k = 2 * radius + 1
205
+ patches, inb = _unfold(depth, radius)
206
+ valid = (patches >= 0.001) & (patches < zfar) & inb
207
+ num_valid = valid.sum(0).float()
208
+ mean_depth = (patches * valid).sum(0) / num_valid.clamp(min=1)
209
+
210
+ dv, du = torch.meshgrid(
211
+ torch.arange(-radius, radius + 1, device=depth.device, dtype=torch.float),
212
+ torch.arange(-radius, radius + 1, device=depth.device, dtype=torch.float),
213
+ indexing="ij",
214
+ )
215
+ # kernel loop order is u (cols) outer, v (rows) inner -> index = du*k + dv
216
+ # F.unfold orders the k*k channels row-major: index = (dv+r)*k + (du+r)
217
+ spatial = torch.exp(-(du * du + dv * dv) / (2.0 * sigmaD * sigmaD)).reshape(k * k, 1, 1)
218
+
219
+ sel = valid & ((patches - mean_depth[None]).abs() < 0.01)
220
+ rng = torch.exp(-((depth[None] - patches) ** 2) / (2.0 * sigmaR * sigmaR))
221
+ w = spatial * rng * sel
222
+ sum_w = w.sum(0)
223
+ out = torch.where(
224
+ (num_valid > 0) & (sum_w > 0), (w * patches).sum(0) / sum_w.clamp(min=1e-12), torch.zeros_like(depth)
225
+ )
226
+ return out
227
+
228
+
229
+ def sample_views_icosphere(n_views=40):
230
+ sub = 1
231
+ while True:
232
+ m = trimesh.creation.icosphere(subdivisions=sub, radius=1)
233
+ if m.vertices.shape[0] >= n_views:
234
+ break
235
+ sub += 1
236
+ cam_in_obs = np.tile(np.eye(4)[None], (len(m.vertices), 1, 1))
237
+ cam_in_obs[:, :3, 3] = m.vertices
238
+ up = np.array([0, 0, 1.0])
239
+ z = -cam_in_obs[:, :3, 3].copy()
240
+ z /= np.linalg.norm(z, axis=-1, keepdims=True)
241
+ x = np.cross(up.reshape(1, 3), z)
242
+ x[(x == 0).all(axis=-1)] = [1, 0, 0]
243
+ x /= np.linalg.norm(x, axis=-1, keepdims=True)
244
+ y = np.cross(z, x)
245
+ y /= np.linalg.norm(y, axis=-1, keepdims=True)
246
+ cam_in_obs[:, :3, 0] = x
247
+ cam_in_obs[:, :3, 1] = y
248
+ cam_in_obs[:, :3, 2] = z
249
+ return cam_in_obs
250
+
251
+
252
+ def make_rotation_grid(min_n_views=40, inplane_step=60):
253
+ """252 pose hypotheses: 42 icosphere viewpoints x 6 in-plane rotations.
254
+
255
+ The reference additionally calls mycpp.cluster_poses(30deg, ...); the minimum
256
+ pairwise geodesic distance in this grid is 31.7deg so that call is a no-op,
257
+ which matches the 252-row output documented on the model card.
258
+ """
259
+ cam_in_obs = sample_views_icosphere(min_n_views)
260
+ grid = []
261
+ for i in range(len(cam_in_obs)):
262
+ for ang in np.deg2rad(np.arange(0, 360, inplane_step)):
263
+ rz = np.eye(4)
264
+ c, s = np.cos(ang), np.sin(ang)
265
+ rz[0, 0], rz[0, 1], rz[1, 0], rz[1, 1] = c, -s, s, c
266
+ grid.append(np.linalg.inv(cam_in_obs[i] @ rz))
267
+ return np.asarray(grid)
268
+
269
+
270
+ ROT_GRID = make_rotation_grid()
271
+ print(f"rotation grid: {ROT_GRID.shape}", flush=True)
272
+
273
+
274
+ def compute_crop_window_tf_batch(poses, K, crop_ratio, out_size, mesh_diameter):
275
+ """box_3d crop: a square window around the projected object centre."""
276
+ B = len(poses)
277
+ r = mesh_diameter * crop_ratio / 2
278
+ offsets = torch.tensor(
279
+ [[0, 0, 0], [r, 0, 0], [-r, 0, 0], [0, r, 0], [0, -r, 0]],
280
+ device=poses.device,
281
+ dtype=torch.float,
282
+ )
283
+ pts = poses[:, :3, 3].reshape(-1, 1, 3) + offsets.reshape(1, -1, 3)
284
+ Kt = torch.as_tensor(K, device=poses.device, dtype=torch.float)
285
+ projected = (Kt @ pts.reshape(-1, 3).T).T
286
+ uvs = (projected[:, :2] / projected[:, 2:3]).reshape(B, -1, 2)
287
+ center = uvs[:, 0]
288
+ rad = torch.abs(uvs - center.reshape(-1, 1, 2)).reshape(B, -1).max(dim=-1)[0]
289
+ left = (center[:, 0] - rad).round()
290
+ right = (center[:, 0] + rad).round()
291
+ top = (center[:, 1] - rad).round()
292
+ bottom = (center[:, 1] + rad).round()
293
+ tf = torch.eye(3, device=poses.device, dtype=torch.float)[None].repeat(B, 1, 1)
294
+ tf[:, 0, 2] = -left
295
+ tf[:, 1, 2] = -top
296
+ new_tf = torch.eye(3, device=poses.device, dtype=torch.float)[None].repeat(B, 1, 1)
297
+ new_tf[:, 0, 0] = out_size[0] / (right - left).clamp(min=1)
298
+ new_tf[:, 1, 1] = out_size[1] / (bottom - top).clamp(min=1)
299
+ return new_tf @ tf
300
+
301
+
302
+ def _material_image(material):
303
+ for attr in ("baseColorTexture", "image", "emissiveTexture"):
304
+ img = getattr(material, attr, None)
305
+ if img is not None:
306
+ return img
307
+ return None
308
+
309
+
310
+ def make_mesh_tensors(mesh, max_tex_size=MAX_TEX_SIZE):
311
+ t = {}
312
+ img = None
313
+ if isinstance(mesh.visual, trimesh.visual.texture.TextureVisuals):
314
+ img = _material_image(mesh.visual.material)
315
+ if img is not None and getattr(mesh.visual, "uv", None) is not None:
316
+ arr = np.array(img.convert("RGB"))[..., :3]
317
+ big = max(arr.shape[0], arr.shape[1])
318
+ if big > max_tex_size:
319
+ s = max_tex_size / big
320
+ arr = cv2.resize(arr, fx=s, fy=s, dsize=None)
321
+ t["tex"] = torch.as_tensor(
322
+ np.ascontiguousarray(arr), device="cuda", dtype=torch.float
323
+ )[None] / 255.0
324
+ t["uv_idx"] = torch.as_tensor(mesh.faces, device="cuda", dtype=torch.int)
325
+ uv = torch.as_tensor(np.asarray(mesh.visual.uv), device="cuda", dtype=torch.float).clone()
326
+ uv[:, 1] = 1 - uv[:, 1]
327
+ t["uv"] = uv
328
+ else:
329
+ vc = None
330
+ try:
331
+ vc = np.asarray(mesh.visual.to_color().vertex_colors)
332
+ except Exception:
333
+ pass
334
+ if vc is None or len(vc) != len(mesh.vertices):
335
+ vc = np.tile(np.array([[160, 160, 160, 255]]), (len(mesh.vertices), 1))
336
+ t["vertex_color"] = torch.as_tensor(
337
+ vc[..., :3], device="cuda", dtype=torch.float
338
+ ) / 255.0
339
+ t["pos"] = torch.tensor(np.asarray(mesh.vertices), device="cuda", dtype=torch.float)
340
+ t["faces"] = torch.tensor(np.asarray(mesh.faces), device="cuda", dtype=torch.int)
341
+ t["vnormals"] = torch.tensor(
342
+ np.asarray(mesh.vertex_normals), device="cuda", dtype=torch.float
343
+ )
344
+ return t
345
+
346
+
347
+ def nvdiffrast_render(
348
+ K, H, W, ob_in_cams, mesh_tensors, output_size=None, bbox2d=None, use_light=True, extra=None
349
+ ):
350
+ glctx = get_glctx()
351
+ pos = mesh_tensors["pos"]
352
+ pos_idx = mesh_tensors["faces"]
353
+ has_tex = "tex" in mesh_tensors
354
+
355
+ glcam = torch.tensor(GLCAM_IN_CVCAM, device="cuda", dtype=torch.float)[None]
356
+ ob_in_glcams = glcam @ ob_in_cams
357
+ proj = projection_matrix_from_intrinsics(K, height=H, width=W, znear=0.001, zfar=100)
358
+ proj = torch.as_tensor(proj.reshape(-1, 4, 4), device="cuda", dtype=torch.float)
359
+ mtx = proj @ ob_in_glcams
360
+
361
+ if output_size is None:
362
+ output_size = np.asarray([H, W])
363
+
364
+ pts_cam = transform_pts(pos, ob_in_cams)
365
+ pos_homo = to_homo_torch(pos)
366
+ pos_clip = (mtx[:, None] @ pos_homo[None, ..., None])[..., 0]
367
+ if bbox2d is not None:
368
+ l = bbox2d[:, 0]
369
+ t_ = H - bbox2d[:, 1]
370
+ r = bbox2d[:, 2]
371
+ b = H - bbox2d[:, 3]
372
+ tf = torch.eye(4, dtype=torch.float, device="cuda")[None].repeat(len(ob_in_cams), 1, 1)
373
+ tf[:, 0, 0] = W / (r - l)
374
+ tf[:, 1, 1] = H / (t_ - b)
375
+ tf[:, 3, 0] = (W - r - l) / (r - l)
376
+ tf[:, 3, 1] = (H - t_ - b) / (t_ - b)
377
+ pos_clip = pos_clip @ tf
378
+
379
+ rast_out, _ = dr.rasterize(
380
+ glctx, pos_clip, pos_idx, resolution=np.asarray(output_size, dtype=np.int64)
381
+ )
382
+ xyz_map, _ = dr.interpolate(pts_cam, rast_out, pos_idx)
383
+ depth = xyz_map[..., 2]
384
+ if has_tex:
385
+ texc, _ = dr.interpolate(mesh_tensors["uv"], rast_out, mesh_tensors["uv_idx"])
386
+ color = dr.texture(mesh_tensors["tex"], texc, filter_mode="linear")
387
+ else:
388
+ color, _ = dr.interpolate(mesh_tensors["vertex_color"], rast_out, pos_idx)
389
+
390
+ if use_light:
391
+ vnormals_cam = transform_dirs(mesh_tensors["vnormals"], ob_in_cams)
392
+ light_dir_neg = -torch.as_tensor(
393
+ np.array([0, 0, 1.0]), dtype=torch.float, device="cuda"
394
+ )
395
+ diffuse = (
396
+ (F.normalize(vnormals_cam, dim=-1) * F.normalize(light_dir_neg, dim=-1))
397
+ .sum(dim=-1)
398
+ .clip(0, 1)[..., None]
399
+ )
400
+ diffuse_map, _ = dr.interpolate(diffuse, rast_out, pos_idx)
401
+ color = color * 0.8 + diffuse_map * color * 0.5
402
+
403
+ color = color.clip(0, 1)
404
+ color = color * torch.clamp(rast_out[..., -1:], 0, 1)
405
+ color = torch.flip(color, dims=[1])
406
+ depth = torch.flip(depth, dims=[1])
407
+ if extra is not None:
408
+ extra["xyz_map"] = torch.flip(xyz_map, dims=[1])
409
+ return color, depth
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Refiner / scorer
414
+ # ---------------------------------------------------------------------------
415
+ def _warp_chunked(src, tfs, dsize, mode):
416
+ """Warp one (C,H,W) source through B different homographies, in chunks.
417
+
418
+ Expanding the source to (B,C,H,W) up front costs ~1 GB at B=252 for a VGA
419
+ frame; chunking keeps the transient under ~256 MB.
420
+ """
421
+ C, H, W = src.shape
422
+ chunk = max(1, int(2.5e8 / max(C * H * W * 4, 1)))
423
+ outs = []
424
+ for b in range(0, len(tfs), chunk):
425
+ n = len(tfs[b : b + chunk])
426
+ outs.append(
427
+ kornia.geometry.transform.warp_perspective(
428
+ src[None].expand(n, -1, -1, -1).contiguous(),
429
+ tfs[b : b + chunk],
430
+ dsize=dsize,
431
+ mode=mode,
432
+ align_corners=False,
433
+ )
434
+ )
435
+ return torch.cat(outs, dim=0)
436
+
437
+
438
+ def _render_hypotheses(poses, mesh_tensors, K, H, W, tf_to_crops, chunk=128):
439
+ """Render each hypothesis directly into its own 160x160 crop window."""
440
+ bbox2d_crop = torch.as_tensor(
441
+ np.array([0, 0, INPUT_RESIZE[0] - 1, INPUT_RESIZE[1] - 1]).reshape(2, 2),
442
+ device="cuda",
443
+ dtype=torch.float,
444
+ )
445
+ bbox2d_ori = transform_pts(bbox2d_crop, tf_to_crops.inverse()).reshape(-1, 4)
446
+ rgb_rs, xyz_rs = [], []
447
+ for b in range(0, len(poses), chunk):
448
+ extra = {}
449
+ rgb_r, _ = nvdiffrast_render(
450
+ K=K,
451
+ H=H,
452
+ W=W,
453
+ ob_in_cams=poses[b : b + chunk],
454
+ mesh_tensors=mesh_tensors,
455
+ output_size=INPUT_RESIZE,
456
+ bbox2d=bbox2d_ori[b : b + chunk],
457
+ use_light=True,
458
+ extra=extra,
459
+ )
460
+ rgb_rs.append(rgb_r)
461
+ xyz_rs.append(extra["xyz_map"])
462
+ rgb_rs = torch.cat(rgb_rs, dim=0).permute(0, 3, 1, 2) * 255
463
+ xyz_rs = torch.cat(xyz_rs, dim=0).permute(0, 3, 1, 2)
464
+ return rgb_rs, xyz_rs
465
+
466
+
467
+ def _normalize_xyz(xyz, pose_t, mesh_radius, z_thres):
468
+ """FoundationPose transform_depth_to_xyzmap (normalize_xyz=True branch)."""
469
+ bs = xyz.shape[0]
470
+ invalid = xyz[:, 2:3] < z_thres
471
+ xyz = xyz - pose_t.reshape(bs, 3, 1, 1)
472
+ xyz = xyz * (1.0 / mesh_radius)
473
+ invalid = invalid.expand(bs, 3, -1, -1) | (torch.abs(xyz) >= 2)
474
+ xyz = xyz.masked_fill(invalid, 0)
475
+ return xyz
476
+
477
+
478
+ @torch.inference_mode()
479
+ def refine_poses(poses, mesh_tensors, rgb_t, xyz_map_t, K, mesh_diameter, iterations, net_bs=512):
480
+ H, W = rgb_t.shape[:2]
481
+ B_in_cams = poses
482
+ for _ in range(iterations):
483
+ tf_to_crops = compute_crop_window_tf_batch(
484
+ B_in_cams, K, REFINE_CROP_RATIO, INPUT_RESIZE, mesh_diameter
485
+ )
486
+ rgb_as, xyz_as = _render_hypotheses(B_in_cams, mesh_tensors, K, H, W, tf_to_crops)
487
+ B = len(B_in_cams)
488
+ rgb_bs = _warp_chunked(
489
+ rgb_t.permute(2, 0, 1).contiguous(), tf_to_crops, INPUT_RESIZE, "bilinear"
490
+ )
491
+ xyz_bs = _warp_chunked(
492
+ xyz_map_t.permute(2, 0, 1).contiguous(), tf_to_crops, INPUT_RESIZE, "nearest"
493
+ )
494
+ rgb_as = rgb_as / 255.0
495
+ rgb_bs = rgb_bs / 255.0
496
+ pose_t = B_in_cams[:, :3, 3]
497
+ radius = mesh_diameter / 2
498
+ xyz_as = _normalize_xyz(xyz_as, pose_t, radius, 0.001)
499
+ xyz_bs = _normalize_xyz(xyz_bs, pose_t, radius, 0.001)
500
+
501
+ out_poses = []
502
+ for b in range(0, B, net_bs):
503
+ A = torch.cat([rgb_as[b : b + net_bs], xyz_as[b : b + net_bs]], dim=1).float()
504
+ Bt = torch.cat([rgb_bs[b : b + net_bs], xyz_bs[b : b + net_bs]], dim=1).float()
505
+ trans, rot = refine_net(A, Bt)
506
+ trans = trans.float()
507
+ rot = rot.float()
508
+ trans_delta = trans * (mesh_diameter / 2)
509
+ rot_mat_delta = torch.tanh(rot) * ROT_NORMALIZER
510
+ rot_mat_delta = so3_exp_map(rot_mat_delta).permute(0, 2, 1)
511
+ out_poses.append(
512
+ egocentric_delta_pose_to_pose(
513
+ B_in_cams[b : b + net_bs], trans_delta, rot_mat_delta
514
+ )
515
+ )
516
+ B_in_cams = torch.cat(out_poses, dim=0).reshape(-1, 4, 4)
517
+ return B_in_cams
518
+
519
+
520
+ @torch.inference_mode()
521
+ def score_poses(poses, mesh_tensors, rgb_t, depth_t, K, mesh_diameter, net_bs=512):
522
+ H, W = rgb_t.shape[:2]
523
+ B = len(poses)
524
+ tf_to_crops = compute_crop_window_tf_batch(
525
+ poses, K, SCORE_CROP_RATIO, INPUT_RESIZE, mesh_diameter
526
+ )
527
+ rgb_as, xyz_as = _render_hypotheses(poses, mesh_tensors, K, H, W, tf_to_crops)
528
+ rgb_bs = _warp_chunked(
529
+ rgb_t.permute(2, 0, 1).contiguous(), tf_to_crops, INPUT_RESIZE, "bilinear"
530
+ )
531
+ # The scorer reconstructs xyzB by warping the cropped depth back to full
532
+ # resolution, back-projecting there, then warping into the crop again.
533
+ crop_to_oris = tf_to_crops.inverse()
534
+ depth_bs = _warp_chunked(
535
+ depth_t[None].contiguous(), tf_to_crops, INPUT_RESIZE, "nearest"
536
+ )
537
+ chunk = max(1, int(1.2e8 / max(H * W, 1)))
538
+ xyz_bs = []
539
+ for b in range(0, B, chunk):
540
+ d_ori = kornia.geometry.transform.warp_perspective(
541
+ depth_bs[b : b + chunk],
542
+ crop_to_oris[b : b + chunk],
543
+ dsize=(H, W),
544
+ mode="nearest",
545
+ align_corners=False,
546
+ )
547
+ xyz_ori = depth2xyzmap_batch_t(d_ori[:, 0], K).permute(0, 3, 1, 2)
548
+ xyz_bs.append(
549
+ kornia.geometry.transform.warp_perspective(
550
+ xyz_ori,
551
+ tf_to_crops[b : b + chunk],
552
+ dsize=INPUT_RESIZE,
553
+ mode="nearest",
554
+ align_corners=False,
555
+ )
556
+ )
557
+ del d_ori, xyz_ori
558
+ xyz_bs = torch.cat(xyz_bs, dim=0)
559
+
560
+ rgb_as = rgb_as / 255.0
561
+ rgb_bs = rgb_bs / 255.0
562
+ pose_t = poses[:, :3, 3]
563
+ radius = mesh_diameter / 2
564
+ xyz_as = _normalize_xyz(xyz_as, pose_t, radius, 0.1)
565
+ xyz_bs = _normalize_xyz(xyz_bs, pose_t, radius, 0.1)
566
+
567
+ scores = []
568
+ for b in range(0, B, net_bs):
569
+ A = torch.cat([rgb_as[b : b + net_bs], xyz_as[b : b + net_bs]], dim=1).float()
570
+ Bt = torch.cat([rgb_bs[b : b + net_bs], xyz_bs[b : b + net_bs]], dim=1).float()
571
+ scores.append(score_net(A, Bt).float().reshape(-1))
572
+ return torch.cat(scores, dim=0)
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Drawing
577
+ # ---------------------------------------------------------------------------
578
+ def _project(pt, K, ob_in_cam):
579
+ p = K @ ((ob_in_cam @ pt.reshape(4, 1))[:3, :])
580
+ p = p.reshape(-1) / p.reshape(-1)[2]
581
+ return tuple(np.round(p[:2]).astype(int).tolist())
582
+
583
+
584
+ def draw_xyz_axis(img_rgb, ob_in_cam, K, scale=0.1, thickness=3):
585
+ img = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
586
+ o = _project(np.array([0, 0, 0, 1.0]), K, ob_in_cam)
587
+ for vec, col in (
588
+ (np.array([scale, 0, 0, 1.0]), (0, 0, 255)),
589
+ (np.array([0, scale, 0, 1.0]), (0, 255, 0)),
590
+ (np.array([0, 0, scale, 1.0]), (255, 0, 0)),
591
+ ):
592
+ img = cv2.arrowedLine(
593
+ img, o, _project(vec, K, ob_in_cam), color=col, thickness=thickness,
594
+ line_type=cv2.LINE_AA, tipLength=0.15,
595
+ )
596
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
597
+
598
+
599
+ def draw_posed_3d_box(K, img, ob_in_cam, bbox, line_color=(0, 255, 0), linewidth=2):
600
+ xmin, ymin, zmin = bbox.min(axis=0)
601
+ xmax, ymax, zmax = bbox.max(axis=0)
602
+
603
+ def line3d(start, end, im):
604
+ pts = np.stack((start, end), axis=0).reshape(-1, 3)
605
+ pts = (ob_in_cam @ np.concatenate([pts, np.ones((2, 1))], axis=-1).T).T[:, :3]
606
+ pr = (K @ pts.T).T
607
+ uv = np.round(pr[:, :2] / pr[:, 2].reshape(-1, 1)).astype(int)
608
+ return cv2.line(
609
+ im, uv[0].tolist(), uv[1].tolist(), color=line_color,
610
+ thickness=linewidth, lineType=cv2.LINE_AA,
611
+ )
612
+
613
+ for y in [ymin, ymax]:
614
+ for z in [zmin, zmax]:
615
+ img = line3d(np.array([xmin, y, z]), np.array([xmax, y, z]), img)
616
+ for x in [xmin, xmax]:
617
+ for z in [zmin, zmax]:
618
+ img = line3d(np.array([x, ymin, z]), np.array([x, ymax, z]), img)
619
+ for x in [xmin, xmax]:
620
+ for y in [ymin, ymax]:
621
+ img = line3d(np.array([x, y, zmin]), np.array([x, y, zmax]), img)
622
+ return img
623
+
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # Input parsing
627
+ # ---------------------------------------------------------------------------
628
+ def load_depth(path, unit):
629
+ ext = os.path.splitext(path)[1].lower()
630
+ if ext == ".npy":
631
+ d = np.load(path).astype(np.float32)
632
+ else:
633
+ d = np.array(Image.open(path)).astype(np.float32)
634
+ if d.ndim == 3:
635
+ d = d[..., 0]
636
+ scale = {"millimetres (uint16 PNG)": 1e-3, "metres (float)": 1.0, "0.1 mm": 1e-4}[unit]
637
+ return d * scale
638
+
639
+
640
+ def load_mesh(path, unit):
641
+ obj = trimesh.load(path, process=False, force="mesh")
642
+ if isinstance(obj, trimesh.Scene):
643
+ obj = obj.to_geometry()
644
+ if not isinstance(obj, trimesh.Trimesh):
645
+ raise gr.Error("Could not read a triangle mesh from the uploaded CAD model.")
646
+ ext = float(np.linalg.norm(obj.extents))
647
+ if unit == "auto":
648
+ if ext > 10.0:
649
+ obj.vertices = np.asarray(obj.vertices) * 1e-3
650
+ note = "auto-detected millimetres"
651
+ else:
652
+ note = "auto-detected metres"
653
+ elif unit == "millimetres":
654
+ obj.vertices = np.asarray(obj.vertices) * 1e-3
655
+ note = "millimetres"
656
+ else:
657
+ note = "metres"
658
+ return obj, note
659
+
660
+
661
+ def parse_floats(text, n, name):
662
+ try:
663
+ vals = [float(x) for x in str(text).replace(";", ",").replace(" ", ",").split(",") if x != ""]
664
+ except ValueError:
665
+ raise gr.Error(f"Could not parse {name}: {text!r}")
666
+ if len(vals) != n:
667
+ raise gr.Error(f"{name} needs {n} numbers, got {len(vals)}: {text!r}")
668
+ return vals
669
+
670
+
671
+ def mesh_diameter(mesh):
672
+ """Exact diameter via the convex hull (deterministic form of the reference's
673
+ random-sample pairwise max distance)."""
674
+ try:
675
+ pts = np.asarray(mesh.convex_hull.vertices)
676
+ except Exception:
677
+ pts = np.asarray(mesh.vertices)
678
+ if len(pts) > 4000:
679
+ idx = np.linspace(0, len(pts) - 1, 4000).astype(int)
680
+ pts = pts[idx]
681
+ d = np.linalg.norm(pts[None] - pts[:, None], axis=-1)
682
+ return float(d.max())
683
+
684
+
685
+ # ---------------------------------------------------------------------------
686
+ # Inference
687
+ # ---------------------------------------------------------------------------
688
+ def _run_pose(
689
+ rgb_image,
690
+ depth_file,
691
+ mesh_file,
692
+ bbox,
693
+ intrinsics,
694
+ depth_unit,
695
+ mesh_unit,
696
+ refine_iterations,
697
+ progress,
698
+ ):
699
+ t_start = time.time()
700
+ if rgb_image is None:
701
+ raise gr.Error("Please provide an RGB image.")
702
+ if depth_file is None:
703
+ raise gr.Error("Please provide a depth map (16-bit PNG or .npy).")
704
+ if mesh_file is None:
705
+ raise gr.Error("Please provide a CAD model of the object.")
706
+
707
+ progress(0.05, desc="Reading inputs")
708
+ rgb = np.array(Image.open(rgb_image).convert("RGB"))
709
+ depth = load_depth(depth_file, depth_unit)
710
+ if depth.shape[:2] != rgb.shape[:2]:
711
+ depth = cv2.resize(depth, (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_NEAREST)
712
+
713
+ fx, fy, cx, cy = parse_floats(intrinsics, 4, "intrinsics (fx,fy,cx,cy)")
714
+ x1, y1, x2, y2 = parse_floats(bbox, 4, "bounding box (x1,y1,x2,y2)")
715
+ K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64)
716
+
717
+ H, W = rgb.shape[:2]
718
+ if max(H, W) > MAX_SIDE:
719
+ s = MAX_SIDE / max(H, W)
720
+ rgb = cv2.resize(rgb, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
721
+ depth = cv2.resize(depth, (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_NEAREST)
722
+ K[:2] *= s
723
+ x1, y1, x2, y2 = [v * s for v in (x1, y1, x2, y2)]
724
+ H, W = rgb.shape[:2]
725
+
726
+ x1, x2 = sorted([int(round(x1)), int(round(x2))])
727
+ y1, y2 = sorted([int(round(y1)), int(round(y2))])
728
+ x1 = max(0, min(W - 2, x1)); x2 = max(x1 + 1, min(W - 1, x2))
729
+ y1 = max(0, min(H - 2, y1)); y2 = max(y1 + 1, min(H - 1, y2))
730
+
731
+ # nvdiffrast's CUDA rasteriser needs both dimensions divisible by 8. Pad on
732
+ # the right/bottom so pixel coordinates (and therefore K) are unchanged.
733
+ H0, W0 = H, W
734
+ Hp, Wp = (H + 7) // 8 * 8, (W + 7) // 8 * 8
735
+ if (Hp, Wp) != (H, W):
736
+ rgb = np.pad(rgb, ((0, Hp - H), (0, Wp - W), (0, 0)))
737
+ depth = np.pad(depth, ((0, Hp - H), (0, Wp - W)))
738
+ H, W = Hp, Wp
739
+
740
+ mesh, unit_note = load_mesh(mesh_file, mesh_unit)
741
+ progress(0.15, desc="Preparing mesh")
742
+
743
+ # Centre the mesh on its AABB centre (reference: FoundationPose.reset_object).
744
+ model_center = (np.asarray(mesh.vertices).min(axis=0) + np.asarray(mesh.vertices).max(axis=0)) / 2
745
+ mesh_c = mesh.copy()
746
+ mesh_c.vertices = np.asarray(mesh_c.vertices) - model_center.reshape(1, 3)
747
+ diameter = mesh_diameter(mesh_c)
748
+ if not np.isfinite(diameter) or diameter <= 0:
749
+ raise gr.Error("Degenerate CAD model (zero diameter).")
750
+
751
+ mesh_tensors = make_mesh_tensors(mesh_c)
752
+
753
+ rgb_t = torch.as_tensor(rgb.astype(np.float32), device="cuda", dtype=torch.float)
754
+ depth_t = torch.as_tensor(depth.astype(np.float32), device="cuda", dtype=torch.float)
755
+
756
+ progress(0.25, desc="Filtering depth")
757
+ depth_t = erode_depth(depth_t, radius=2)
758
+ depth_t = bilateral_filter_depth(depth_t, radius=2)
759
+
760
+ # Translation guess from the box centre + median depth inside the box.
761
+ mask = torch.zeros_like(depth_t, dtype=torch.bool)
762
+ mask[y1 : y2 + 1, x1 : x2 + 1] = True
763
+ valid = mask & (depth_t >= 0.001)
764
+ if int(valid.sum()) < 4:
765
+ raise gr.Error(
766
+ "No valid depth inside the box. Check the depth unit and the box coordinates."
767
+ )
768
+ zc = torch.median(depth_t[valid]).item()
769
+ uc = (x1 + x2) / 2.0
770
+ vc = (y1 + y2) / 2.0
771
+ center = (np.linalg.inv(K) @ np.array([uc, vc, 1.0]).reshape(3, 1)).reshape(3) * zc
772
+
773
+ poses = torch.as_tensor(ROT_GRID, device="cuda", dtype=torch.float).clone()
774
+ poses[:, :3, 3] = torch.as_tensor(center.reshape(1, 3), device="cuda", dtype=torch.float)
775
+
776
+ xyz_map_t = depth2xyzmap_t(depth_t, K)
777
+
778
+ progress(0.35, desc=f"Refining {len(poses)} hypotheses x {refine_iterations}")
779
+ t0 = time.time()
780
+ poses = refine_poses(
781
+ poses, mesh_tensors, rgb_t, xyz_map_t, K, diameter, int(refine_iterations)
782
+ )
783
+ t_refine = time.time() - t0
784
+
785
+ progress(0.8, desc="Scoring hypotheses")
786
+ t0 = time.time()
787
+ scores = score_poses(poses, mesh_tensors, rgb_t, depth_t, K, diameter)
788
+ t_score = time.time() - t0
789
+
790
+ order = scores.argsort(descending=True)
791
+ best = poses[order[0]]
792
+ best_score = float(scores[order[0]])
793
+
794
+ tf_to_centered = np.eye(4)
795
+ tf_to_centered[:3, 3] = -model_center
796
+ pose = best.detach().cpu().numpy().astype(np.float64) @ tf_to_centered
797
+
798
+ progress(0.9, desc="Rendering")
799
+ # Overlay: render the mesh at the winning pose over the input image.
800
+ with torch.inference_mode():
801
+ color, _ = nvdiffrast_render(
802
+ K=K,
803
+ H=H,
804
+ W=W,
805
+ ob_in_cams=best[None],
806
+ mesh_tensors=mesh_tensors,
807
+ output_size=(H, W),
808
+ use_light=True,
809
+ )
810
+ render = (color[0].clamp(0, 1).cpu().numpy() * 255).astype(np.uint8)
811
+ alpha = (render.sum(axis=-1) > 0).astype(np.float32)[..., None]
812
+ overlay = (rgb.astype(np.float32) * (1 - 0.65 * alpha) + render.astype(np.float32) * 0.65 * alpha)
813
+ overlay = overlay.clip(0, 255).astype(np.uint8)
814
+
815
+ # Annotated: oriented 3D bounding box + object axes.
816
+ to_origin, extents = trimesh.bounds.oriented_bounds(mesh)
817
+ box = np.stack([-extents / 2, extents / 2], axis=0).reshape(2, 3)
818
+ center_pose = pose @ np.linalg.inv(to_origin)
819
+ annotated = rgb.copy()
820
+ annotated = draw_posed_3d_box(K, annotated, center_pose, box)
821
+ axis_scale = float(np.clip(0.6 * float(extents.max()), 0.03, 0.15))
822
+ annotated = draw_xyz_axis(annotated, center_pose, K, scale=axis_scale, thickness=3)
823
+ annotated = cv2.rectangle(
824
+ annotated, (x1, y1), (x2, y2), color=(255, 180, 0), thickness=1, lineType=cv2.LINE_AA
825
+ )
826
+
827
+ # Undo the multiple-of-8 padding applied for the rasteriser.
828
+ annotated = annotated[:H0, :W0]
829
+ overlay = overlay[:H0, :W0]
830
+
831
+ R = pose[:3, :3]
832
+ t = pose[:3, 3]
833
+ ang = math.degrees(math.acos(float(np.clip((np.trace(R) - 1) / 2, -1, 1))))
834
+ pose_txt = "\n".join(
835
+ " ".join(f"{v: .6f}" for v in row) for row in pose
836
+ )
837
+ total = time.time() - t_start
838
+ report = (
839
+ f"**Object → camera translation** x={t[0]*100:.1f} cm, y={t[1]*100:.1f} cm, z={t[2]*100:.1f} cm\n\n"
840
+ f"**Rotation angle** {ang:.1f}° · **Score logit** {best_score:.3f}\n\n"
841
+ f"CAD model: {len(mesh.vertices):,} vertices, diameter {diameter*100:.1f} cm ({unit_note})\n\n"
842
+ f"252 hypotheses · {int(refine_iterations)} refine passes · "
843
+ f"refine {t_refine:.2f}s · score {t_score:.2f}s · total {total:.2f}s"
844
+ )
845
+
846
+ del mesh_tensors, rgb_t, depth_t, xyz_map_t, poses, scores
847
+ torch.cuda.empty_cache()
848
+ return annotated, overlay, pose_txt, report
849
+
850
+
851
+ @spaces.GPU(duration=90)
852
+ def estimate_pose(
853
+ rgb_image: str,
854
+ depth_file: str,
855
+ mesh_file: str,
856
+ bbox: str,
857
+ intrinsics: str,
858
+ depth_unit: str = "millimetres (uint16 PNG)",
859
+ mesh_unit: str = "auto",
860
+ refine_iterations: int = 5,
861
+ progress=gr.Progress(track_tqdm=False),
862
+ ):
863
+ """Estimate the 6-DoF pose of a CAD model in an RGB-D frame with FoundationPose.
864
+
865
+ Args:
866
+ rgb_image: path to the RGB image of the scene.
867
+ depth_file: path to the aligned depth map (16-bit PNG or .npy).
868
+ mesh_file: path to the object's CAD model (.glb/.obj/.ply/.stl).
869
+ bbox: 2D box around the object, "x1,y1,x2,y2" in pixels.
870
+ intrinsics: pinhole camera intrinsics, "fx,fy,cx,cy" in pixels.
871
+ depth_unit: unit of the stored depth values.
872
+ mesh_unit: unit of the CAD model vertices.
873
+ refine_iterations: number of pose-refinement passes (reference uses 5).
874
+
875
+ Returns:
876
+ Annotated image, rendered overlay, 4x4 object-to-camera pose, and a report.
877
+ """
878
+ try:
879
+ return _run_pose(
880
+ rgb_image,
881
+ depth_file,
882
+ mesh_file,
883
+ bbox,
884
+ intrinsics,
885
+ depth_unit,
886
+ mesh_unit,
887
+ refine_iterations,
888
+ progress,
889
+ )
890
+ except gr.Error:
891
+ raise
892
+ except Exception as e:
893
+ traceback.print_exc()
894
+ raise gr.Error(f"{type(e).__name__}: {e}")
895
+
896
+
897
+ # ---------------------------------------------------------------------------
898
+ # UI
899
+ # ---------------------------------------------------------------------------
900
+ _meta_path = os.path.join(EX_DIR, "_meta.json")
901
+ EXAMPLES = []
902
+ if os.path.exists(_meta_path):
903
+ _meta = json.load(open(_meta_path))
904
+ for _k in ["mustard_bottle", "power_drill", "pitcher_base", "sugar_box"]:
905
+ if _k not in _meta:
906
+ continue
907
+ m = _meta[_k]
908
+ EXAMPLES.append(
909
+ [
910
+ os.path.join(EX_DIR, m["rgb"]),
911
+ os.path.join(EX_DIR, m["depth"]),
912
+ os.path.join(EX_DIR, m["mesh"]),
913
+ ",".join(str(int(v)) for v in m["bbox"]),
914
+ ",".join(f"{v:.4f}" for v in m["K"]),
915
+ ]
916
+ )
917
+
918
+ CSS = """
919
+ .dark .gradio-container { --body-background-fill: #06080d; }
920
+ """
921
+
922
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="FoundationPose 6-DoF") as demo:
923
+ gr.Markdown(
924
+ """
925
+ # FoundationPose — 6-DoF pose of novel objects
926
+
927
+ Give it an **RGB-D frame**, the object's **CAD model**, the **camera intrinsics**
928
+ and a **2D box**, and [NVIDIA FoundationPose](https://huggingface.co/nvidia/foundationpose)
929
+ returns the object's full 6-DoF pose — zero-shot, no training on the object.
930
+
931
+ 252 pose hypotheses are sampled on an icosphere, refined by the refiner network, then ranked by the scorer network.
932
+ """
933
+ )
934
+
935
+ with gr.Row():
936
+ with gr.Column(scale=1):
937
+ rgb_in = gr.Image(type="filepath", label="RGB image", height=280)
938
+ depth_in = gr.File(
939
+ label="Depth map — 16-bit PNG or .npy, aligned to the RGB image",
940
+ file_types=[".png", ".npy", ".tif", ".tiff"],
941
+ )
942
+ bbox_in = gr.Textbox(
943
+ label="2D bounding box — x1,y1,x2,y2 (pixels)",
944
+ placeholder="409,47,542,324",
945
+ info="Click twice on the RGB image to set two opposite corners.",
946
+ )
947
+ k_in = gr.Textbox(
948
+ label="Camera intrinsics — fx,fy,cx,cy (pixels)",
949
+ value="1066.7780,1067.4870,312.9869,241.3109",
950
+ )
951
+ with gr.Column(scale=1):
952
+ mesh_in = gr.Model3D(label="CAD model of the object", height=340)
953
+ with gr.Accordion("Advanced options", open=False):
954
+ depth_unit_in = gr.Radio(
955
+ ["millimetres (uint16 PNG)", "metres (float)", "0.1 mm"],
956
+ value="millimetres (uint16 PNG)",
957
+ label="Depth unit",
958
+ )
959
+ mesh_unit_in = gr.Radio(
960
+ ["auto", "metres", "millimetres"],
961
+ value="auto",
962
+ label="CAD model unit",
963
+ )
964
+ iters_in = gr.Slider(
965
+ 1, 10, value=5, step=1,
966
+ label="Refinement iterations",
967
+ info="The reference implementation uses 5.",
968
+ )
969
+ run_btn = gr.Button("Estimate pose", variant="primary", size="lg")
970
+
971
+ with gr.Row():
972
+ annotated_out = gr.Image(label="3D box + object axes", height=380)
973
+ overlay_out = gr.Image(label="CAD model rendered at the estimated pose", height=380)
974
+ report_out = gr.Markdown()
975
+ pose_out = gr.Code(label="Object → camera pose (4×4, metres)", language=None)
976
+
977
+ corner_state = gr.State(None)
978
+
979
+ def on_click(corner, evt: gr.SelectData):
980
+ x, y = int(evt.index[0]), int(evt.index[1])
981
+ if corner is None:
982
+ return (x, y), f"{x},{y},{x},{y}"
983
+ x0, y0 = corner
984
+ return None, f"{min(x0,x)},{min(y0,y)},{max(x0,x)},{max(y0,y)}"
985
+
986
+ rgb_in.select(on_click, inputs=[corner_state], outputs=[corner_state, bbox_in])
987
+
988
+ inputs = [rgb_in, depth_in, mesh_in, bbox_in, k_in, depth_unit_in, mesh_unit_in, iters_in]
989
+ outputs = [annotated_out, overlay_out, pose_out, report_out]
990
+ run_btn.click(estimate_pose, inputs=inputs, outputs=outputs)
991
+
992
+ if EXAMPLES:
993
+ gr.Examples(
994
+ examples=EXAMPLES,
995
+ inputs=[rgb_in, depth_in, mesh_in, bbox_in, k_in],
996
+ outputs=outputs,
997
+ fn=estimate_pose,
998
+ cache_examples=True,
999
+ cache_mode="lazy",
1000
+ label="Examples — YCB-Video (BOP), one of FoundationPose's own evaluation datasets",
1001
+ )
1002
+
1003
+ gr.Markdown(
1004
+ """
1005
+ ---
1006
+ **Model** [nvidia/foundationpose](https://huggingface.co/nvidia/foundationpose) ·
1007
+ **Paper** [FoundationPose: Unified 6D Pose Estimation and Tracking of Novel Objects](https://arxiv.org/abs/2312.08344) (CVPR 2024, Best Paper Nominee) ·
1008
+ **Code** [NVlabs/FoundationPose](https://github.com/NVlabs/FoundationPose)
1009
+
1010
+ The published checkpoints are ONNX; they are converted to PyTorch with
1011
+ [onnx2torch](https://github.com/ENOT-AutoDL/onnx2torch) at startup. The exported
1012
+ scorer rates each hypothesis independently (`score_logit` per pose), which is the
1013
+ shape NVIDIA ships.
1014
+
1015
+ Example scenes and CAD models come from the
1016
+ [YCB-Video / BOP](https://huggingface.co/datasets/bop-benchmark/ycbv) dataset (MIT licence,
1017
+ © 2017 UW Robotics and State Estimation Lab). Model weights are covered by the
1018
+ NVIDIA Open Model License.
1019
+ """
1020
+ )
1021
+
1022
+ if __name__ == "__main__":
1023
+ demo.queue(max_size=12).launch(mcp_server=True, show_error=True)
examples/004_sugar_box.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d8b3830dd3067ecee4abf16da3624a78353a472b06bc9f2bbaa8f00c500ff65d
3
+ size 1571864
examples/006_mustard_bottle.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d9dc011cddcb899fdc8713b4904ca1eb60bda0d0f8e280e726c92de4a247c7a2
3
+ size 1289596
examples/019_pitcher_base.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bda6f4993f9c30be5f052c51ca7671a32d5e96e3baf53359f660bbad44f1f497
3
+ size 1212516
examples/035_power_drill.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:777e3f8921db35fbc210c936cc3321b05200c1b318783f804daa414c06347767
3
+ size 1089540
examples/_meta.json ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mustard_bottle": {
3
+ "name": "006_mustard_bottle",
4
+ "mesh": "006_mustard_bottle.glb",
5
+ "rgb": "mustard_bottle_rgb.png",
6
+ "depth": "mustard_bottle_depth.png",
7
+ "bbox": [
8
+ 409,
9
+ 47,
10
+ 542,
11
+ 324
12
+ ],
13
+ "K": [
14
+ 1066.778,
15
+ 1067.487,
16
+ 312.9869,
17
+ 241.3109
18
+ ],
19
+ "gt_pose": [
20
+ [
21
+ -0.9586628303517895,
22
+ -0.2835287329246605,
23
+ -0.024014888195919924,
24
+ 0.11510576923433374
25
+ ],
26
+ [
27
+ -0.08379337156245308,
28
+ 0.3619577494350433,
29
+ -0.9284211691506922,
30
+ -0.041077239594684054
31
+ ],
32
+ [
33
+ 0.27192578395746053,
34
+ -0.8880300419285506,
35
+ -0.37075363826953395,
36
+ 0.7556857413095329
37
+ ],
38
+ [
39
+ 0.0,
40
+ 0.0,
41
+ 0.0,
42
+ 1.0
43
+ ]
44
+ ],
45
+ "scene": "000050",
46
+ "im": "1874"
47
+ },
48
+ "power_drill": {
49
+ "name": "035_power_drill",
50
+ "mesh": "035_power_drill.glb",
51
+ "rgb": "power_drill_rgb.png",
52
+ "depth": "power_drill_depth.png",
53
+ "bbox": [
54
+ 58,
55
+ 246,
56
+ 371,
57
+ 456
58
+ ],
59
+ "K": [
60
+ 1066.778,
61
+ 1067.487,
62
+ 312.9869,
63
+ 241.3109
64
+ ],
65
+ "gt_pose": [
66
+ [
67
+ -0.9265112328620186,
68
+ 0.3554015793763549,
69
+ 0.12355990855995728,
70
+ -0.07007990833197376
71
+ ],
72
+ [
73
+ -0.053990458994419316,
74
+ -0.4505591780531961,
75
+ 0.8911131272403047,
76
+ 0.06763430848607617
77
+ ],
78
+ [
79
+ 0.3723725271017997,
80
+ 0.8189545363840561,
81
+ 0.4366367182365112,
82
+ 0.7316101236716381
83
+ ],
84
+ [
85
+ 0.0,
86
+ 0.0,
87
+ 0.0,
88
+ 1.0
89
+ ]
90
+ ],
91
+ "scene": "000050",
92
+ "im": "1324"
93
+ },
94
+ "pitcher_base": {
95
+ "name": "019_pitcher_base",
96
+ "mesh": "019_pitcher_base.glb",
97
+ "rgb": "pitcher_base_rgb.png",
98
+ "depth": "pitcher_base_depth.png",
99
+ "bbox": [
100
+ 226,
101
+ 69,
102
+ 513,
103
+ 439
104
+ ],
105
+ "K": [
106
+ 1066.778,
107
+ 1067.487,
108
+ 312.9869,
109
+ 241.3109
110
+ ],
111
+ "gt_pose": [
112
+ [
113
+ -0.7630850378965286,
114
+ -0.6459310027194527,
115
+ 0.021784917094012195,
116
+ 0.03607193041593472
117
+ ],
118
+ [
119
+ -0.23791608153732055,
120
+ 0.24940775817574404,
121
+ -0.9387177934933404,
122
+ 0.00816719940180273
123
+ ],
124
+ [
125
+ 0.6009142146924277,
126
+ -0.7215054182161463,
127
+ -0.3439961790363487,
128
+ 0.7327526085460678
129
+ ],
130
+ [
131
+ 0.0,
132
+ 0.0,
133
+ 0.0,
134
+ 1.0
135
+ ]
136
+ ],
137
+ "scene": "000056",
138
+ "im": "1"
139
+ },
140
+ "sugar_box": {
141
+ "name": "004_sugar_box",
142
+ "mesh": "004_sugar_box.glb",
143
+ "rgb": "sugar_box_rgb.png",
144
+ "depth": "sugar_box_depth.png",
145
+ "bbox": [
146
+ 151,
147
+ 255,
148
+ 413,
149
+ 452
150
+ ],
151
+ "K": [
152
+ 1066.778,
153
+ 1067.487,
154
+ 312.9869,
155
+ 241.3109
156
+ ],
157
+ "gt_pose": [
158
+ [
159
+ 0.04261657197093155,
160
+ 0.8018176555886548,
161
+ 0.5960480236123241,
162
+ -0.015860798256254367
163
+ ],
164
+ [
165
+ -0.85005714750022,
166
+ 0.3425727880987984,
167
+ -0.40005896186039525,
168
+ 0.0695873326444807
169
+ ],
170
+ [
171
+ -0.5249637632720354,
172
+ -0.48962547962401337,
173
+ 0.6961900608197962,
174
+ 0.7274594452418055
175
+ ],
176
+ [
177
+ 0.0,
178
+ 0.0,
179
+ 0.0,
180
+ 1.0
181
+ ]
182
+ ],
183
+ "scene": "000051",
184
+ "im": "1729"
185
+ }
186
+ }
examples/mustard_bottle_depth.png ADDED
examples/mustard_bottle_rgb.png ADDED

Git LFS Details

  • SHA256: f23a3fd9384540ac0122d414228211a672e66332aef66926991c42d42712b59c
  • Pointer size: 131 Bytes
  • Size of remote file: 482 kB
examples/pitcher_base_depth.png ADDED
examples/pitcher_base_rgb.png ADDED

Git LFS Details

  • SHA256: f5f5ff8b1dd079e01d0f866d26ff9d1aa8a603ffb128a2ee2fa9a2031815b2da
  • Pointer size: 131 Bytes
  • Size of remote file: 629 kB
examples/power_drill_depth.png ADDED
examples/power_drill_rgb.png ADDED

Git LFS Details

  • SHA256: eaba6d1e8a6530efd785e218be6ce0a329be0780624791aa95876d1109be41c2
  • Pointer size: 131 Bytes
  • Size of remote file: 483 kB
examples/sugar_box_depth.png ADDED
examples/sugar_box_rgb.png ADDED

Git LFS Details

  • SHA256: f348eb5aef6f3a166a6670e5e5df1e882656a7d804785d1808cdefae66fce5c2
  • Pointer size: 131 Bytes
  • Size of remote file: 500 kB
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # torch pinned to match the prebuilt sm_120 nvdiffrast wheel cell (pt211-cu130-cp312)
2
+ torch==2.11.0
3
+ torchvision==0.26.0
4
+ numpy
5
+ scipy
6
+ networkx
7
+ pillow
8
+ opencv-python-headless
9
+ trimesh
10
+ kornia
11
+ onnx
12
+ onnx2torch
13
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt211-cu130-cp312/nvdiffrast-0.4.0-cp312-cp312-linux_x86_64.whl