Image-to-3D
Trellis
Safetensors
localmesh-engine
mesh
gltf
multi-view
3d-generation
Qtn-Cls commited on
Commit
9dfdcbc
·
verified ·
1 Parent(s): 06fe951

Les quatre vues passent sous multivue/ : l'arborescence du depot devient celle du disque (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. multivue/LISEZ-MOI.md +23 -0
  2. multivue/cameras/source/src/depth_anything_3/model/gs_adapter.py +200 -0
  3. multivue/cameras/source/src/depth_anything_3/model/gsdpt.py +133 -0
  4. multivue/cameras/source/src/depth_anything_3/model/reference_view_selector.py +223 -0
  5. multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/attention.cpython-312.pyc +0 -0
  6. multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/block.cpython-312.pyc +0 -0
  7. multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/head_utils.cpython-312.pyc +0 -0
  8. multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/transform.cpython-312.pyc +0 -0
  9. multivue/cameras/source/src/depth_anything_3/model/utils/attention.py +109 -0
  10. multivue/cameras/source/src/depth_anything_3/model/utils/block.py +81 -0
  11. multivue/cameras/source/src/depth_anything_3/model/utils/gs_renderer.py +340 -0
  12. multivue/cameras/source/src/depth_anything_3/model/utils/head_utils.py +230 -0
  13. multivue/cameras/source/src/depth_anything_3/model/utils/transform.py +208 -0
  14. multivue/cameras/source/src/depth_anything_3/registry.py +50 -0
  15. multivue/cameras/source/src/depth_anything_3/services/__init__.py +24 -0
  16. multivue/cameras/source/src/depth_anything_3/services/backend.py +1556 -0
  17. multivue/cameras/source/src/depth_anything_3/services/gallery.py +806 -0
  18. multivue/cameras/source/src/depth_anything_3/services/inference_service.py +247 -0
  19. multivue/cameras/source/src/depth_anything_3/services/input_handlers.py +266 -0
  20. multivue/cameras/source/src/depth_anything_3/specs.py +45 -0
  21. multivue/cameras/source/src/depth_anything_3/utils/__pycache__/alignment.cpython-312.pyc +0 -0
  22. multivue/cameras/source/src/depth_anything_3/utils/__pycache__/constants.cpython-312.pyc +0 -0
  23. multivue/cameras/source/src/depth_anything_3/utils/__pycache__/geometry.cpython-312.pyc +0 -0
  24. multivue/cameras/source/src/depth_anything_3/utils/__pycache__/logger.cpython-312.pyc +0 -0
  25. multivue/cameras/source/src/depth_anything_3/utils/__pycache__/parallel_utils.cpython-312.pyc +0 -0
  26. multivue/cameras/source/src/depth_anything_3/utils/__pycache__/ray_utils.cpython-312.pyc +0 -0
  27. multivue/cameras/source/src/depth_anything_3/utils/alignment.py +163 -0
  28. multivue/cameras/source/src/depth_anything_3/utils/api_helpers.py +58 -0
  29. multivue/cameras/source/src/depth_anything_3/utils/camera_trj_helpers.py +479 -0
  30. multivue/cameras/source/src/depth_anything_3/utils/constants.py +270 -0
  31. multivue/cameras/source/src/depth_anything_3/utils/export/__init__.py +75 -0
  32. multivue/cameras/source/src/depth_anything_3/utils/export/colmap.py +150 -0
  33. multivue/cameras/source/src/depth_anything_3/utils/export/depth_vis.py +41 -0
  34. multivue/cameras/source/src/depth_anything_3/utils/export/feat_vis.py +75 -0
  35. multivue/cameras/source/src/depth_anything_3/utils/export/glb.py +437 -0
  36. multivue/cameras/source/src/depth_anything_3/utils/export/gs.py +154 -0
  37. multivue/cameras/source/src/depth_anything_3/utils/export/npz.py +73 -0
  38. multivue/cameras/source/src/depth_anything_3/utils/export/utils.py +30 -0
  39. multivue/cameras/source/src/depth_anything_3/utils/geometry.py +498 -0
  40. multivue/cameras/source/src/depth_anything_3/utils/gsply_helpers.py +173 -0
  41. multivue/cameras/source/src/depth_anything_3/utils/io/__pycache__/input_processor.cpython-312.pyc +0 -0
  42. multivue/cameras/source/src/depth_anything_3/utils/io/input_processor.py +501 -0
  43. multivue/cameras/source/src/depth_anything_3/utils/io/output_processor.py +172 -0
  44. multivue/cameras/source/src/depth_anything_3/utils/layout_helpers.py +216 -0
  45. multivue/cameras/source/src/depth_anything_3/utils/logger.py +82 -0
  46. multivue/cameras/source/src/depth_anything_3/utils/memory.py +127 -0
  47. multivue/cameras/source/src/depth_anything_3/utils/model_loading.py +149 -0
  48. multivue/cameras/source/src/depth_anything_3/utils/parallel_utils.py +133 -0
  49. multivue/cameras/source/src/depth_anything_3/utils/pca_utils.py +284 -0
  50. multivue/cameras/source/src/depth_anything_3/utils/pose_align.py +347 -0
multivue/LISEZ-MOI.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Les poids du multivue
2
+
3
+ Quatre fichiers obligatoires et un optionnel. Aucun n'est telecharge par le
4
+ moteur : c'est l'application qui les pose, ou la personne qui installe.
5
+
6
+ structure_mv_fp8.safetensors fusionne les quatre vues en un volume
7
+ forme_512_mv_fp8.safetensors premiere descente de la forme
8
+ forme_1024_mv_fp8.safetensors seconde descente, celle qui rend la surface
9
+ champ.safetensors rattrape le detail entre deux jetons
10
+
11
+ cameras/ mesure sous quel angle chaque photo a ete
12
+ prise, et en deduit si les deux profils
13
+ sont a l'endroit ou inverses.
14
+
15
+ CE DERNIER A ETE ECRIT « OPTIONNEL » ICI, ET C'ETAIT FAUX (corrige le 8
16
+ septembre 2026). Sans lui le moteur SUPPOSE la lateralite, et quand il suppose
17
+ mal l'objet sort avec un visage sculpte a l'avant ET a l'arriere. Mesure sur
18
+ quatre sujets : les quatre demandent l'inverse de la convention. Le moteur
19
+ tourne encore sans ce dossier, mais il devine, et il le dit dans son resultat.
20
+ A ne pas retirer pour gagner 517 Mo.
21
+
22
+ Chaque .safetensors va avec son .json, qui decrit l'architecture : sans lui le
23
+ modele ne peut pas etre construit.
multivue/cameras/source/src/depth_anything_3/model/gs_adapter.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Optional
16
+ import torch
17
+ from einops import einsum, rearrange, repeat
18
+ from torch import nn
19
+
20
+ from depth_anything_3.model.utils.transform import cam_quat_xyzw_to_world_quat_wxyz
21
+ from depth_anything_3.specs import Gaussians
22
+ from depth_anything_3.utils.geometry import affine_inverse, get_world_rays, sample_image_grid
23
+ from depth_anything_3.utils.pose_align import batch_align_poses_umeyama
24
+ from depth_anything_3.utils.sh_helpers import rotate_sh
25
+
26
+
27
+ class GaussianAdapter(nn.Module):
28
+
29
+ def __init__(
30
+ self,
31
+ sh_degree: int = 0,
32
+ pred_color: bool = False,
33
+ pred_offset_depth: bool = False,
34
+ pred_offset_xy: bool = True,
35
+ gaussian_scale_min: float = 1e-5,
36
+ gaussian_scale_max: float = 30.0,
37
+ ):
38
+ super().__init__()
39
+ self.sh_degree = sh_degree
40
+ self.pred_color = pred_color
41
+ self.pred_offset_depth = pred_offset_depth
42
+ self.pred_offset_xy = pred_offset_xy
43
+ self.gaussian_scale_min = gaussian_scale_min
44
+ self.gaussian_scale_max = gaussian_scale_max
45
+
46
+ # Create a mask for the spherical harmonics coefficients. This ensures that at
47
+ # initialization, the coefficients are biased towards having a large DC
48
+ # component and small view-dependent components.
49
+ if not pred_color:
50
+ self.register_buffer(
51
+ "sh_mask",
52
+ torch.ones((self.d_sh,), dtype=torch.float32),
53
+ persistent=False,
54
+ )
55
+ for degree in range(1, sh_degree + 1):
56
+ self.sh_mask[degree**2 : (degree + 1) ** 2] = 0.1 * 0.25**degree
57
+
58
+ def forward(
59
+ self,
60
+ extrinsics: torch.Tensor, # "*#batch 4 4"
61
+ intrinsics: torch.Tensor, # "*#batch 3 3"
62
+ depths: torch.Tensor, # "*#batch"
63
+ opacities: torch.Tensor, # "*#batch" | "*#batch _"
64
+ raw_gaussians: torch.Tensor, # "*#batch _"
65
+ image_shape: tuple[int, int],
66
+ eps: float = 1e-8,
67
+ gt_extrinsics: Optional[torch.Tensor] = None, # "*#batch 4 4"
68
+ **kwargs,
69
+ ) -> Gaussians:
70
+ device = extrinsics.device
71
+ dtype = raw_gaussians.dtype
72
+ H, W = image_shape
73
+ b, v = raw_gaussians.shape[:2]
74
+
75
+ # get cam2worlds and intr_normed to adapt to 3DGS codebase
76
+ cam2worlds = affine_inverse(extrinsics)
77
+ intr_normed = intrinsics.clone().detach()
78
+ intr_normed[..., 0, :] /= W
79
+ intr_normed[..., 1, :] /= H
80
+
81
+ # 1. compute 3DGS means
82
+ # 1.1) offset the predicted depth if needed
83
+ if self.pred_offset_depth:
84
+ gs_depths = depths + raw_gaussians[..., -1]
85
+ raw_gaussians = raw_gaussians[..., :-1]
86
+ else:
87
+ gs_depths = depths
88
+ # 1.2) align predicted poses with GT if needed
89
+ if gt_extrinsics is not None and not torch.equal(extrinsics, gt_extrinsics):
90
+ try:
91
+ _, _, pose_scales = batch_align_poses_umeyama(
92
+ gt_extrinsics.detach().float(),
93
+ extrinsics.detach().float(),
94
+ )
95
+ except Exception:
96
+ pose_scales = torch.ones_like(extrinsics[:, 0, 0, 0])
97
+ pose_scales = torch.clamp(pose_scales, min=1 / 3.0, max=3.0)
98
+ cam2worlds[:, :, :3, 3] = cam2worlds[:, :, :3, 3] * rearrange(
99
+ pose_scales, "b -> b () ()"
100
+ ) # [b, i, j]
101
+ gs_depths = gs_depths * rearrange(pose_scales, "b -> b () () ()") # [b, v, h, w]
102
+ # 1.3) casting xy in image space
103
+ xy_ray, _ = sample_image_grid((H, W), device)
104
+ xy_ray = xy_ray[None, None, ...].expand(b, v, -1, -1, -1) # b v h w xy
105
+ # offset xy if needed
106
+ if self.pred_offset_xy:
107
+ pixel_size = 1 / torch.tensor((W, H), dtype=xy_ray.dtype, device=device)
108
+ offset_xy = raw_gaussians[..., :2]
109
+ xy_ray = xy_ray + offset_xy * pixel_size
110
+ raw_gaussians = raw_gaussians[..., 2:] # skip the offset_xy
111
+ # 1.4) unproject depth + xy to world ray
112
+ origins, directions = get_world_rays(
113
+ xy_ray,
114
+ repeat(cam2worlds, "b v i j -> b v h w i j", h=H, w=W),
115
+ repeat(intr_normed, "b v i j -> b v h w i j", h=H, w=W),
116
+ )
117
+ gs_means_world = origins + directions * gs_depths[..., None]
118
+ gs_means_world = rearrange(gs_means_world, "b v h w d -> b (v h w) d")
119
+
120
+ # 2. compute other GS attributes
121
+ scales, rotations, sh = raw_gaussians.split((3, 4, 3 * self.d_sh), dim=-1)
122
+
123
+ # 2.1) 3DGS scales
124
+ # make the scale invarient to resolution
125
+ scale_min = self.gaussian_scale_min
126
+ scale_max = self.gaussian_scale_max
127
+ scales = scale_min + (scale_max - scale_min) * scales.sigmoid()
128
+ pixel_size = 1 / torch.tensor((W, H), dtype=dtype, device=device)
129
+ multiplier = self.get_scale_multiplier(intr_normed, pixel_size)
130
+ gs_scales = scales * gs_depths[..., None] * multiplier[..., None, None, None]
131
+ gs_scales = rearrange(gs_scales, "b v h w d -> b (v h w) d")
132
+
133
+ # 2.2) 3DGS quaternion (world space)
134
+ # due to historical issue, assume quaternion in order xyzw, not wxyz
135
+ # Normalize the quaternion features to yield a valid quaternion.
136
+ rotations = rotations / (rotations.norm(dim=-1, keepdim=True) + eps)
137
+ # rotate them to world space
138
+ cam_quat_xyzw = rearrange(rotations, "b v h w c -> b (v h w) c")
139
+ c2w_mat = repeat(
140
+ cam2worlds,
141
+ "b v i j -> b (v h w) i j",
142
+ h=H,
143
+ w=W,
144
+ )
145
+ world_quat_wxyz = cam_quat_xyzw_to_world_quat_wxyz(cam_quat_xyzw, c2w_mat)
146
+ gs_rotations_world = world_quat_wxyz # b (v h w) c
147
+
148
+ # 2.3) 3DGS color / SH coefficient (world space)
149
+ sh = rearrange(sh, "... (xyz d_sh) -> ... xyz d_sh", xyz=3)
150
+ if not self.pred_color:
151
+ sh = sh * self.sh_mask
152
+
153
+ if self.pred_color or self.sh_degree == 0:
154
+ # predict pre-computed color or predict only DC band, no need to transform
155
+ gs_sh_world = sh
156
+ else:
157
+ gs_sh_world = rotate_sh(sh, cam2worlds[:, :, None, None, None, :3, :3])
158
+ gs_sh_world = rearrange(gs_sh_world, "b v h w xyz d_sh -> b (v h w) xyz d_sh")
159
+
160
+ # 2.4) 3DGS opacity
161
+ gs_opacities = rearrange(opacities, "b v h w ... -> b (v h w) ...")
162
+
163
+ return Gaussians(
164
+ means=gs_means_world,
165
+ harmonics=gs_sh_world,
166
+ opacities=gs_opacities,
167
+ scales=gs_scales,
168
+ rotations=gs_rotations_world,
169
+ )
170
+
171
+ def get_scale_multiplier(
172
+ self,
173
+ intrinsics: torch.Tensor, # "*#batch 3 3"
174
+ pixel_size: torch.Tensor, # "*#batch 2"
175
+ multiplier: float = 0.1,
176
+ ) -> torch.Tensor: # " *batch"
177
+ xy_multipliers = multiplier * einsum(
178
+ intrinsics[..., :2, :2].float().inverse().to(intrinsics),
179
+ pixel_size,
180
+ "... i j, j -> ... i",
181
+ )
182
+ return xy_multipliers.sum(dim=-1)
183
+
184
+ @property
185
+ def d_sh(self) -> int:
186
+ return 1 if self.pred_color else (self.sh_degree + 1) ** 2
187
+
188
+ @property
189
+ def d_in(self) -> int:
190
+ # provided as reference to the gs_dpt output dim
191
+ raw_gs_dim = 0
192
+ if self.pred_offset_xy:
193
+ raw_gs_dim += 2
194
+ raw_gs_dim += 3 # scales
195
+ raw_gs_dim += 4 # quaternion
196
+ raw_gs_dim += 3 * self.d_sh # color
197
+ if self.pred_offset_depth:
198
+ raw_gs_dim += 1
199
+
200
+ return raw_gs_dim
multivue/cameras/source/src/depth_anything_3/model/gsdpt.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Dict as TyDict
16
+ from typing import List, Sequence
17
+ import torch
18
+ import torch.nn as nn
19
+
20
+ from depth_anything_3.model.dpt import DPT
21
+ from depth_anything_3.model.utils.head_utils import activate_head_gs, custom_interpolate
22
+
23
+
24
+ class GSDPT(DPT):
25
+
26
+ def __init__(
27
+ self,
28
+ dim_in: int,
29
+ patch_size: int = 14,
30
+ output_dim: int = 4,
31
+ activation: str = "linear",
32
+ conf_activation: str = "sigmoid",
33
+ features: int = 256,
34
+ out_channels: Sequence[int] = (256, 512, 1024, 1024),
35
+ pos_embed: bool = True,
36
+ feature_only: bool = False,
37
+ down_ratio: int = 1,
38
+ conf_dim: int = 1,
39
+ norm_type: str = "idt", # use to match legacy GS-DPT head, "idt" / "layer"
40
+ fusion_block_inplace: bool = False,
41
+ ) -> None:
42
+ super().__init__(
43
+ dim_in=dim_in,
44
+ patch_size=patch_size,
45
+ output_dim=output_dim,
46
+ activation=activation,
47
+ conf_activation=conf_activation,
48
+ features=features,
49
+ out_channels=out_channels,
50
+ pos_embed=pos_embed,
51
+ down_ratio=down_ratio,
52
+ head_name="raw_gs",
53
+ use_sky_head=False,
54
+ norm_type=norm_type,
55
+ fusion_block_inplace=fusion_block_inplace,
56
+ )
57
+ self.conf_dim = conf_dim
58
+ if conf_dim and conf_dim > 1:
59
+ assert (
60
+ conf_activation == "linear"
61
+ ), "use linear prediction when using view-dependent opacity"
62
+
63
+ merger_out_dim = features if feature_only else features // 2
64
+ self.images_merger = nn.Sequential(
65
+ nn.Conv2d(3, merger_out_dim // 4, 3, 1, 1), # fewer channels first
66
+ nn.GELU(),
67
+ nn.Conv2d(merger_out_dim // 4, merger_out_dim // 2, 3, 1, 1),
68
+ nn.GELU(),
69
+ nn.Conv2d(merger_out_dim // 2, merger_out_dim, 3, 1, 1),
70
+ nn.GELU(),
71
+ )
72
+
73
+ # -------------------------------------------------------------------------
74
+ # Internal forward (single chunk)
75
+ # -------------------------------------------------------------------------
76
+ def _forward_impl(
77
+ self,
78
+ feats: List[torch.Tensor],
79
+ H: int,
80
+ W: int,
81
+ patch_start_idx: int,
82
+ images: torch.Tensor,
83
+ ) -> TyDict[str, torch.Tensor]:
84
+ B, _, C = feats[0].shape
85
+ ph, pw = H // self.patch_size, W // self.patch_size
86
+ resized_feats = []
87
+ for stage_idx, take_idx in enumerate(self.intermediate_layer_idx):
88
+ x = feats[take_idx][:, patch_start_idx:] # [B*S, N_patch, C]
89
+ x = self.norm(x)
90
+ x = x.permute(0, 2, 1).reshape(B, C, ph, pw) # [B*S, C, ph, pw]
91
+
92
+ x = self.projects[stage_idx](x)
93
+ if self.pos_embed:
94
+ x = self._add_pos_embed(x, W, H)
95
+ x = self.resize_layers[stage_idx](x) # Align scale
96
+ resized_feats.append(x)
97
+
98
+ # 2) Fusion pyramid (main branch only)
99
+ fused = self._fuse(resized_feats)
100
+ fused = self.scratch.output_conv1(fused)
101
+
102
+ # 3) Upsample to target resolution, optionally add position encoding again
103
+ h_out = int(ph * self.patch_size / self.down_ratio)
104
+ w_out = int(pw * self.patch_size / self.down_ratio)
105
+
106
+ fused = custom_interpolate(fused, (h_out, w_out), mode="bilinear", align_corners=True)
107
+
108
+ # inject the image information here
109
+ fused = fused + self.images_merger(images)
110
+
111
+ if self.pos_embed:
112
+ fused = self._add_pos_embed(fused, W, H)
113
+
114
+ # 4) Shared neck1
115
+ # feat = self.scratch.output_conv1(fused)
116
+ feat = fused
117
+
118
+ # 5) Main head: logits -> activate_head or single channel activation
119
+ main_logits = self.scratch.output_conv2(feat)
120
+ outs: TyDict[str, torch.Tensor] = {}
121
+ if self.has_conf:
122
+ pred, conf = activate_head_gs(
123
+ main_logits,
124
+ activation=self.activation,
125
+ conf_activation=self.conf_activation,
126
+ conf_dim=self.conf_dim,
127
+ )
128
+ outs[self.head_main] = pred.squeeze(1)
129
+ outs[f"{self.head_main}_conf"] = conf.squeeze(1)
130
+ else:
131
+ outs[self.head_main] = self._apply_activation_single(main_logits).squeeze(1)
132
+
133
+ return outs
multivue/cameras/source/src/depth_anything_3/model/reference_view_selector.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Reference View Selection Strategies
17
+
18
+ This module provides different strategies for selecting a reference view
19
+ from multiple input views in multi-view depth estimation.
20
+ """
21
+
22
+ import torch
23
+ from typing import Literal
24
+
25
+
26
+ RefViewStrategy = Literal["first", "middle", "saddle_balanced", "saddle_sim_range"]
27
+
28
+
29
+ def select_reference_view(
30
+ x: torch.Tensor,
31
+ strategy: RefViewStrategy = "saddle_balanced",
32
+ ) -> torch.Tensor:
33
+ """
34
+ Select a reference view from multiple views using the specified strategy.
35
+
36
+ Args:
37
+ x: Input tensor of shape (B, S, N, C) where
38
+ B = batch size
39
+ S = number of views
40
+ N = number of tokens
41
+ C = channel dimension
42
+ strategy: Selection strategy, one of:
43
+ - "first": Always select the first view
44
+ - "middle": Select the middle view
45
+ - "saddle_balanced": Select view with balanced features across multiple metrics
46
+ - "saddle_sim_range": Select view with largest similarity range
47
+
48
+ Returns:
49
+ b_idx: Tensor of shape (B,) containing the selected view index for each batch
50
+ """
51
+ B, S, N, C = x.shape
52
+
53
+ # For single view, no reordering needed
54
+ if S <= 1:
55
+ return torch.zeros(B, dtype=torch.long, device=x.device)
56
+
57
+ # Simple position-based strategies
58
+ if strategy == "first":
59
+ return torch.zeros(B, dtype=torch.long, device=x.device)
60
+
61
+ elif strategy == "middle":
62
+ return torch.full((B,), S // 2, dtype=torch.long, device=x.device)
63
+
64
+ # Feature-based strategies require normalized class tokens
65
+ # Extract and normalize class tokens (first token of each view)
66
+ img_class_feat = x[:, :, 0] / x[:, :, 0].norm(dim=-1, keepdim=True) # B S C
67
+
68
+ if strategy == "saddle_balanced":
69
+ # Select view with balanced features across multiple metrics
70
+ # Compute similarity matrix
71
+ sim = torch.matmul(img_class_feat, img_class_feat.transpose(1, 2)) # B S S
72
+ sim_no_diag = sim - torch.eye(S, device=sim.device).unsqueeze(0)
73
+ sim_score = sim_no_diag.sum(dim=-1) / (S - 1) # B S
74
+
75
+ feat_norm = x[:, :, 0].norm(dim=-1) # B S
76
+ feat_var = img_class_feat.var(dim=-1) # B S
77
+
78
+ # Normalize all metrics to [0, 1]
79
+ def normalize_metric(metric):
80
+ min_val = metric.min(dim=1, keepdim=True).values
81
+ max_val = metric.max(dim=1, keepdim=True).values
82
+ return (metric - min_val) / (max_val - min_val + 1e-8)
83
+
84
+ sim_score_norm = normalize_metric(sim_score)
85
+ norm_norm = normalize_metric(feat_norm)
86
+ var_norm = normalize_metric(feat_var)
87
+
88
+ # Select view closest to the median (0.5) across all metrics
89
+ balance_score = (
90
+ (sim_score_norm - 0.5).abs() +
91
+ (norm_norm - 0.5).abs() +
92
+ (var_norm - 0.5).abs()
93
+ )
94
+ b_idx = balance_score.argmin(dim=1)
95
+
96
+ elif strategy == "saddle_sim_range":
97
+ # Select view with largest similarity range (max - min)
98
+ sim = torch.matmul(img_class_feat, img_class_feat.transpose(1, 2)) # B S S
99
+ sim_no_diag = sim - torch.eye(S, device=sim.device).unsqueeze(0)
100
+
101
+ sim_max = sim_no_diag.max(dim=-1).values # B S
102
+ sim_min = sim_no_diag.min(dim=-1).values # B S
103
+ sim_range = sim_max - sim_min
104
+ b_idx = sim_range.argmax(dim=1)
105
+
106
+ else:
107
+ raise ValueError(
108
+ f"Unknown reference view selection strategy: {strategy}. "
109
+ f"Must be one of: 'first', 'middle', 'saddle_balanced', 'saddle_sim_range'"
110
+ )
111
+
112
+ return b_idx
113
+
114
+
115
+ def reorder_by_reference(
116
+ x: torch.Tensor,
117
+ b_idx: torch.Tensor,
118
+ ) -> torch.Tensor:
119
+ """
120
+ Reorder views to place the selected reference view first.
121
+
122
+ Args:
123
+ x: Input tensor of shape (B, S, N, C)
124
+ b_idx: Reference view indices of shape (B,)
125
+
126
+ Returns:
127
+ Reordered tensor with reference view at position 0
128
+
129
+ Example:
130
+ If b_idx = [2] and S = 5 (views [0,1,2,3,4]),
131
+ result order is [2,0,1,3,4] (ref_idx first, then others in order)
132
+ """
133
+ B, S = x.shape[0], x.shape[1]
134
+
135
+ # For single view, no reordering needed
136
+ if S <= 1:
137
+ return x
138
+
139
+ # Create position indices: (B, S) where each row is [0, 1, 2, ..., S-1]
140
+ positions = torch.arange(S, device=x.device).unsqueeze(0).expand(B, -1) # B S
141
+
142
+ # For each position, determine which original index it should take
143
+ # Position 0 gets ref_idx
144
+ # Position 1 to ref_idx gets indices 0 to ref_idx-1
145
+ # Position ref_idx+1 to S-1 gets indices ref_idx+1 to S-1
146
+
147
+ b_idx_expanded = b_idx.unsqueeze(1) # B 1
148
+
149
+ # Create the reordering indices
150
+ # For positions 1 to ref_idx: map to indices 0 to ref_idx-1 (shift by -1)
151
+ # For positions > ref_idx: keep the same
152
+ reorder_indices = positions.clone()
153
+ reorder_indices = torch.where(
154
+ (positions > 0) & (positions <= b_idx_expanded),
155
+ positions - 1,
156
+ positions
157
+ )
158
+ # Set position 0 to ref_idx
159
+ reorder_indices[:, 0] = b_idx
160
+
161
+ # Gather using advanced indexing
162
+ batch_indices = torch.arange(B, device=x.device).unsqueeze(1) # B 1
163
+ x_reordered = x[batch_indices, reorder_indices]
164
+
165
+ return x_reordered
166
+
167
+
168
+ def restore_original_order(
169
+ x: torch.Tensor,
170
+ b_idx: torch.Tensor,
171
+ ) -> torch.Tensor:
172
+ """
173
+ Restore original view order after processing.
174
+
175
+ Args:
176
+ x: Reordered tensor of shape (B, S, ...)
177
+ b_idx: Original reference view indices of shape (B,)
178
+
179
+ Returns:
180
+ Tensor with original view order restored
181
+
182
+ Example:
183
+ If original order was [0, 1, 2, 3, 4] and b_idx=2,
184
+ reordered becomes [2, 0, 1, 3, 4] (reference at position 0),
185
+ restore should return [0, 1, 2, 3, 4] (original order).
186
+ """
187
+ B, S = x.shape[0], x.shape[1]
188
+
189
+ # For single view, no restoration needed
190
+ if S <= 1:
191
+ return x
192
+
193
+ # Create target position indices: (B, S) where each row is [0, 1, 2, ..., S-1]
194
+ target_positions = torch.arange(S, device=x.device).unsqueeze(0).expand(B, -1) # B S
195
+
196
+ # For each target position, determine which current position it comes from
197
+ # Target position 0 to ref_idx-1 <- Current position 1 to ref_idx (shift by +1)
198
+ # Target position ref_idx <- Current position 0
199
+ # Target position ref_idx+1 to S-1 <- Current position ref_idx+1 to S-1 (no change)
200
+
201
+ b_idx_expanded = b_idx.unsqueeze(1) # B 1
202
+
203
+ # Create the restore indices
204
+ restore_indices = torch.where(
205
+ target_positions < b_idx_expanded,
206
+ target_positions + 1, # Positions before ref_idx come from current position + 1
207
+ target_positions # Positions after ref_idx stay the same
208
+ )
209
+ # Target position = ref_idx comes from current position 0
210
+ # Use scatter to set specific positions
211
+ restore_indices = torch.scatter(
212
+ restore_indices,
213
+ dim=1,
214
+ index=b_idx_expanded,
215
+ src=torch.zeros_like(b_idx_expanded)
216
+ )
217
+
218
+ # Gather using advanced indexing
219
+ batch_indices = torch.arange(B, device=x.device).unsqueeze(1) # B 1
220
+ x_restored = x[batch_indices, restore_indices]
221
+
222
+ return x_restored
223
+
multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/attention.cpython-312.pyc ADDED
Binary file (5.98 kB). View file
 
multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/block.cpython-312.pyc ADDED
Binary file (3.33 kB). View file
 
multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/head_utils.cpython-312.pyc ADDED
Binary file (9.25 kB). View file
 
multivue/cameras/source/src/depth_anything_3/model/utils/__pycache__/transform.cpython-312.pyc ADDED
Binary file (8.71 kB). View file
 
multivue/cameras/source/src/depth_anything_3/model/utils/attention.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Modified from: https://github.com/huggingface/pytorch-image-models/blob/main/timm/models/vision_transformer.py#L103-L110 # noqa
16
+
17
+ from typing import Callable, Optional, Union
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from torch import Tensor, nn
21
+
22
+
23
+ class Attention(nn.Module):
24
+ def __init__(
25
+ self,
26
+ dim: int,
27
+ num_heads: int = 8,
28
+ qkv_bias: bool = True,
29
+ proj_bias: bool = True,
30
+ attn_drop: float = 0.0,
31
+ proj_drop: float = 0.0,
32
+ norm_layer: nn.Module = nn.LayerNorm,
33
+ qk_norm: bool = False,
34
+ rope=None,
35
+ ) -> None:
36
+ super().__init__()
37
+ assert dim % num_heads == 0, "dim should be divisible by num_heads"
38
+ self.num_heads = num_heads
39
+ self.head_dim = dim // num_heads
40
+ self.scale = self.head_dim**-0.5
41
+ self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
42
+ self.q_norm = norm_layer(self.head_dim) if qk_norm else nn.Identity()
43
+ self.k_norm = norm_layer(self.head_dim) if qk_norm else nn.Identity()
44
+ self.attn_drop = nn.Dropout(attn_drop)
45
+ self.proj = nn.Linear(dim, dim, bias=proj_bias)
46
+ self.proj_drop = nn.Dropout(proj_drop)
47
+ self.rope = rope
48
+
49
+ def forward(self, x: Tensor, pos=None, attn_mask=None) -> Tensor:
50
+ # Debug breakpoint removed for production
51
+ B, N, C = x.shape
52
+ qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4)
53
+ q, k, v = qkv.unbind(0)
54
+ q, k = self.q_norm(q), self.k_norm(k)
55
+ q = self.rope(q, pos) if self.rope is not None else q
56
+ k = self.rope(k, pos) if self.rope is not None else k
57
+ x = F.scaled_dot_product_attention(
58
+ q,
59
+ k,
60
+ v,
61
+ dropout_p=self.attn_drop.p if self.training else 0.0,
62
+ attn_mask=attn_mask,
63
+ )
64
+ x = x.transpose(1, 2).reshape(B, N, C)
65
+ x = self.proj(x)
66
+ x = self.proj_drop(x)
67
+ return x
68
+
69
+
70
+ class LayerScale(nn.Module):
71
+ def __init__(
72
+ self,
73
+ dim: int,
74
+ init_values: Union[float, Tensor] = 1e-5,
75
+ inplace: bool = False,
76
+ ) -> None:
77
+ super().__init__()
78
+ self.inplace = inplace
79
+ self.gamma = nn.Parameter(init_values * torch.ones(dim))
80
+
81
+ def forward(self, x: Tensor) -> Tensor:
82
+ return x.mul_(self.gamma) if self.inplace else x * self.gamma
83
+
84
+
85
+ class Mlp(nn.Module):
86
+ def __init__(
87
+ self,
88
+ in_features: int,
89
+ hidden_features: Optional[int] = None,
90
+ out_features: Optional[int] = None,
91
+ act_layer: Callable[..., nn.Module] = nn.GELU,
92
+ drop: float = 0.0,
93
+ bias: bool = True,
94
+ ) -> None:
95
+ super().__init__()
96
+ out_features = out_features or in_features
97
+ hidden_features = hidden_features or in_features
98
+ self.fc1 = nn.Linear(in_features, hidden_features, bias=bias)
99
+ self.act = act_layer()
100
+ self.fc2 = nn.Linear(hidden_features, out_features, bias=bias)
101
+ self.drop = nn.Dropout(drop)
102
+
103
+ def forward(self, x: Tensor) -> Tensor:
104
+ x = self.fc1(x)
105
+ x = self.act(x)
106
+ x = self.drop(x)
107
+ x = self.fc2(x)
108
+ x = self.drop(x)
109
+ return x
multivue/cameras/source/src/depth_anything_3/model/utils/block.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ from typing import Callable
17
+ from torch import Tensor, nn
18
+
19
+ from .attention import Attention, LayerScale, Mlp
20
+
21
+
22
+ class Block(nn.Module):
23
+ def __init__(
24
+ self,
25
+ dim: int,
26
+ num_heads: int,
27
+ mlp_ratio: float = 4.0,
28
+ qkv_bias: bool = True,
29
+ proj_bias: bool = True,
30
+ ffn_bias: bool = True,
31
+ drop: float = 0.0,
32
+ attn_drop: float = 0.0,
33
+ init_values=None,
34
+ drop_path: float = 0.0,
35
+ act_layer: Callable[..., nn.Module] = nn.GELU,
36
+ norm_layer: Callable[..., nn.Module] = nn.LayerNorm,
37
+ attn_class: Callable[..., nn.Module] = Attention,
38
+ ffn_layer: Callable[..., nn.Module] = Mlp,
39
+ qk_norm: bool = False,
40
+ rope=None,
41
+ ) -> None:
42
+ super().__init__()
43
+
44
+ self.norm1 = norm_layer(dim)
45
+
46
+ self.attn = attn_class(
47
+ dim,
48
+ num_heads=num_heads,
49
+ qkv_bias=qkv_bias,
50
+ proj_bias=proj_bias,
51
+ attn_drop=attn_drop,
52
+ proj_drop=drop,
53
+ qk_norm=qk_norm,
54
+ rope=rope,
55
+ )
56
+
57
+ self.ls1 = LayerScale(dim, init_values=init_values) if init_values else nn.Identity()
58
+ self.norm2 = norm_layer(dim)
59
+ mlp_hidden_dim = int(dim * mlp_ratio)
60
+ self.mlp = ffn_layer(
61
+ in_features=dim,
62
+ hidden_features=mlp_hidden_dim,
63
+ act_layer=act_layer,
64
+ drop=drop,
65
+ bias=ffn_bias,
66
+ )
67
+ self.ls2 = LayerScale(dim, init_values=init_values) if init_values else nn.Identity()
68
+
69
+ self.sample_drop_ratio = 0.0 # Equivalent to always having drop_path=0
70
+
71
+ def forward(self, x: Tensor, pos=None, attn_mask=None) -> Tensor:
72
+ def attn_residual_func(x: Tensor, pos=None, attn_mask=None) -> Tensor:
73
+ return self.ls1(self.attn(self.norm1(x), pos=pos, attn_mask=attn_mask))
74
+
75
+ def ffn_residual_func(x: Tensor) -> Tensor:
76
+ return self.ls2(self.mlp(self.norm2(x)))
77
+
78
+ # drop_path is always 0, so always take the else branch
79
+ x = x + attn_residual_func(x, pos=pos, attn_mask=attn_mask)
80
+ x = x + ffn_residual_func(x)
81
+ return x
multivue/cameras/source/src/depth_anything_3/model/utils/gs_renderer.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import math
16
+ from math import isqrt
17
+ from typing import Literal, Optional
18
+ import torch
19
+ from einops import rearrange, repeat
20
+ from tqdm import tqdm
21
+
22
+ from depth_anything_3.specs import Gaussians
23
+ from depth_anything_3.utils.camera_trj_helpers import (
24
+ interpolate_extrinsics,
25
+ interpolate_intrinsics,
26
+ render_dolly_zoom_path,
27
+ render_stabilization_path,
28
+ render_wander_path,
29
+ render_wobble_inter_path,
30
+ )
31
+ from depth_anything_3.utils.geometry import affine_inverse, as_homogeneous, get_fov
32
+ from depth_anything_3.utils.logger import logger
33
+
34
+ try:
35
+ from gsplat import rasterization
36
+ except ImportError:
37
+ logger.warn(
38
+ "Dependency `gsplat` is required for rendering 3DGS. "
39
+ "Install via: pip install git+https://github.com/nerfstudio-project/"
40
+ "gsplat.git@0b4dddf04cb687367602c01196913cde6a743d70"
41
+ )
42
+
43
+
44
+ def render_3dgs(
45
+ extrinsics: torch.Tensor, # "batch_views 4 4", w2c
46
+ intrinsics: torch.Tensor, # "batch_views 3 3", normalized
47
+ image_shape: tuple[int, int],
48
+ gaussian: Gaussians,
49
+ background_color: Optional[torch.Tensor] = None, # "batch_views 3"
50
+ use_sh: bool = True,
51
+ num_view: int = 1,
52
+ color_mode: Literal["RGB+D", "RGB+ED"] = "RGB+D",
53
+ **kwargs,
54
+ ) -> tuple[
55
+ torch.Tensor, # "batch_views 3 height width"
56
+ torch.Tensor, # "batch_views height width"
57
+ ]:
58
+ # extract gaussian params
59
+ gaussian_means = gaussian.means
60
+ gaussian_scales = gaussian.scales
61
+ gaussian_quats = gaussian.rotations
62
+ gaussian_opacities = gaussian.opacities
63
+ gaussian_sh_coefficients = gaussian.harmonics
64
+ b, _, _ = extrinsics.shape
65
+
66
+ if background_color is None:
67
+ background_color = repeat(torch.tensor([0.0, 0.0, 0.0]), "c -> b c", b=b).to(
68
+ gaussian_sh_coefficients
69
+ )
70
+
71
+ if use_sh:
72
+ _, _, _, n = gaussian_sh_coefficients.shape
73
+ degree = isqrt(n) - 1
74
+ shs = rearrange(gaussian_sh_coefficients, "b g xyz n -> b g n xyz").contiguous()
75
+ else: # use color
76
+ shs = (
77
+ gaussian_sh_coefficients.squeeze(-1).sigmoid().contiguous()
78
+ ) # (b, g, c), normed to (0, 1)
79
+
80
+ h, w = image_shape
81
+
82
+ fov_x, fov_y = get_fov(intrinsics).unbind(dim=-1)
83
+ tan_fov_x = (0.5 * fov_x).tan()
84
+ tan_fov_y = (0.5 * fov_y).tan()
85
+ focal_length_x = w / (2 * tan_fov_x)
86
+ focal_length_y = h / (2 * tan_fov_y)
87
+
88
+ view_matrix = extrinsics.float()
89
+
90
+ all_images = []
91
+ all_radii = []
92
+ all_depths = []
93
+ # render view in a batch based, each batch contains one scene
94
+ # assume the Gaussian parameters are originally repeated along the view dim
95
+ batch_scene = b // num_view
96
+
97
+ def index_i_gs_attr(full_attr, idx):
98
+ # return rearrange(full_attr, "(b v) ... -> b v ...", v=num_view)[idx, 0]
99
+ return full_attr[idx]
100
+
101
+ for i in range(batch_scene):
102
+ K = repeat(
103
+ torch.tensor(
104
+ [
105
+ [0, 0, w / 2.0],
106
+ [0, 0, h / 2.0],
107
+ [0, 0, 1],
108
+ ]
109
+ ),
110
+ "i j -> v i j",
111
+ v=num_view,
112
+ ).to(gaussian_means)
113
+ K[:, 0, 0] = focal_length_x.reshape(batch_scene, num_view)[i]
114
+ K[:, 1, 1] = focal_length_y.reshape(batch_scene, num_view)[i]
115
+
116
+ i_means = index_i_gs_attr(gaussian_means, i) # [N, 3]
117
+ i_scales = index_i_gs_attr(gaussian_scales, i)
118
+ i_quats = index_i_gs_attr(gaussian_quats, i)
119
+ i_opacities = index_i_gs_attr(gaussian_opacities, i) # [N,]
120
+ i_colors = index_i_gs_attr(shs, i) # [N, K, 3]
121
+ i_viewmats = rearrange(view_matrix, "(b v) ... -> b v ...", v=num_view)[i] # [v, 4, 4]
122
+ i_backgrounds = rearrange(background_color, "(b v) ... -> b v ...", v=num_view)[
123
+ i
124
+ ] # [v, 3]
125
+
126
+ render_colors, render_alphas, info = rasterization(
127
+ means=i_means,
128
+ quats=i_quats, # [N, 4]
129
+ scales=i_scales, # [N, 3]
130
+ opacities=i_opacities,
131
+ colors=i_colors,
132
+ viewmats=i_viewmats, # [v, 4, 4]
133
+ Ks=K, # [v, 3, 3]
134
+ backgrounds=i_backgrounds,
135
+ render_mode=color_mode,
136
+ width=w,
137
+ height=h,
138
+ packed=False,
139
+ sh_degree=degree if use_sh else None,
140
+ )
141
+ depth = render_colors[..., -1].unbind(dim=0)
142
+
143
+ image = rearrange(render_colors[..., :3], "v h w c -> v c h w").unbind(dim=0)
144
+ radii = info["radii"].unbind(dim=0)
145
+ try:
146
+ info["means2d"].retain_grad() # [1, N, 2]
147
+ except Exception:
148
+ pass
149
+ all_images.extend(image)
150
+ all_depths.extend(depth)
151
+ all_radii.extend(radii)
152
+
153
+ return torch.stack(all_images), torch.stack(all_depths)
154
+
155
+
156
+ def run_renderer_in_chunk_w_trj_mode(
157
+ gaussians: Gaussians,
158
+ extrinsics: torch.Tensor, # world2cam, "batch view 4 4" | "batch view 3 4"
159
+ intrinsics: torch.Tensor, # unnormed intrinsics, "batch view 3 3"
160
+ image_shape: tuple[int, int],
161
+ chunk_size: Optional[int] = 8,
162
+ trj_mode: Literal[
163
+ "original",
164
+ "smooth",
165
+ "interpolate",
166
+ "interpolate_smooth",
167
+ "wander",
168
+ "dolly_zoom",
169
+ "extend",
170
+ "wobble_inter",
171
+ ] = "smooth",
172
+ input_shape: Optional[tuple[int, int]] = None,
173
+ enable_tqdm: Optional[bool] = False,
174
+ **kwargs,
175
+ ) -> tuple[
176
+ torch.Tensor, # color, "batch view 3 height width"
177
+ torch.Tensor, # depth, "batch view height width"
178
+ ]:
179
+ cam2world = affine_inverse(as_homogeneous(extrinsics))
180
+ if input_shape is not None:
181
+ in_h, in_w = input_shape
182
+ else:
183
+ in_h, in_w = image_shape
184
+ intr_normed = intrinsics.clone().detach()
185
+ intr_normed[..., 0, :] /= in_w
186
+ intr_normed[..., 1, :] /= in_h
187
+ if extrinsics.shape[1] <= 1:
188
+ assert trj_mode in [
189
+ "wander",
190
+ "dolly_zoom",
191
+ ], "Please set trj_mode to 'wander' or 'dolly_zoom' when n_views=1"
192
+
193
+ def _smooth_trj_fn_batch(raw_c2ws, k_size=50):
194
+ try:
195
+ smooth_c2ws = torch.stack(
196
+ [render_stabilization_path(c2w_i, k_size) for c2w_i in raw_c2ws],
197
+ dim=0,
198
+ )
199
+ except Exception as e:
200
+ print(f"[DEBUG] Path smoothing failed with error: {e}.")
201
+ smooth_c2ws = raw_c2ws
202
+ return smooth_c2ws
203
+
204
+ # get rendered trj
205
+ if trj_mode == "original":
206
+ tgt_c2w = cam2world
207
+ tgt_intr = intr_normed
208
+ elif trj_mode == "smooth":
209
+ tgt_c2w = _smooth_trj_fn_batch(cam2world)
210
+ tgt_intr = intr_normed
211
+ elif trj_mode in ["interpolate", "interpolate_smooth", "extend"]:
212
+ inter_len = 8
213
+ total_len = (cam2world.shape[1] - 1) * inter_len
214
+ if total_len > 24 * 18: # no more than 18s
215
+ inter_len = max(1, 24 * 10 // (cam2world.shape[1] - 1))
216
+ if total_len < 24 * 2: # no less than 2s
217
+ inter_len = max(1, 24 * 2 // (cam2world.shape[1] - 1))
218
+
219
+ if inter_len > 2:
220
+ t = torch.linspace(0, 1, inter_len, dtype=torch.float32, device=cam2world.device)
221
+ t = (torch.cos(torch.pi * (t + 1)) + 1) / 2
222
+ tgt_c2w_b = []
223
+ tgt_intr_b = []
224
+ for b_idx in range(cam2world.shape[0]):
225
+ tgt_c2w = []
226
+ tgt_intr = []
227
+ for cur_idx in range(cam2world.shape[1] - 1):
228
+ tgt_c2w.append(
229
+ interpolate_extrinsics(
230
+ cam2world[b_idx, cur_idx], cam2world[b_idx, cur_idx + 1], t
231
+ )[(0 if cur_idx == 0 else 1) :]
232
+ )
233
+ tgt_intr.append(
234
+ interpolate_intrinsics(
235
+ intr_normed[b_idx, cur_idx], intr_normed[b_idx, cur_idx + 1], t
236
+ )[(0 if cur_idx == 0 else 1) :]
237
+ )
238
+ tgt_c2w_b.append(torch.cat(tgt_c2w))
239
+ tgt_intr_b.append(torch.cat(tgt_intr))
240
+ tgt_c2w = torch.stack(tgt_c2w_b) # b v 4 4
241
+ tgt_intr = torch.stack(tgt_intr_b) # b v 3 3
242
+ else:
243
+ tgt_c2w = cam2world
244
+ tgt_intr = intr_normed
245
+ if trj_mode in ["interpolate_smooth", "extend"]:
246
+ tgt_c2w = _smooth_trj_fn_batch(tgt_c2w)
247
+ if trj_mode == "extend":
248
+ # apply dolly_zoom and wander in the middle frame
249
+ assert cam2world.shape[0] == 1, "extend only supports for batch_size=1 currently."
250
+ mid_idx = tgt_c2w.shape[1] // 2
251
+ c2w_wd, intr_wd = render_wander_path(
252
+ tgt_c2w[0, mid_idx],
253
+ tgt_intr[0, mid_idx],
254
+ h=in_h,
255
+ w=in_w,
256
+ num_frames=max(36, min(60, mid_idx // 2)),
257
+ max_disp=24.0,
258
+ )
259
+ c2w_dz, intr_dz = render_dolly_zoom_path(
260
+ tgt_c2w[0, mid_idx],
261
+ tgt_intr[0, mid_idx],
262
+ h=in_h,
263
+ w=in_w,
264
+ num_frames=max(36, min(60, mid_idx // 2)),
265
+ )
266
+ tgt_c2w = torch.cat(
267
+ [
268
+ tgt_c2w[:, :mid_idx],
269
+ c2w_wd.unsqueeze(0),
270
+ c2w_dz.unsqueeze(0),
271
+ tgt_c2w[:, mid_idx:],
272
+ ],
273
+ dim=1,
274
+ )
275
+ tgt_intr = torch.cat(
276
+ [
277
+ tgt_intr[:, :mid_idx],
278
+ intr_wd.unsqueeze(0),
279
+ intr_dz.unsqueeze(0),
280
+ tgt_intr[:, mid_idx:],
281
+ ],
282
+ dim=1,
283
+ )
284
+ elif trj_mode in ["wander", "dolly_zoom"]:
285
+ if trj_mode == "wander":
286
+ render_fn = render_wander_path
287
+ extra_kwargs = {"max_disp": 24.0}
288
+ else:
289
+ render_fn = render_dolly_zoom_path
290
+ extra_kwargs = {"D_focus": 30.0, "max_disp": 2.0}
291
+ tgt_c2w = []
292
+ tgt_intr = []
293
+ for b_idx in range(cam2world.shape[0]):
294
+ c2w_i, intr_i = render_fn(
295
+ cam2world[b_idx, 0], intr_normed[b_idx, 0], h=in_h, w=in_w, **extra_kwargs
296
+ )
297
+ tgt_c2w.append(c2w_i)
298
+ tgt_intr.append(intr_i)
299
+ tgt_c2w = torch.stack(tgt_c2w)
300
+ tgt_intr = torch.stack(tgt_intr)
301
+ elif trj_mode == "wobble_inter":
302
+ tgt_c2w, tgt_intr = render_wobble_inter_path(
303
+ cam2world=cam2world,
304
+ intr_normed=intr_normed,
305
+ inter_len=10,
306
+ n_skip=3,
307
+ )
308
+ else:
309
+ raise Exception(f"trj mode [{trj_mode}] is not implemented.")
310
+
311
+ _, v = tgt_c2w.shape[:2]
312
+ tgt_extr = affine_inverse(tgt_c2w)
313
+ if chunk_size is None:
314
+ chunk_size = v
315
+ chunk_size = min(v, chunk_size)
316
+ all_colors = []
317
+ all_depths = []
318
+ for chunk_idx in tqdm(
319
+ range(math.ceil(v / chunk_size)),
320
+ desc="Rendering novel views",
321
+ disable=(not enable_tqdm),
322
+ leave=False,
323
+ ):
324
+ s = int(chunk_idx * chunk_size)
325
+ e = int((chunk_idx + 1) * chunk_size)
326
+ cur_n_view = tgt_extr[:, s:e].shape[1]
327
+ color, depth = render_3dgs(
328
+ extrinsics=rearrange(tgt_extr[:, s:e], "b v ... -> (b v) ..."), # w2c
329
+ intrinsics=rearrange(tgt_intr[:, s:e], "b v ... -> (b v) ..."), # normed
330
+ image_shape=image_shape,
331
+ gaussian=gaussians,
332
+ num_view=cur_n_view,
333
+ **kwargs,
334
+ )
335
+ all_colors.append(rearrange(color, "(b v) ... -> b v ...", v=cur_n_view))
336
+ all_depths.append(rearrange(depth, "(b v) ... -> b v ...", v=cur_n_view))
337
+ all_colors = torch.cat(all_colors, dim=1)
338
+ all_depths = torch.cat(all_depths, dim=1)
339
+
340
+ return all_colors, all_depths
multivue/cameras/source/src/depth_anything_3/model/utils/head_utils.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Tuple, Union
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+ # -----------------------------------------------------------------------------
21
+ # Activation functions
22
+ # -----------------------------------------------------------------------------
23
+
24
+
25
+ def activate_head_gs(out, activation="norm_exp", conf_activation="expp1", conf_dim=None):
26
+ """
27
+ Process network output to extract GS params and density values.
28
+ Density could be view-dependent as SH coefficient
29
+
30
+
31
+ Args:
32
+ out: Network output tensor (B, C, H, W)
33
+ activation: Activation type for 3D points
34
+ conf_activation: Activation type for confidence values
35
+
36
+ Returns:
37
+ Tuple of (3D points tensor, confidence tensor)
38
+ """
39
+ # Move channels from last dim to the 4th dimension => (B, H, W, C)
40
+ fmap = out.permute(0, 2, 3, 1) # B,H,W,C expected
41
+
42
+ # Split into xyz (first C-1 channels) and confidence (last channel)
43
+ conf_dim = 1 if conf_dim is None else conf_dim
44
+ xyz = fmap[:, :, :, :-conf_dim]
45
+ conf = fmap[:, :, :, -1] if conf_dim == 1 else fmap[:, :, :, -conf_dim:]
46
+
47
+ if activation == "norm_exp":
48
+ d = xyz.norm(dim=-1, keepdim=True).clamp(min=1e-8)
49
+ xyz_normed = xyz / d
50
+ pts3d = xyz_normed * torch.expm1(d)
51
+ elif activation == "norm":
52
+ pts3d = xyz / xyz.norm(dim=-1, keepdim=True)
53
+ elif activation == "exp":
54
+ pts3d = torch.exp(xyz)
55
+ elif activation == "relu":
56
+ pts3d = F.relu(xyz)
57
+ elif activation == "sigmoid":
58
+ pts3d = torch.sigmoid(xyz)
59
+ elif activation == "linear":
60
+ pts3d = xyz
61
+ else:
62
+ raise ValueError(f"Unknown activation: {activation}")
63
+
64
+ if conf_activation == "expp1":
65
+ conf_out = 1 + conf.exp()
66
+ elif conf_activation == "expp0":
67
+ conf_out = conf.exp()
68
+ elif conf_activation == "sigmoid":
69
+ conf_out = torch.sigmoid(conf)
70
+ elif conf_activation == "linear":
71
+ conf_out = conf
72
+ else:
73
+ raise ValueError(f"Unknown conf_activation: {conf_activation}")
74
+
75
+ return pts3d, conf_out
76
+
77
+
78
+ # -----------------------------------------------------------------------------
79
+ # Other utilities
80
+ # -----------------------------------------------------------------------------
81
+
82
+
83
+ class Permute(nn.Module):
84
+ """nn.Module wrapper around Tensor.permute for cleaner nn.Sequential usage."""
85
+
86
+ dims: Tuple[int, ...]
87
+
88
+ def __init__(self, dims: Tuple[int, ...]) -> None:
89
+ super().__init__()
90
+ self.dims = dims
91
+
92
+ def forward(self, x: torch.Tensor) -> torch.Tensor: # type: ignore[override]
93
+ return x.permute(*self.dims)
94
+
95
+
96
+ def position_grid_to_embed(
97
+ pos_grid: torch.Tensor, embed_dim: int, omega_0: float = 100
98
+ ) -> torch.Tensor:
99
+ """
100
+ Convert 2D position grid (HxWx2) to sinusoidal embeddings (HxWxC)
101
+
102
+ Args:
103
+ pos_grid: Tensor of shape (H, W, 2) containing 2D coordinates
104
+ embed_dim: Output channel dimension for embeddings
105
+
106
+ Returns:
107
+ Tensor of shape (H, W, embed_dim) with positional embeddings
108
+ """
109
+ H, W, grid_dim = pos_grid.shape
110
+ assert grid_dim == 2
111
+ pos_flat = pos_grid.reshape(-1, grid_dim) # Flatten to (H*W, 2)
112
+
113
+ # Process x and y coordinates separately
114
+ emb_x = make_sincos_pos_embed(embed_dim // 2, pos_flat[:, 0], omega_0=omega_0) # [1, H*W, D/2]
115
+ emb_y = make_sincos_pos_embed(embed_dim // 2, pos_flat[:, 1], omega_0=omega_0) # [1, H*W, D/2]
116
+
117
+ # Combine and reshape
118
+ emb = torch.cat([emb_x, emb_y], dim=-1) # [1, H*W, D]
119
+
120
+ return emb.view(H, W, embed_dim) # [H, W, D]
121
+
122
+
123
+ def make_sincos_pos_embed(embed_dim: int, pos: torch.Tensor, omega_0: float = 100) -> torch.Tensor:
124
+ """
125
+ This function generates a 1D positional embedding from a given grid using sine and cosine functions. # noqa
126
+
127
+ Args:
128
+ - embed_dim: The embedding dimension.
129
+ - pos: The position to generate the embedding from.
130
+
131
+ Returns:
132
+ - emb: The generated 1D positional embedding.
133
+ """
134
+ assert embed_dim % 2 == 0
135
+ omega = torch.arange(embed_dim // 2, dtype=torch.float32, device=pos.device)
136
+ omega /= embed_dim / 2.0
137
+ omega = 1.0 / omega_0**omega # (D/2,)
138
+
139
+ pos = pos.reshape(-1) # (M,)
140
+ out = torch.einsum("m,d->md", pos, omega) # (M, D/2), outer product
141
+
142
+ emb_sin = torch.sin(out) # (M, D/2)
143
+ emb_cos = torch.cos(out) # (M, D/2)
144
+
145
+ emb = torch.cat([emb_sin, emb_cos], dim=1) # (M, D)
146
+ return emb.float()
147
+
148
+
149
+ # Inspired by https://github.com/microsoft/moge
150
+
151
+
152
+ def create_uv_grid(
153
+ width: int,
154
+ height: int,
155
+ aspect_ratio: float = None,
156
+ dtype: torch.dtype = None,
157
+ device: torch.device = None,
158
+ ) -> torch.Tensor:
159
+ """
160
+ Create a normalized UV grid of shape (width, height, 2).
161
+
162
+ The grid spans horizontally and vertically according to an aspect ratio,
163
+ ensuring the top-left corner is at (-x_span, -y_span) and the bottom-right
164
+ corner is at (x_span, y_span), normalized by the diagonal of the plane.
165
+
166
+ Args:
167
+ width (int): Number of points horizontally.
168
+ height (int): Number of points vertically.
169
+ aspect_ratio (float, optional): Width-to-height ratio. Defaults to width/height.
170
+ dtype (torch.dtype, optional): Data type of the resulting tensor.
171
+ device (torch.device, optional): Device on which the tensor is created.
172
+
173
+ Returns:
174
+ torch.Tensor: A (width, height, 2) tensor of UV coordinates.
175
+ """
176
+ # Derive aspect ratio if not explicitly provided
177
+ if aspect_ratio is None:
178
+ aspect_ratio = float(width) / float(height)
179
+
180
+ # Compute normalized spans for X and Y
181
+ diag_factor = (aspect_ratio**2 + 1.0) ** 0.5
182
+ span_x = aspect_ratio / diag_factor
183
+ span_y = 1.0 / diag_factor
184
+
185
+ # Establish the linspace boundaries
186
+ left_x = -span_x * (width - 1) / width
187
+ right_x = span_x * (width - 1) / width
188
+ top_y = -span_y * (height - 1) / height
189
+ bottom_y = span_y * (height - 1) / height
190
+
191
+ # Generate 1D coordinates
192
+ x_coords = torch.linspace(left_x, right_x, steps=width, dtype=dtype, device=device)
193
+ y_coords = torch.linspace(top_y, bottom_y, steps=height, dtype=dtype, device=device)
194
+
195
+ # Create 2D meshgrid (width x height) and stack into UV
196
+ uu, vv = torch.meshgrid(x_coords, y_coords, indexing="xy")
197
+ uv_grid = torch.stack((uu, vv), dim=-1)
198
+
199
+ return uv_grid
200
+
201
+
202
+ # -----------------------------------------------------------------------------
203
+ # Interpolation (safe interpolation, avoid INT_MAX overflow)
204
+ # -----------------------------------------------------------------------------
205
+ def custom_interpolate(
206
+ x: torch.Tensor,
207
+ size: Union[Tuple[int, int], None] = None,
208
+ scale_factor: Union[float, None] = None,
209
+ mode: str = "bilinear",
210
+ align_corners: bool = True,
211
+ ) -> torch.Tensor:
212
+ """
213
+ Safe interpolation implementation to avoid INT_MAX overflow in torch.nn.functional.interpolate.
214
+ """
215
+ if size is None:
216
+ assert scale_factor is not None, "Either size or scale_factor must be provided."
217
+ size = (int(x.shape[-2] * scale_factor), int(x.shape[-1] * scale_factor))
218
+
219
+ INT_MAX = 1610612736
220
+ total = size[0] * size[1] * x.shape[0] * x.shape[1]
221
+
222
+ if total > INT_MAX:
223
+ chunks = torch.chunk(x, chunks=(total // INT_MAX) + 1, dim=0)
224
+ outs = [
225
+ nn.functional.interpolate(c, size=size, mode=mode, align_corners=align_corners)
226
+ for c in chunks
227
+ ]
228
+ return torch.cat(outs, dim=0).contiguous()
229
+
230
+ return nn.functional.interpolate(x, size=size, mode=mode, align_corners=align_corners)
multivue/cameras/source/src/depth_anything_3/model/utils/transform.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import torch
16
+ import torch.nn.functional as F
17
+
18
+
19
+ def extri_intri_to_pose_encoding(
20
+ extrinsics,
21
+ intrinsics,
22
+ image_size_hw=None,
23
+ ):
24
+ """Convert camera extrinsics and intrinsics to a compact pose encoding."""
25
+
26
+ # extrinsics: BxSx3x4
27
+ # intrinsics: BxSx3x3
28
+ R = extrinsics[:, :, :3, :3] # BxSx3x3
29
+ T = extrinsics[:, :, :3, 3] # BxSx3
30
+
31
+ quat = mat_to_quat(R)
32
+ # Note the order of h and w here
33
+ H, W = image_size_hw
34
+ fov_h = 2 * torch.atan((H / 2) / intrinsics[..., 1, 1])
35
+ fov_w = 2 * torch.atan((W / 2) / intrinsics[..., 0, 0])
36
+ pose_encoding = torch.cat([T, quat, fov_h[..., None], fov_w[..., None]], dim=-1).float()
37
+
38
+ return pose_encoding
39
+
40
+
41
+ def pose_encoding_to_extri_intri(
42
+ pose_encoding,
43
+ image_size_hw=None,
44
+ ):
45
+ """Convert a pose encoding back to camera extrinsics and intrinsics."""
46
+
47
+ T = pose_encoding[..., :3]
48
+ quat = pose_encoding[..., 3:7]
49
+ fov_h = pose_encoding[..., 7]
50
+ fov_w = pose_encoding[..., 8]
51
+
52
+ R = quat_to_mat(quat)
53
+ extrinsics = torch.cat([R, T[..., None]], dim=-1)
54
+
55
+ H, W = image_size_hw
56
+ fy = (H / 2.0) / torch.clamp(torch.tan(fov_h / 2.0), 1e-6)
57
+ fx = (W / 2.0) / torch.clamp(torch.tan(fov_w / 2.0), 1e-6)
58
+ intrinsics = torch.zeros(pose_encoding.shape[:2] + (3, 3), device=pose_encoding.device)
59
+ intrinsics[..., 0, 0] = fx
60
+ intrinsics[..., 1, 1] = fy
61
+ intrinsics[..., 0, 2] = W / 2
62
+ intrinsics[..., 1, 2] = H / 2
63
+ intrinsics[..., 2, 2] = 1.0 # Set the homogeneous coordinate to 1
64
+
65
+ return extrinsics, intrinsics
66
+
67
+
68
+ def quat_to_mat(quaternions: torch.Tensor) -> torch.Tensor:
69
+ """
70
+ Quaternion Order: XYZW or say ijkr, scalar-last
71
+
72
+ Convert rotations given as quaternions to rotation matrices.
73
+ Args:
74
+ quaternions: quaternions with real part last,
75
+ as tensor of shape (..., 4).
76
+
77
+ Returns:
78
+ Rotation matrices as tensor of shape (..., 3, 3).
79
+ """
80
+ i, j, k, r = torch.unbind(quaternions, -1)
81
+ two_s = 2.0 / (quaternions * quaternions).sum(-1)
82
+
83
+ o = torch.stack(
84
+ (
85
+ 1 - two_s * (j * j + k * k),
86
+ two_s * (i * j - k * r),
87
+ two_s * (i * k + j * r),
88
+ two_s * (i * j + k * r),
89
+ 1 - two_s * (i * i + k * k),
90
+ two_s * (j * k - i * r),
91
+ two_s * (i * k - j * r),
92
+ two_s * (j * k + i * r),
93
+ 1 - two_s * (i * i + j * j),
94
+ ),
95
+ -1,
96
+ )
97
+ return o.reshape(quaternions.shape[:-1] + (3, 3))
98
+
99
+
100
+ def mat_to_quat(matrix: torch.Tensor) -> torch.Tensor:
101
+ """
102
+ Convert rotations given as rotation matrices to quaternions.
103
+
104
+ Args:
105
+ matrix: Rotation matrices as tensor of shape (..., 3, 3).
106
+
107
+ Returns:
108
+ quaternions with real part last, as tensor of shape (..., 4).
109
+ Quaternion Order: XYZW or say ijkr, scalar-last
110
+ """
111
+ if matrix.size(-1) != 3 or matrix.size(-2) != 3:
112
+ raise ValueError(f"Invalid rotation matrix shape {matrix.shape}.")
113
+
114
+ batch_dim = matrix.shape[:-2]
115
+ m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
116
+ matrix.reshape(batch_dim + (9,)), dim=-1
117
+ )
118
+
119
+ q_abs = _sqrt_positive_part(
120
+ torch.stack(
121
+ [
122
+ 1.0 + m00 + m11 + m22,
123
+ 1.0 + m00 - m11 - m22,
124
+ 1.0 - m00 + m11 - m22,
125
+ 1.0 - m00 - m11 + m22,
126
+ ],
127
+ dim=-1,
128
+ )
129
+ )
130
+
131
+ quat_by_rijk = torch.stack(
132
+ [
133
+ torch.stack([q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01], dim=-1),
134
+ torch.stack([m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20], dim=-1),
135
+ torch.stack([m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21], dim=-1),
136
+ torch.stack([m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2], dim=-1),
137
+ ],
138
+ dim=-2,
139
+ )
140
+
141
+ flr = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
142
+ quat_candidates = quat_by_rijk / (2.0 * q_abs[..., None].max(flr))
143
+
144
+ out = quat_candidates[F.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, :].reshape(
145
+ batch_dim + (4,)
146
+ )
147
+
148
+ out = out[..., [1, 2, 3, 0]]
149
+
150
+ out = standardize_quaternion(out)
151
+
152
+ return out
153
+
154
+
155
+ def _sqrt_positive_part(x: torch.Tensor) -> torch.Tensor:
156
+ """
157
+ Returns torch.sqrt(torch.max(0, x))
158
+ but with a zero subgradient where x is 0.
159
+ """
160
+ ret = torch.zeros_like(x)
161
+ positive_mask = x > 0
162
+ if torch.is_grad_enabled():
163
+ ret[positive_mask] = torch.sqrt(x[positive_mask])
164
+ else:
165
+ ret = torch.where(positive_mask, torch.sqrt(x), ret)
166
+ return ret
167
+
168
+
169
+ def standardize_quaternion(quaternions: torch.Tensor) -> torch.Tensor:
170
+ """
171
+ Convert a unit quaternion to a standard form: one in which the real
172
+ part is non negative.
173
+
174
+ Args:
175
+ quaternions: Quaternions with real part last,
176
+ as tensor of shape (..., 4).
177
+
178
+ Returns:
179
+ Standardized quaternions as tensor of shape (..., 4).
180
+ """
181
+ return torch.where(quaternions[..., 3:4] < 0, -quaternions, quaternions)
182
+
183
+
184
+ def cam_quat_xyzw_to_world_quat_wxyz(cam_quat_xyzw, c2w):
185
+ # cam_quat_xyzw: (b, n, 4) in xyzw
186
+ # c2w: (b, n, 4, 4)
187
+ b, n = cam_quat_xyzw.shape[:2]
188
+ # 1. xyzw -> wxyz
189
+ cam_quat_wxyz = torch.cat(
190
+ [
191
+ cam_quat_xyzw[..., 3:4], # w
192
+ cam_quat_xyzw[..., 0:1], # x
193
+ cam_quat_xyzw[..., 1:2], # y
194
+ cam_quat_xyzw[..., 2:3], # z
195
+ ],
196
+ dim=-1,
197
+ )
198
+ # 2. Quaternion to matrix
199
+ cam_quat_wxyz_flat = cam_quat_wxyz.reshape(-1, 4)
200
+ rotmat_cam = quat_to_mat(cam_quat_wxyz_flat).reshape(b, n, 3, 3)
201
+ # 3. Transform to world space
202
+ rotmat_c2w = c2w[..., :3, :3]
203
+ rotmat_world = torch.matmul(rotmat_c2w, rotmat_cam)
204
+ # 4. Matrix to quaternion (wxyz)
205
+ rotmat_world_flat = rotmat_world.reshape(-1, 3, 3)
206
+ world_quat_wxyz_flat = mat_to_quat(rotmat_world_flat)
207
+ world_quat_wxyz = world_quat_wxyz_flat.reshape(b, n, 4)
208
+ return world_quat_wxyz
multivue/cameras/source/src/depth_anything_3/registry.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from collections import OrderedDict
16
+ from pathlib import Path
17
+
18
+
19
+ def get_all_models() -> OrderedDict:
20
+ """
21
+ Scans all YAML files in the configs directory and returns a sorted dictionary where:
22
+ - Keys are model names (YAML filenames without the .yaml extension)
23
+ - Values are absolute paths to the corresponding YAML files
24
+ """
25
+ # Get path to the configs directory within the da3 package
26
+ # Works both in development and after pip installation
27
+ # configs_dir = files("depth_anything_3").joinpath("configs")
28
+ configs_dir = Path(__file__).resolve().parent / "configs"
29
+
30
+ # Ensure path is a Path object for consistent cross-platform handling
31
+ configs_dir = Path(configs_dir)
32
+
33
+ model_entries = []
34
+ # Iterate through all items in the configs directory
35
+ for item in configs_dir.iterdir():
36
+ # Filter for YAML files (excluding directories)
37
+ if item.is_file() and item.suffix == ".yaml":
38
+ # Extract model name (filename without .yaml extension)
39
+ model_name = item.stem
40
+ # Get absolute path (resolve() handles symlinks)
41
+ file_abs_path = str(item.resolve())
42
+ model_entries.append((model_name, file_abs_path))
43
+
44
+ # Sort entries by model name and convert to OrderedDict
45
+ sorted_entries = sorted(model_entries, key=lambda x: x[0])
46
+ return OrderedDict(sorted_entries)
47
+
48
+
49
+ # Global registry for external imports
50
+ MODEL_REGISTRY = get_all_models()
multivue/cameras/source/src/depth_anything_3/services/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Services module for Depth Anything 3.
17
+ """
18
+
19
+ from depth_anything_3.services.backend import create_app, start_server
20
+
21
+ __all__ = [
22
+ start_server,
23
+ create_app,
24
+ ]
multivue/cameras/source/src/depth_anything_3/services/backend.py ADDED
@@ -0,0 +1,1556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # flake8: noqa: E501
2
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ Model backend service for Depth Anything 3.
18
+ Provides HTTP API for model inference with persistent model loading.
19
+ """
20
+
21
+ import hmac
22
+ import html
23
+ import os
24
+ import posixpath
25
+ import re
26
+ import secrets
27
+ import time
28
+ import uuid
29
+
30
+ from concurrent.futures import ThreadPoolExecutor
31
+ from typing import Any, Dict, List, Optional
32
+ from urllib.parse import quote
33
+ import numpy as np
34
+
35
+ import uvicorn
36
+ from fastapi import Depends, FastAPI, Header, HTTPException
37
+ from fastapi.responses import FileResponse, HTMLResponse
38
+ from pydantic import BaseModel, field_validator
39
+
40
+ from ..api import DepthAnything3
41
+ from ..utils.export import SUPPORTED_EXPORT_FORMATS
42
+ from ..utils.memory import (
43
+ get_gpu_memory_info,
44
+ cleanup_cuda_memory,
45
+ check_memory_availability,
46
+ estimate_memory_requirement,
47
+ )
48
+
49
+
50
+ class InferenceRequest(BaseModel):
51
+ """Request model for inference API."""
52
+
53
+ image_paths: List[str]
54
+ export_dir: Optional[str] = None
55
+ export_format: str = "mini_npz-glb"
56
+ extrinsics: Optional[List[List[List[float]]]] = None
57
+ intrinsics: Optional[List[List[List[float]]]] = None
58
+ process_res: int = 504
59
+ process_res_method: str = "upper_bound_resize"
60
+ export_feat_layers: List[int] = []
61
+ align_to_input_ext_scale: bool = True
62
+ # GLB export parameters
63
+ conf_thresh_percentile: float = 40.0
64
+ num_max_points: int = 1_000_000
65
+ show_cameras: bool = True
66
+ # Feat_vis export parameters
67
+ feat_vis_fps: int = 15
68
+
69
+ @field_validator("export_format")
70
+ @classmethod
71
+ def _validate_export_format(cls, value: str) -> str:
72
+ """Restrict export_format to the server's known/trusted export dispatcher formats,
73
+ instead of letting an arbitrary client-supplied string reach the export() dispatcher."""
74
+ tokens = value.split("-") if value else [value]
75
+ unknown = sorted(set(tokens) - SUPPORTED_EXPORT_FORMATS)
76
+ if unknown:
77
+ raise ValueError(
78
+ f"Unsupported export_format value(s): {unknown}. "
79
+ f"Supported formats: {sorted(SUPPORTED_EXPORT_FORMATS)}"
80
+ )
81
+ return value
82
+
83
+
84
+ class InferenceResponse(BaseModel):
85
+ """Response model for inference API."""
86
+
87
+ success: bool
88
+ message: str
89
+ task_id: Optional[str] = None
90
+ export_dir: Optional[str] = None
91
+ export_format: str = "mini_npz-glb"
92
+ processing_time: Optional[float] = None
93
+
94
+
95
+ class TaskStatus(BaseModel):
96
+ """Task status model."""
97
+
98
+ task_id: str
99
+ status: str # "pending", "running", "completed", "failed"
100
+ message: str
101
+ progress: Optional[float] = None # 0.0 to 1.0
102
+ created_at: float
103
+ started_at: Optional[float] = None
104
+ completed_at: Optional[float] = None
105
+ export_dir: Optional[str] = None
106
+ request: Optional[InferenceRequest] = None # Store the original request
107
+
108
+ # Essential task parameters
109
+ num_images: Optional[int] = None # Number of input images
110
+ export_format: Optional[str] = None # Export format
111
+ process_res_method: Optional[str] = None # Processing resolution method
112
+ video_path: Optional[str] = None # Source video path
113
+
114
+
115
+ def _render_task_card(task: "TaskStatus", css_class: str) -> str:
116
+ """Render a single task as an HTML fragment for the dashboard.
117
+
118
+ Every field is HTML-escaped: task fields originate from a client request and
119
+ are rendered on a page other users may view.
120
+ """
121
+ e = html.escape
122
+
123
+ video_row = ""
124
+ if task.video_path:
125
+ video_row = f"<br><small>Video: {e(task.video_path)}</small>"
126
+
127
+ return f"""
128
+ <div class="task-item {e(css_class)}">
129
+ <div class="task-header">
130
+ <span class="task-id">{e(task.task_id)}</span>
131
+ <span class="task-status status-{e(task.status)}">{e(task.status)}</span>
132
+ </div>
133
+ <div class="task-message">{e(task.message)}</div>
134
+ <div class="task-params">
135
+ <small>
136
+ Images: {e(str(task.num_images)) if task.num_images is not None else 'N/A'} |
137
+ Format: {e(task.export_format) if task.export_format else 'N/A'} |
138
+ Method: {e(task.process_res_method) if task.process_res_method else 'N/A'} |
139
+ Export Dir: {e(task.export_dir) if task.export_dir else 'N/A'}
140
+ </small>
141
+ {video_row}
142
+ </div>
143
+ </div>
144
+ """
145
+
146
+
147
+ class ModelBackend:
148
+ """Model backend service with persistent model loading."""
149
+
150
+ def __init__(self, model_dir: str, device: str = "cuda"):
151
+ self.model_dir = model_dir
152
+ self.device = device
153
+ self.model = None
154
+ self.model_loaded = False
155
+ self.load_time = None
156
+ self.load_start_time = None # Time when model loading started
157
+ self.load_completed_time = None # Time when model loading completed
158
+ self.last_used = None
159
+
160
+ def load_model(self):
161
+ """Load model if not already loaded."""
162
+ if self.model_loaded and self.model is not None:
163
+ self.last_used = time.time()
164
+ return self.model
165
+
166
+ try:
167
+ print(f"Loading model from {self.model_dir}...")
168
+ self.load_start_time = time.time()
169
+ start_time = time.time()
170
+
171
+ self.model = DepthAnything3.from_pretrained(self.model_dir).to(self.device)
172
+ self.model.eval()
173
+
174
+ self.model_loaded = True
175
+ self.load_time = time.time() - start_time
176
+ self.load_completed_time = time.time()
177
+ self.last_used = time.time()
178
+
179
+ print(f"Model loaded successfully in {self.load_time:.2f}s")
180
+ return self.model
181
+
182
+ except Exception as e:
183
+ print(f"Failed to load model: {e}")
184
+ raise e
185
+
186
+ def get_model(self):
187
+ """Get model, loading if necessary."""
188
+ if not self.model_loaded:
189
+ return self.load_model()
190
+ self.last_used = time.time()
191
+ return self.model
192
+
193
+ def get_status(self) -> Dict[str, Any]:
194
+ """Get backend status information."""
195
+ # Calculate uptime from when model loading completed
196
+ uptime = 0
197
+ if self.model_loaded and self.load_completed_time:
198
+ uptime = time.time() - self.load_completed_time
199
+
200
+ return {
201
+ "model_loaded": self.model_loaded,
202
+ "model_dir": self.model_dir,
203
+ "device": self.device,
204
+ "load_time": self.load_time,
205
+ "last_used": self.last_used,
206
+ "uptime": uptime,
207
+ }
208
+
209
+
210
+ # Global backend instance
211
+ _backend: Optional[ModelBackend] = None
212
+ _app: Optional[FastAPI] = None
213
+ _gallery_dir: Optional[str] = None
214
+ _tasks: Dict[str, TaskStatus] = {}
215
+ _executor = ThreadPoolExecutor(max_workers=1) # Restrict to single-task execution
216
+ _running_task_id: Optional[str] = None # Currently running task ID
217
+ _task_queue: List[str] = [] # Pending task queue
218
+
219
+ # Task cleanup configuration
220
+ MAX_TASK_HISTORY = 100 # Maximum number of tasks to keep in memory
221
+ CLEANUP_INTERVAL = 300 # Cleanup interval in seconds (5 minutes)
222
+
223
+
224
+ def _process_next_task():
225
+ """Process the next task in the queue."""
226
+ global _task_queue, _running_task_id
227
+
228
+ if not _task_queue or _running_task_id is not None:
229
+ return
230
+
231
+ # Get next task from queue
232
+ task_id = _task_queue.pop(0)
233
+
234
+ # Get task request from tasks dict (we need to store the request)
235
+ if task_id not in _tasks:
236
+ return
237
+
238
+ # Submit task to executor
239
+ _executor.submit(_run_inference_task, task_id)
240
+
241
+
242
+ # get_gpu_memory_info imported from depth_anything_3.utils.memory
243
+
244
+
245
+ # cleanup_cuda_memory imported from depth_anything_3.utils.memory
246
+
247
+
248
+ # check_memory_availability imported from depth_anything_3.utils.memory
249
+
250
+
251
+ # estimate_memory_requirement imported from depth_anything_3.utils.memory
252
+
253
+
254
+ def _run_inference_task(task_id: str):
255
+ """Run inference task in background thread with OOM protection."""
256
+ global _tasks, _backend, _running_task_id, _task_queue
257
+
258
+ model = None
259
+ inference_started = False
260
+ start_time = time.time()
261
+
262
+ try:
263
+ # Get task request
264
+ if task_id not in _tasks or _tasks[task_id].request is None:
265
+ print(f"[{task_id}] Task not found or request missing")
266
+ return
267
+
268
+ request = _tasks[task_id].request
269
+ num_images = len(request.image_paths)
270
+
271
+ # Set current running task
272
+ _running_task_id = task_id
273
+
274
+ # Update task status to running
275
+ _tasks[task_id].status = "running"
276
+ _tasks[task_id].started_at = start_time
277
+ _tasks[task_id].message = f"[{task_id}] Starting inference on {num_images} frames..."
278
+ print(f"[{task_id}] Starting inference on {num_images} frames")
279
+
280
+ # Pre-inference cleanup to ensure maximum available memory
281
+ print(f"[{task_id}] Pre-inference cleanup...")
282
+ cleanup_cuda_memory()
283
+
284
+ # Check memory availability
285
+ estimated_memory = estimate_memory_requirement(num_images, request.process_res)
286
+ mem_available, mem_msg = check_memory_availability(estimated_memory)
287
+ print(f"[{task_id}] {mem_msg}")
288
+
289
+ if not mem_available:
290
+ # Try aggressive cleanup
291
+ print(f"[{task_id}] Insufficient memory, attempting aggressive cleanup...")
292
+ cleanup_cuda_memory()
293
+ time.sleep(0.5) # Give system time to reclaim memory
294
+
295
+ # Check again
296
+ mem_available, mem_msg = check_memory_availability(estimated_memory)
297
+ if not mem_available:
298
+ raise RuntimeError(
299
+ f"Insufficient GPU memory after cleanup. {mem_msg}\n"
300
+ f"Suggestions:\n"
301
+ f" 1. Reduce process_res (current: {request.process_res})\n"
302
+ f" 2. Process fewer images at once (current: {num_images})\n"
303
+ f" 3. Clear other GPU processes"
304
+ )
305
+
306
+ # Get model (with error handling)
307
+ print(f"[{task_id}] Loading model...")
308
+ _tasks[task_id].message = f"[{task_id}] Loading model..."
309
+ _tasks[task_id].progress = 0.1
310
+
311
+ try:
312
+ model = _backend.get_model()
313
+ except RuntimeError as e:
314
+ if "out of memory" in str(e).lower():
315
+ cleanup_cuda_memory()
316
+ raise RuntimeError(
317
+ f"OOM during model loading: {str(e)}\n"
318
+ f"Try reducing the batch size or resolution."
319
+ )
320
+ raise
321
+
322
+ print(f"[{task_id}] Model loaded successfully")
323
+ _tasks[task_id].progress = 0.2
324
+
325
+ # Prepare inference parameters
326
+ inference_kwargs = {
327
+ "image": request.image_paths,
328
+ "export_format": request.export_format,
329
+ "process_res": request.process_res,
330
+ "process_res_method": request.process_res_method,
331
+ "export_feat_layers": request.export_feat_layers,
332
+ "align_to_input_ext_scale": request.align_to_input_ext_scale,
333
+ "conf_thresh_percentile": request.conf_thresh_percentile,
334
+ "num_max_points": request.num_max_points,
335
+ "show_cameras": request.show_cameras,
336
+ "feat_vis_fps": request.feat_vis_fps,
337
+ }
338
+
339
+ if request.export_dir:
340
+ # Re-check against the current configuration: this task may have sat in the
341
+ # queue for a while after the initial request-time check, and configuration
342
+ # can differ if the process was restarted with a different gallery_dir.
343
+ validate_export_dir(request.export_dir, _gallery_dir)
344
+ inference_kwargs["export_dir"] = request.export_dir
345
+
346
+ if request.extrinsics:
347
+ inference_kwargs["extrinsics"] = np.array(request.extrinsics, dtype=np.float32)
348
+
349
+ if request.intrinsics:
350
+ inference_kwargs["intrinsics"] = np.array(request.intrinsics, dtype=np.float32)
351
+
352
+ # Run inference with timing
353
+ inference_start_time = time.time()
354
+ print(f"[{task_id}] Running model inference...")
355
+ _tasks[task_id].message = f"[{task_id}] Running model inference on {num_images} images..."
356
+ _tasks[task_id].progress = 0.3
357
+
358
+ inference_started = True
359
+
360
+ try:
361
+ model.inference(**inference_kwargs)
362
+ inference_time = time.time() - inference_start_time
363
+ avg_time_per_image = inference_time / num_images if num_images > 0 else 0
364
+
365
+ print(
366
+ f"[{task_id}] Inference completed in {inference_time:.2f}s "
367
+ f"({avg_time_per_image:.2f}s per image)"
368
+ )
369
+
370
+ except RuntimeError as e:
371
+ if "out of memory" in str(e).lower():
372
+ cleanup_cuda_memory()
373
+ raise RuntimeError(
374
+ f"OOM during inference: {str(e)}\n"
375
+ f"Settings: {num_images} images, resolution={request.process_res}\n"
376
+ f"Suggestions:\n"
377
+ f" 1. Reduce process_res to {int(request.process_res * 0.75)}\n"
378
+ f" 2. Process images in smaller batches\n"
379
+ f" 3. Use process_res_method='resize' instead of 'upper_bound_resize'"
380
+ )
381
+ raise
382
+
383
+ _tasks[task_id].progress = 0.9
384
+
385
+ # Post-inference cleanup
386
+ print(f"[{task_id}] Post-inference cleanup...")
387
+ cleanup_cuda_memory()
388
+
389
+ # Calculate total processing time
390
+ total_time = time.time() - start_time
391
+
392
+ # Update task status to completed
393
+ _tasks[task_id].status = "completed"
394
+ _tasks[task_id].completed_at = time.time()
395
+ _tasks[task_id].message = (
396
+ f"[{task_id}] Completed in {total_time:.2f}s " f"({avg_time_per_image:.2f}s per image)"
397
+ )
398
+ _tasks[task_id].progress = 1.0
399
+ _tasks[task_id].export_dir = request.export_dir
400
+
401
+ # Clear running state
402
+ _running_task_id = None
403
+
404
+ # Process next task in queue
405
+ _process_next_task()
406
+
407
+ print(f"[{task_id}] Task completed successfully")
408
+ print(
409
+ f"[{task_id}] Total time: {total_time:.2f}s, "
410
+ f"Inference time: {inference_time:.2f}s, "
411
+ f"Avg per image: {avg_time_per_image:.2f}s"
412
+ )
413
+
414
+ except Exception as e:
415
+ # Update task status to failed
416
+ error_msg = str(e)
417
+ total_time = time.time() - start_time
418
+
419
+ print(f"[{task_id}] Task failed after {total_time:.2f}s: {error_msg}")
420
+
421
+ # Always attempt cleanup on failure
422
+ cleanup_cuda_memory()
423
+
424
+ _tasks[task_id].status = "failed"
425
+ _tasks[task_id].completed_at = time.time()
426
+ _tasks[task_id].message = f"[{task_id}] Failed after {total_time:.2f}s: {error_msg}"
427
+
428
+ # Clear running state
429
+ _running_task_id = None
430
+
431
+ # Process next task in queue
432
+ _process_next_task()
433
+
434
+ finally:
435
+ # Final cleanup in finally block to ensure it always runs
436
+ # This is critical for releasing resources even if unexpected errors occur
437
+ try:
438
+ if inference_started:
439
+ print(f"[{task_id}] Final cleanup in finally block...")
440
+ cleanup_cuda_memory()
441
+ except Exception as e:
442
+ print(f"[{task_id}] Warning: Finally block cleanup failed: {e}")
443
+
444
+ # Schedule cleanup after task completion
445
+ _schedule_task_cleanup()
446
+
447
+
448
+ def _cleanup_old_tasks():
449
+ """Clean up old completed/failed tasks to prevent memory buildup."""
450
+ global _tasks
451
+
452
+ current_time = time.time()
453
+ tasks_to_remove = []
454
+
455
+ # Find tasks to remove - more aggressive cleanup
456
+ for task_id, task in _tasks.items():
457
+ # Remove completed/failed tasks older than 10 minutes (instead of 1 hour)
458
+ if (
459
+ task.status in ["completed", "failed"]
460
+ and task.completed_at
461
+ and current_time - task.completed_at > 600
462
+ ): # 10 minutes
463
+ tasks_to_remove.append(task_id)
464
+
465
+ # Remove old tasks
466
+ for task_id in tasks_to_remove:
467
+ del _tasks[task_id]
468
+ print(f"[CLEANUP] Removed old task: {task_id}")
469
+
470
+ # If still too many tasks, remove oldest completed/failed tasks
471
+ if len(_tasks) > MAX_TASK_HISTORY:
472
+ completed_tasks = [
473
+ (task_id, task)
474
+ for task_id, task in _tasks.items()
475
+ if task.status in ["completed", "failed"]
476
+ ]
477
+ completed_tasks.sort(key=lambda x: x[1].completed_at or 0)
478
+
479
+ excess_count = len(_tasks) - MAX_TASK_HISTORY
480
+ for i in range(min(excess_count, len(completed_tasks))):
481
+ task_id = completed_tasks[i][0]
482
+ del _tasks[task_id]
483
+ print(f"[CLEANUP] Removed excess task: {task_id}")
484
+
485
+ # Count active tasks (only pending and running)
486
+ active_count = sum(1 for task in _tasks.values() if task.status in ["pending", "running"])
487
+ print(
488
+ "[CLEANUP] Task cleanup completed. "
489
+ f"Total tasks: {len(_tasks)}, Active tasks: {active_count}"
490
+ )
491
+
492
+
493
+ def _schedule_task_cleanup():
494
+ """Schedule task cleanup in background."""
495
+
496
+ def cleanup_worker():
497
+ try:
498
+ time.sleep(2) # Small delay to ensure task status is updated
499
+ _cleanup_old_tasks()
500
+ except Exception as e:
501
+ print(f"[CLEANUP] Cleanup worker failed: {e}")
502
+
503
+ # Run cleanup in background thread
504
+ _executor.submit(cleanup_worker)
505
+
506
+
507
+ # ============================================================================
508
+ # Gallery utilities (extracted from gallery.py)
509
+ # ============================================================================
510
+
511
+ GALLERY_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
512
+
513
+
514
+ def _load_gallery_html() -> str:
515
+ """
516
+ Load and modify gallery HTML to work under /gallery/ subdirectory.
517
+ Replaces API paths from root to /gallery/ prefix.
518
+ """
519
+ from ..services.gallery import HTML_PAGE
520
+
521
+ # Replace API paths to be under /gallery/ subdirectory
522
+ html = (
523
+ HTML_PAGE.replace("fetch('/manifest.json'", "fetch('/gallery/manifest.json'")
524
+ .replace("fetch('/manifest/'+", "fetch('/gallery/manifest/'+")
525
+ .replace(
526
+ "if(location.pathname!=\"/\")history.replaceState(null,'','/'+location.search)",
527
+ "if(!location.pathname.startsWith(\"/gallery\"))history.replaceState(null,'','/gallery/'+location.search)",
528
+ )
529
+ )
530
+
531
+ return html
532
+
533
+
534
+ def _gallery_url_join(*parts: str) -> str:
535
+ """Join URL parts safely."""
536
+ norm = posixpath.join(*[p.replace("\\", "/") for p in parts])
537
+ segs = [s for s in norm.split("/") if s not in ("", ".")]
538
+ return "/".join(quote(s) for s in segs)
539
+
540
+
541
+ def _is_plain_name(name: str) -> bool:
542
+ """Check if name is safe for use in paths."""
543
+ return all(c not in name for c in ("/", "\\")) and name not in (".", "..")
544
+
545
+
546
+ def build_group_list(root_dir: str) -> dict:
547
+ """Build list of groups from gallery directory."""
548
+ groups = []
549
+ try:
550
+ for gname in sorted(os.listdir(root_dir)):
551
+ gpath = os.path.join(root_dir, gname)
552
+ if not os.path.isdir(gpath):
553
+ continue
554
+ has_scene = False
555
+ try:
556
+ for sname in os.listdir(gpath):
557
+ spath = os.path.join(gpath, sname)
558
+ if not os.path.isdir(spath):
559
+ continue
560
+ if os.path.exists(os.path.join(spath, "scene.glb")) and os.path.exists(
561
+ os.path.join(spath, "scene.jpg")
562
+ ):
563
+ has_scene = True
564
+ break
565
+ except Exception:
566
+ pass
567
+ if has_scene:
568
+ groups.append({"id": gname, "title": gname})
569
+ except Exception as e:
570
+ print(f"[warn] build_group_list failed: {e}")
571
+ return {"groups": groups}
572
+
573
+
574
+ def build_group_manifest(root_dir: str, group: str) -> dict:
575
+ """Build manifest for a specific group."""
576
+ items = []
577
+ gpath = os.path.join(root_dir, group)
578
+ try:
579
+ if not os.path.isdir(gpath):
580
+ return {"group": group, "items": []}
581
+ for sname in sorted(os.listdir(gpath)):
582
+ spath = os.path.join(gpath, sname)
583
+ if not os.path.isdir(spath):
584
+ continue
585
+ glb_fs = os.path.join(spath, "scene.glb")
586
+ jpg_fs = os.path.join(spath, "scene.jpg")
587
+ if not (os.path.exists(glb_fs) and os.path.exists(jpg_fs)):
588
+ continue
589
+ depth_images = []
590
+ dpath = os.path.join(spath, "depth_vis")
591
+ if os.path.isdir(dpath):
592
+ files = [
593
+ f
594
+ for f in os.listdir(dpath)
595
+ if os.path.splitext(f)[1].lower() in GALLERY_IMAGE_EXTS
596
+ ]
597
+ for fn in sorted(files):
598
+ depth_images.append(
599
+ "/gallery/" + _gallery_url_join(group, sname, "depth_vis", fn)
600
+ )
601
+ items.append(
602
+ {
603
+ "id": sname,
604
+ "title": sname,
605
+ "model": "/gallery/" + _gallery_url_join(group, sname, "scene.glb"),
606
+ "thumbnail": "/gallery/" + _gallery_url_join(group, sname, "scene.jpg"),
607
+ "depth_images": depth_images,
608
+ }
609
+ )
610
+ except Exception as e:
611
+ print(f"[warn] build_group_manifest failed for {group}: {e}")
612
+ return {"group": group, "items": items}
613
+
614
+
615
+ # ============================================================================
616
+ # Inference request validation
617
+ # ============================================================================
618
+
619
+ # export_dir is expected to be a simple relative path; anything else is rejected below.
620
+ _EXPORT_DIR_METACHAR_RE = re.compile(r"[;&|`$\n\r<>\x00]")
621
+
622
+ # Hosts considered "local only" for the purposes of deciding whether an unauthenticated
623
+ # backend is safe to start.
624
+ _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"}
625
+
626
+
627
+ def validate_export_dir(export_dir: Optional[str], gallery_dir: Optional[str]) -> Optional[str]:
628
+ """Validate a client-supplied export_dir before it reaches any inference/export code.
629
+
630
+ export_dir must be a simple relative path that resolves within the server's
631
+ configured gallery directory.
632
+
633
+ Raises:
634
+ ValueError: with a human-readable reason, if export_dir is rejected.
635
+ """
636
+ if not export_dir:
637
+ return export_dir
638
+
639
+ if _EXPORT_DIR_METACHAR_RE.search(export_dir):
640
+ raise ValueError("export_dir contains disallowed characters")
641
+
642
+ if os.path.isabs(export_dir) or export_dir.startswith("~"):
643
+ raise ValueError("export_dir must be a relative path")
644
+
645
+ if any(part == ".." for part in export_dir.replace("\\", "/").split("/")):
646
+ raise ValueError("export_dir must not contain '..' path traversal")
647
+
648
+ if not gallery_dir:
649
+ raise ValueError(
650
+ "export_dir is not permitted: no gallery directory is configured on this server"
651
+ )
652
+
653
+ gallery_root = os.path.realpath(gallery_dir)
654
+ resolved = os.path.realpath(export_dir)
655
+ if resolved != gallery_root and not resolved.startswith(gallery_root + os.sep):
656
+ raise ValueError(
657
+ "export_dir must resolve within the server's configured gallery directory"
658
+ )
659
+
660
+ return export_dir
661
+
662
+
663
+ def create_app(
664
+ model_dir: str,
665
+ device: str = "cuda",
666
+ gallery_dir: Optional[str] = None,
667
+ api_key: Optional[str] = None,
668
+ ) -> FastAPI:
669
+ """Create FastAPI application with model backend.
670
+
671
+ Note: the non-loopback-without-authentication guard lives in start_server(), not
672
+ here. Callers that take this app and serve it themselves (e.g. via a custom
673
+ uvicorn.run() or by mounting it into a larger ASGI app) are responsible for their
674
+ own host-binding and authentication decisions.
675
+ """
676
+ global _backend, _app, _gallery_dir
677
+
678
+ _backend = ModelBackend(model_dir, device)
679
+ _app = FastAPI(
680
+ title="Depth Anything 3 Backend",
681
+ description="Model inference service for Depth Anything 3",
682
+ version="1.0.0",
683
+ )
684
+
685
+ # Module-level (not just closure-local) so background tasks can re-check it too.
686
+ _gallery_dir = gallery_dir
687
+
688
+ # Optional shared-secret gate for task-submitting endpoints. Falls back to the
689
+ # DA3_BACKEND_API_KEY env var so the key never needs to appear in a command line.
690
+ _api_key = api_key or os.environ.get("DA3_BACKEND_API_KEY")
691
+
692
+ async def _require_api_key(x_api_key: Optional[str] = Header(default=None, alias="X-API-Key")):
693
+ """FastAPI dependency enforcing the API key when one is configured."""
694
+ if _api_key and not hmac.compare_digest(x_api_key or "", _api_key):
695
+ raise HTTPException(status_code=401, detail="Missing or invalid X-API-Key header")
696
+
697
+ @_app.get("/", response_class=HTMLResponse)
698
+ async def root():
699
+ """Home page with navigation to dashboard and gallery."""
700
+ html_content = (
701
+ """
702
+ <!DOCTYPE html>
703
+ <html lang="en">
704
+ <head>
705
+ <meta charset="UTF-8">
706
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
707
+ <title>Depth Anything 3 Backend</title>
708
+ <style>
709
+ :root {
710
+ --tech-blue: #00d4ff;
711
+ --tech-cyan: #00ffcc;
712
+ --tech-purple: #7877c6;
713
+ }
714
+
715
+ * {
716
+ box-sizing: border-box;
717
+ }
718
+
719
+ /* Dark mode styles */
720
+ @media (prefers-color-scheme: dark) {
721
+ body {
722
+ margin: 0;
723
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
724
+ background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
725
+ color: #e8eaed;
726
+ min-height: 100vh;
727
+ display: flex;
728
+ align-items: center;
729
+ justify-content: center;
730
+ position: relative;
731
+ overflow-x: hidden;
732
+ }
733
+
734
+ body::before {
735
+ content: '';
736
+ position: fixed;
737
+ top: 0;
738
+ left: 0;
739
+ right: 0;
740
+ bottom: 0;
741
+ background:
742
+ radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
743
+ radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
744
+ radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%);
745
+ animation: techPulse 8s ease-in-out infinite;
746
+ z-index: -1;
747
+ }
748
+
749
+ .container {
750
+ max-width: 800px;
751
+ padding: 40px;
752
+ text-align: center;
753
+ z-index: 1;
754
+ }
755
+
756
+ h1 {
757
+ font-size: 3em;
758
+ margin: 0 0 20px 0;
759
+ background: linear-gradient(45deg, var(--tech-blue), var(--tech-cyan), var(--tech-purple));
760
+ background-size: 400% 400%;
761
+ -webkit-background-clip: text;
762
+ background-clip: text;
763
+ color: transparent;
764
+ animation: techGradient 3s ease infinite;
765
+ text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
766
+ }
767
+
768
+ .subtitle {
769
+ font-size: 1.2em;
770
+ opacity: 0.8;
771
+ margin-bottom: 50px;
772
+ color: #a0a0a0;
773
+ }
774
+
775
+ .nav-grid {
776
+ display: grid;
777
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
778
+ gap: 24px;
779
+ margin-top: 40px;
780
+ }
781
+
782
+ .nav-card {
783
+ background: rgba(0, 0, 0, 0.3);
784
+ border: 1px solid rgba(0, 212, 255, 0.2);
785
+ border-radius: 16px;
786
+ padding: 30px;
787
+ text-decoration: none;
788
+ color: inherit;
789
+ transition: all 0.3s ease;
790
+ backdrop-filter: blur(10px);
791
+ }
792
+
793
+ .nav-card:hover {
794
+ transform: translateY(-4px);
795
+ border-color: var(--tech-blue);
796
+ box-shadow: 0 8px 25px rgba(0, 212, 255, 0.2);
797
+ }
798
+
799
+ .nav-card h2 {
800
+ margin: 0 0 15px 0;
801
+ font-size: 1.8em;
802
+ color: var(--tech-blue);
803
+ }
804
+
805
+ .nav-card p {
806
+ margin: 0;
807
+ opacity: 0.8;
808
+ line-height: 1.6;
809
+ }
810
+ }
811
+
812
+ /* Light mode styles */
813
+ @media (prefers-color-scheme: light) {
814
+ body {
815
+ margin: 0;
816
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
817
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
818
+ color: #1e293b;
819
+ min-height: 100vh;
820
+ display: flex;
821
+ align-items: center;
822
+ justify-content: center;
823
+ position: relative;
824
+ overflow-x: hidden;
825
+ }
826
+
827
+ body::before {
828
+ content: '';
829
+ position: fixed;
830
+ top: 0;
831
+ left: 0;
832
+ right: 0;
833
+ bottom: 0;
834
+ background:
835
+ radial-gradient(circle at 20% 80%, rgba(0, 212, 255, 0.1) 0%, transparent 50%),
836
+ radial-gradient(circle at 80% 20%, rgba(0, 102, 255, 0.1) 0%, transparent 50%),
837
+ radial-gradient(circle at 40% 40%, rgba(0, 255, 204, 0.08) 0%, transparent 50%);
838
+ animation: techPulse 8s ease-in-out infinite;
839
+ z-index: -1;
840
+ }
841
+
842
+ .container {
843
+ max-width: 800px;
844
+ padding: 40px;
845
+ text-align: center;
846
+ z-index: 1;
847
+ }
848
+
849
+ h1 {
850
+ font-size: 3em;
851
+ margin: 0 0 20px 0;
852
+ background: linear-gradient(45deg, #0066ff, #00d4ff, #00ffcc);
853
+ background-size: 400% 400%;
854
+ -webkit-background-clip: text;
855
+ background-clip: text;
856
+ color: transparent;
857
+ animation: techGradient 3s ease infinite;
858
+ text-shadow: 0 0 20px rgba(0, 102, 255, 0.3);
859
+ }
860
+
861
+ .subtitle {
862
+ font-size: 1.2em;
863
+ opacity: 0.8;
864
+ margin-bottom: 50px;
865
+ color: #64748b;
866
+ }
867
+
868
+ .nav-grid {
869
+ display: grid;
870
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
871
+ gap: 24px;
872
+ margin-top: 40px;
873
+ }
874
+
875
+ .nav-card {
876
+ background: rgba(255, 255, 255, 0.8);
877
+ border: 1px solid rgba(0, 212, 255, 0.3);
878
+ border-radius: 16px;
879
+ padding: 30px;
880
+ text-decoration: none;
881
+ color: inherit;
882
+ transition: all 0.3s ease;
883
+ backdrop-filter: blur(10px);
884
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
885
+ }
886
+
887
+ .nav-card:hover {
888
+ transform: translateY(-4px);
889
+ border-color: #0066ff;
890
+ box-shadow: 0 8px 25px rgba(0, 102, 255, 0.2);
891
+ }
892
+
893
+ .nav-card h2 {
894
+ margin: 0 0 15px 0;
895
+ font-size: 1.8em;
896
+ color: #0066ff;
897
+ }
898
+
899
+ .nav-card p {
900
+ margin: 0;
901
+ opacity: 0.8;
902
+ line-height: 1.6;
903
+ }
904
+ }
905
+
906
+ @keyframes techPulse {
907
+ 0%, 100% { opacity: 0.5; }
908
+ 50% { opacity: 0.8; }
909
+ }
910
+
911
+ @keyframes techGradient {
912
+ 0% { background-position: 0% 50%; }
913
+ 50% { background-position: 100% 50%; }
914
+ 100% { background-position: 0% 50%; }
915
+ }
916
+
917
+ .footer {
918
+ margin-top: 50px;
919
+ opacity: 0.6;
920
+ font-size: 0.9em;
921
+ }
922
+ </style>
923
+ </head>
924
+ <body>
925
+ <div class="container">
926
+ <h1>Depth Anything 3</h1>
927
+ <p class="subtitle">Model Backend Service</p>
928
+ <div class="nav-grid">
929
+ <a href="/dashboard" class="nav-card">
930
+ <h2>📊 Dashboard</h2>
931
+ <p>Monitor backend status, model information, and inference tasks in real-time.</p>
932
+ </a>
933
+ """
934
+ + (
935
+ '<a href="/gallery/" class="nav-card">'
936
+ "<h2>🎨 Gallery</h2>"
937
+ "<p>Browse 3D reconstructions and depth visualizations from processed scenes.</p>"
938
+ "</a>"
939
+ if _gallery_dir and os.path.exists(_gallery_dir)
940
+ else ""
941
+ )
942
+ + """
943
+ </div>
944
+ <div class="footer">
945
+ <p>Depth Anything 3 Backend API</p>
946
+ </div>
947
+ </div>
948
+ </body>
949
+ </html>
950
+ """
951
+ )
952
+ return HTMLResponse(html_content)
953
+
954
+ @_app.get("/dashboard", response_class=HTMLResponse)
955
+ async def dashboard():
956
+ """HTML dashboard for monitoring backend status and tasks."""
957
+ if _backend is None:
958
+ return HTMLResponse("<h1>Backend not initialized</h1>", status_code=500)
959
+
960
+ # Get backend status
961
+ status = _backend.get_status()
962
+
963
+ # Safely format status values
964
+ if status["load_time"] is not None:
965
+ load_time_str = f"{status['load_time']:.2f}s"
966
+ else:
967
+ load_time_str = "Not loaded"
968
+
969
+ if status["uptime"] is not None:
970
+ uptime_str = f"{status['uptime']:.2f}s"
971
+ else:
972
+ uptime_str = "Not running"
973
+
974
+ # Get tasks information
975
+ active_tasks = [task for task in _tasks.values() if task.status in ["pending", "running"]]
976
+ completed_tasks = [
977
+ task for task in _tasks.values() if task.status in ["completed", "failed"]
978
+ ]
979
+
980
+ # Generate task HTML
981
+ if active_tasks:
982
+ active_tasks_html = "".join(
983
+ _render_task_card(task, "running") for task in active_tasks
984
+ )
985
+ else:
986
+ active_tasks_html = "<p>No active tasks</p>"
987
+
988
+ if completed_tasks:
989
+ completed_tasks_html = "".join(
990
+ _render_task_card(task, "completed") for task in completed_tasks[-10:]
991
+ )
992
+ else:
993
+ completed_tasks_html = "<p>No completed tasks</p>"
994
+
995
+ # Generate HTML
996
+ html_content = f"""
997
+ <!DOCTYPE html>
998
+ <html lang="en">
999
+ <head>
1000
+ <meta charset="UTF-8">
1001
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1002
+ <title>Depth Anything 3 Backend Dashboard</title>
1003
+ <style>
1004
+ body {{
1005
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1006
+ margin: 0;
1007
+ padding: 20px;
1008
+ background-color: #f5f5f5;
1009
+ }}
1010
+ .container {{
1011
+ max-width: 1200px;
1012
+ margin: 0 auto;
1013
+ }}
1014
+ .header {{
1015
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1016
+ color: white;
1017
+ padding: 20px;
1018
+ border-radius: 10px;
1019
+ margin-bottom: 20px;
1020
+ text-align: center;
1021
+ }}
1022
+ .status-grid {{
1023
+ display: grid;
1024
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
1025
+ gap: 20px;
1026
+ margin-bottom: 30px;
1027
+ }}
1028
+ .status-card {{
1029
+ background: white;
1030
+ padding: 20px;
1031
+ border-radius: 10px;
1032
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1033
+ }}
1034
+ .status-card h3 {{
1035
+ margin-top: 0;
1036
+ color: #333;
1037
+ }}
1038
+ .status-item {{
1039
+ display: flex;
1040
+ justify-content: space-between;
1041
+ margin: 10px 0;
1042
+ padding: 8px 0;
1043
+ border-bottom: 1px solid #eee;
1044
+ }}
1045
+ .status-item:last-child {{
1046
+ border-bottom: none;
1047
+ }}
1048
+ .status-value {{
1049
+ font-weight: bold;
1050
+ color: #666;
1051
+ }}
1052
+ .status-online {{
1053
+ color: #28a745;
1054
+ }}
1055
+ .status-offline {{
1056
+ color: #dc3545;
1057
+ }}
1058
+ .tasks-section {{
1059
+ background: white;
1060
+ padding: 20px;
1061
+ border-radius: 10px;
1062
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1063
+ margin-bottom: 20px;
1064
+ }}
1065
+ .task-item {{
1066
+ background: #f8f9fa;
1067
+ padding: 15px;
1068
+ margin: 10px 0;
1069
+ border-radius: 8px;
1070
+ border-left: 4px solid #007bff;
1071
+ }}
1072
+ .task-item.completed {{
1073
+ border-left-color: #28a745;
1074
+ }}
1075
+ .task-item.failed {{
1076
+ border-left-color: #dc3545;
1077
+ }}
1078
+ .task-item.running {{
1079
+ border-left-color: #ffc107;
1080
+ }}
1081
+ .task-header {{
1082
+ display: flex;
1083
+ justify-content: space-between;
1084
+ align-items: center;
1085
+ margin-bottom: 8px;
1086
+ }}
1087
+ .task-id {{
1088
+ font-family: monospace;
1089
+ font-size: 12px;
1090
+ color: #666;
1091
+ }}
1092
+ .task-status {{
1093
+ padding: 4px 8px;
1094
+ border-radius: 4px;
1095
+ font-size: 12px;
1096
+ font-weight: bold;
1097
+ }}
1098
+ .status-pending {{
1099
+ background: #fff3cd;
1100
+ color: #856404;
1101
+ }}
1102
+ .status-running {{
1103
+ background: #d4edda;
1104
+ color: #155724;
1105
+ }}
1106
+ .status-completed {{
1107
+ background: #d1ecf1;
1108
+ color: #0c5460;
1109
+ }}
1110
+ .status-failed {{
1111
+ background: #f8d7da;
1112
+ color: #721c24;
1113
+ }}
1114
+ .refresh-btn {{
1115
+ background: #007bff;
1116
+ color: white;
1117
+ border: none;
1118
+ padding: 10px 20px;
1119
+ border-radius: 5px;
1120
+ cursor: pointer;
1121
+ font-size: 14px;
1122
+ }}
1123
+ .refresh-btn:hover {{
1124
+ background: #0056b3;
1125
+ }}
1126
+ .auto-refresh {{
1127
+ margin-left: 10px;
1128
+ }}
1129
+ .timestamp {{
1130
+ font-size: 12px;
1131
+ color: #666;
1132
+ margin-top: 10px;
1133
+ }}
1134
+ .task-message {{
1135
+ font-size: 14px;
1136
+ color: #333;
1137
+ margin-bottom: 8px;
1138
+ }}
1139
+ .task-params {{
1140
+ font-size: 12px;
1141
+ color: #666;
1142
+ background: #f8f9fa;
1143
+ padding: 6px 8px;
1144
+ border-radius: 4px;
1145
+ margin-top: 8px;
1146
+ }}
1147
+ </style>
1148
+ </head>
1149
+ <body>
1150
+ <div class="container">
1151
+ <div class="header">
1152
+ <h1>Depth Anything 3 Backend Dashboard</h1>
1153
+ <p>Real-time monitoring of model status and inference tasks</p>
1154
+ </div>
1155
+
1156
+ <div class="status-grid">
1157
+ <div class="status-card">
1158
+ <h3>Model Status</h3>
1159
+ <div class="status-item">
1160
+ <span>Status:</span>
1161
+ <span class="status-value {'status-online' if status['model_loaded'] else 'status-offline'}">
1162
+ {'Online' if status['model_loaded'] else 'Offline'}
1163
+ </span>
1164
+ </div>
1165
+ <div class="status-item">
1166
+ <span>Model Directory:</span>
1167
+ <span class="status-value">{html.escape(str(status['model_dir']))}</span>
1168
+ </div>
1169
+ <div class="status-item">
1170
+ <span>Device:</span>
1171
+ <span class="status-value">{html.escape(str(status['device']))}</span>
1172
+ </div>
1173
+ <div class="status-item">
1174
+ <span>Load Time:</span>
1175
+ <span class="status-value">{load_time_str}</span>
1176
+ </div>
1177
+ <div class="status-item">
1178
+ <span>Uptime:</span>
1179
+ <span class="status-value">{uptime_str}</span>
1180
+ </div>
1181
+ </div>
1182
+
1183
+ <div class="status-card">
1184
+ <h3>Task Summary</h3>
1185
+ <div class="status-item">
1186
+ <span>Active Tasks:</span>
1187
+ <span class="status-value">{len(active_tasks)}</span>
1188
+ </div>
1189
+ <div class="status-item">
1190
+ <span>Completed Tasks:</span>
1191
+ <span class="status-value">{len(completed_tasks)}</span>
1192
+ </div>
1193
+ <div class="status-item">
1194
+ <span>Total Tasks:</span>
1195
+ <span class="status-value">{len(_tasks)}</span>
1196
+ </div>
1197
+ </div>
1198
+ </div>
1199
+
1200
+ <div class="tasks-section">
1201
+ <h3>Active Tasks</h3>
1202
+ <button class="refresh-btn" onclick="location.reload()">Refresh</button>
1203
+ <label class="auto-refresh">
1204
+ <input type="checkbox" id="autoRefresh" onchange="toggleAutoRefresh()"> Auto-refresh (5s)
1205
+ </label>
1206
+ <div class="timestamp">Last updated: <span id="lastUpdate">{time.strftime('%Y-%m-%d %H:%M:%S')}</span></div>
1207
+
1208
+ {active_tasks_html}
1209
+ </div>
1210
+
1211
+ <div class="tasks-section">
1212
+ <h3>Recent Completed Tasks</h3>
1213
+ {completed_tasks_html}
1214
+ </div>
1215
+ </div>
1216
+
1217
+ <script>
1218
+ let autoRefreshInterval;
1219
+
1220
+ function toggleAutoRefresh() {{
1221
+ const checkbox = document.getElementById('autoRefresh');
1222
+ if (checkbox.checked) {{
1223
+ autoRefreshInterval = setInterval(() => {{
1224
+ location.reload();
1225
+ }}, 5000);
1226
+ }} else {{
1227
+ clearInterval(autoRefreshInterval);
1228
+ }}
1229
+ }}
1230
+
1231
+ // Update timestamp every second
1232
+ setInterval(() => {{
1233
+ const now = new Date();
1234
+ document.getElementById('lastUpdate').textContent = now.toLocaleString();
1235
+ }}, 1000);
1236
+ </script>
1237
+ </body>
1238
+ </html>
1239
+ """
1240
+
1241
+ return HTMLResponse(html_content)
1242
+
1243
+ @_app.get("/status")
1244
+ async def get_status():
1245
+ """Get backend status with GPU memory information."""
1246
+ if _backend is None:
1247
+ raise HTTPException(status_code=500, detail="Backend not initialized")
1248
+
1249
+ status = _backend.get_status()
1250
+
1251
+ # Add GPU memory information
1252
+ gpu_memory = get_gpu_memory_info()
1253
+ if gpu_memory:
1254
+ status["gpu_memory"] = {
1255
+ "total_gb": round(gpu_memory["total_gb"], 2),
1256
+ "allocated_gb": round(gpu_memory["allocated_gb"], 2),
1257
+ "reserved_gb": round(gpu_memory["reserved_gb"], 2),
1258
+ "free_gb": round(gpu_memory["free_gb"], 2),
1259
+ "utilization_percent": round(gpu_memory["utilization"], 1),
1260
+ }
1261
+ else:
1262
+ status["gpu_memory"] = None
1263
+
1264
+ return status
1265
+
1266
+ @_app.post(
1267
+ "/inference", response_model=InferenceResponse, dependencies=[Depends(_require_api_key)]
1268
+ )
1269
+ async def run_inference(request: InferenceRequest):
1270
+ """Submit inference task and return task ID."""
1271
+ global _running_task_id
1272
+
1273
+ if _backend is None:
1274
+ raise HTTPException(status_code=500, detail="Backend not initialized")
1275
+
1276
+ if request.export_dir:
1277
+ try:
1278
+ validate_export_dir(request.export_dir, _gallery_dir)
1279
+ except ValueError as e:
1280
+ raise HTTPException(status_code=400, detail=str(e))
1281
+
1282
+ # Generate unique task ID
1283
+ task_id = str(uuid.uuid4())
1284
+
1285
+ # Create task status
1286
+ if _running_task_id is not None:
1287
+ status_msg = f"[{task_id}] Task queued (waiting for {_running_task_id} to complete)"
1288
+ else:
1289
+ status_msg = f"[{task_id}] Task submitted"
1290
+
1291
+ _tasks[task_id] = TaskStatus(
1292
+ task_id=task_id,
1293
+ status="pending",
1294
+ message=status_msg,
1295
+ created_at=time.time(),
1296
+ export_dir=request.export_dir,
1297
+ request=request,
1298
+ # Record essential parameters
1299
+ num_images=len(request.image_paths),
1300
+ export_format=request.export_format,
1301
+ process_res_method=request.process_res_method,
1302
+ video_path=(
1303
+ request.image_paths[0] if request.image_paths else None
1304
+ ), # Use first image path as video reference
1305
+ )
1306
+
1307
+ # Add task to queue
1308
+ _task_queue.append(task_id)
1309
+
1310
+ # If no task is running, start processing the queue
1311
+ if _running_task_id is None:
1312
+ _process_next_task()
1313
+
1314
+ return InferenceResponse(
1315
+ success=True,
1316
+ message="Task submitted successfully",
1317
+ task_id=task_id,
1318
+ export_dir=request.export_dir,
1319
+ export_format=request.export_format,
1320
+ )
1321
+
1322
+ @_app.get("/task/{task_id}", response_model=TaskStatus)
1323
+ async def get_task_status(task_id: str):
1324
+ """Get task status by task ID."""
1325
+ if task_id not in _tasks:
1326
+ raise HTTPException(status_code=404, detail="Task not found")
1327
+
1328
+ return _tasks[task_id]
1329
+
1330
+ @_app.get("/gpu-memory")
1331
+ async def get_gpu_memory():
1332
+ """Get detailed GPU memory information."""
1333
+ gpu_memory = get_gpu_memory_info()
1334
+ if gpu_memory is None:
1335
+ return {
1336
+ "available": False,
1337
+ "message": "CUDA not available or memory info cannot be retrieved",
1338
+ }
1339
+
1340
+ return {
1341
+ "available": True,
1342
+ "total_gb": round(gpu_memory["total_gb"], 2),
1343
+ "allocated_gb": round(gpu_memory["allocated_gb"], 2),
1344
+ "reserved_gb": round(gpu_memory["reserved_gb"], 2),
1345
+ "free_gb": round(gpu_memory["free_gb"], 2),
1346
+ "utilization_percent": round(gpu_memory["utilization"], 1),
1347
+ "status": (
1348
+ "healthy"
1349
+ if gpu_memory["utilization"] < 80
1350
+ else "warning" if gpu_memory["utilization"] < 95 else "critical"
1351
+ ),
1352
+ }
1353
+
1354
+ @_app.get("/tasks")
1355
+ async def list_tasks():
1356
+ """List all tasks."""
1357
+ # Separate active and completed tasks
1358
+ active_tasks = [task for task in _tasks.values() if task.status in ["pending", "running"]]
1359
+ completed_tasks = [
1360
+ task for task in _tasks.values() if task.status in ["completed", "failed"]
1361
+ ]
1362
+
1363
+ return {
1364
+ "tasks": list(_tasks.values()),
1365
+ "active_tasks": active_tasks,
1366
+ "completed_tasks": completed_tasks,
1367
+ "active_count": len(active_tasks),
1368
+ "total_count": len(_tasks),
1369
+ }
1370
+
1371
+ @_app.post("/cleanup")
1372
+ async def manual_cleanup():
1373
+ """Manually trigger task cleanup."""
1374
+ try:
1375
+ _cleanup_old_tasks()
1376
+ return {"message": "Cleanup completed", "active_tasks": len(_tasks)}
1377
+ except Exception as e:
1378
+ raise HTTPException(status_code=500, detail=f"Cleanup failed: {str(e)}")
1379
+
1380
+ @_app.delete("/task/{task_id}")
1381
+ async def delete_task(task_id: str):
1382
+ """Delete a specific task."""
1383
+ if task_id not in _tasks:
1384
+ raise HTTPException(status_code=404, detail="Task not found")
1385
+
1386
+ # Only allow deletion of completed/failed tasks
1387
+ if _tasks[task_id].status not in ["completed", "failed"]:
1388
+ raise HTTPException(status_code=400, detail="Cannot delete running or pending tasks")
1389
+
1390
+ del _tasks[task_id]
1391
+ return {"message": f"Task {task_id} deleted successfully"}
1392
+
1393
+ @_app.post("/reload")
1394
+ async def reload_model():
1395
+ """Reload the model."""
1396
+ if _backend is None:
1397
+ raise HTTPException(status_code=500, detail="Backend not initialized")
1398
+
1399
+ try:
1400
+ _backend.model = None
1401
+ _backend.model_loaded = False
1402
+ _backend.load_model()
1403
+ return {"message": "Model reloaded successfully"}
1404
+ except Exception as e:
1405
+ raise HTTPException(status_code=500, detail=f"Failed to reload model: {str(e)}")
1406
+
1407
+ # ============================================================================
1408
+ # Gallery routes
1409
+ # ============================================================================
1410
+
1411
+ if _gallery_dir and os.path.exists(_gallery_dir):
1412
+ # Load gallery HTML page (with modified paths for /gallery/ subdirectory)
1413
+ _gallery_html = _load_gallery_html()
1414
+
1415
+ @_app.get("/gallery/", response_class=HTMLResponse)
1416
+ @_app.get("/gallery", response_class=HTMLResponse)
1417
+ async def gallery_home():
1418
+ """Gallery home page."""
1419
+ return HTMLResponse(_gallery_html)
1420
+
1421
+ @_app.get("/gallery/manifest.json")
1422
+ async def gallery_manifest():
1423
+ """Get gallery group list."""
1424
+ try:
1425
+ return build_group_list(_gallery_dir)
1426
+ except Exception as e:
1427
+ raise HTTPException(
1428
+ status_code=500, detail=f"Failed to build group list: {str(e)}"
1429
+ )
1430
+
1431
+ @_app.get("/gallery/manifest/{group}.json")
1432
+ async def gallery_group_manifest(group: str):
1433
+ """Get manifest for a specific group."""
1434
+ if not _is_plain_name(group):
1435
+ raise HTTPException(status_code=400, detail="Invalid group name")
1436
+ try:
1437
+ return build_group_manifest(_gallery_dir, group)
1438
+ except Exception as e:
1439
+ raise HTTPException(
1440
+ status_code=500, detail=f"Failed to build group manifest: {str(e)}"
1441
+ )
1442
+
1443
+ @_app.get("/gallery/{path:path}")
1444
+ async def gallery_files(path: str):
1445
+ """Serve gallery static files (GLB, JPG, etc.)."""
1446
+ # Security check: prevent directory traversal
1447
+ path_parts = path.split("/")
1448
+ if any(not _is_plain_name(part) for part in path_parts if part):
1449
+ raise HTTPException(status_code=400, detail="Invalid path")
1450
+
1451
+ file_path = os.path.join(_gallery_dir, *path_parts)
1452
+
1453
+ # Ensure the file is within gallery directory
1454
+ real_file_path = os.path.realpath(file_path)
1455
+ real_gallery_dir = os.path.realpath(_gallery_dir)
1456
+ if not real_file_path.startswith(real_gallery_dir):
1457
+ raise HTTPException(status_code=403, detail="Access denied")
1458
+
1459
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
1460
+ raise HTTPException(status_code=404, detail="File not found")
1461
+
1462
+ return FileResponse(file_path)
1463
+
1464
+ return _app
1465
+
1466
+
1467
+ def start_server(
1468
+ model_dir: str,
1469
+ device: str = "cuda",
1470
+ host: str = "127.0.0.1",
1471
+ port: int = 8000,
1472
+ gallery_dir: Optional[str] = None,
1473
+ api_key: Optional[str] = None,
1474
+ allow_unauthenticated: bool = False,
1475
+ ):
1476
+ """Start the backend server."""
1477
+ api_key = api_key or os.environ.get("DA3_BACKEND_API_KEY")
1478
+ auto_generated_key = False
1479
+
1480
+ if host not in _LOOPBACK_HOSTS and not api_key and not allow_unauthenticated:
1481
+ # No key was configured for a host reachable beyond this machine: generate one
1482
+ # for this run rather than requiring the operator to come up with one first.
1483
+ api_key = secrets.token_urlsafe(32)
1484
+ auto_generated_key = True
1485
+
1486
+ app = create_app(model_dir, device, gallery_dir, api_key=api_key)
1487
+
1488
+ print("Starting Depth Anything 3 Backend...")
1489
+ print(f"Model directory: {model_dir}")
1490
+ print(f"Device: {device}")
1491
+ print(f"Server: http://{host}:{port}")
1492
+ print(f"Dashboard: http://{host}:{port}/dashboard")
1493
+ print(f"API Status: http://{host}:{port}/status")
1494
+
1495
+ if gallery_dir and os.path.exists(gallery_dir):
1496
+ print(f"Gallery: http://{host}:{port}/gallery/")
1497
+
1498
+ if auto_generated_key:
1499
+ print("=" * 60)
1500
+ print("No --api-key was set, so one was generated for this run:")
1501
+ print(f" X-API-Key: {api_key}")
1502
+ print("Set DA3_BACKEND_API_KEY to this value wherever you submit jobs from")
1503
+ print("(e.g. `da3 ... --use-backend`), or pass --api-key / set")
1504
+ print("DA3_BACKEND_API_KEY before starting the backend for a key that stays")
1505
+ print("the same across restarts. Use --allow-unauthenticated to skip this.")
1506
+ elif api_key:
1507
+ print("Authentication: X-API-Key header required for /inference")
1508
+
1509
+ print("=" * 60)
1510
+ print("Backend is running! You can now:")
1511
+ print(f" • Open home page: http://{host}:{port}")
1512
+ print(f" • Open dashboard: http://{host}:{port}/dashboard")
1513
+ print(f" • Check API status: http://{host}:{port}/status")
1514
+
1515
+ if gallery_dir and os.path.exists(gallery_dir):
1516
+ print(f" • Browse gallery: http://{host}:{port}/gallery/")
1517
+
1518
+ print(" • Submit inference tasks via API")
1519
+ print("=" * 60)
1520
+
1521
+ uvicorn.run(app, host=host, port=port, log_level="info")
1522
+
1523
+
1524
+ if __name__ == "__main__":
1525
+ import argparse
1526
+
1527
+ parser = argparse.ArgumentParser(description="Depth Anything 3 Backend Server")
1528
+ parser.add_argument("--model-dir", required=True, help="Model directory path")
1529
+ parser.add_argument("--device", default="cuda", help="Device to use")
1530
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
1531
+ parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
1532
+ parser.add_argument("--gallery-dir", help="Gallery directory path (optional)")
1533
+ parser.add_argument(
1534
+ "--api-key",
1535
+ default=None,
1536
+ help="Require this API key (X-API-Key header) on inference requests. Falls back "
1537
+ "to the DA3_BACKEND_API_KEY env var, or an auto-generated key when binding to a "
1538
+ "non-loopback host.",
1539
+ )
1540
+ parser.add_argument(
1541
+ "--allow-unauthenticated",
1542
+ action="store_true",
1543
+ help="Skip authentication entirely on a non-loopback host, instead of using an "
1544
+ "auto-generated API key.",
1545
+ )
1546
+
1547
+ args = parser.parse_args()
1548
+ start_server(
1549
+ args.model_dir,
1550
+ args.device,
1551
+ args.host,
1552
+ args.port,
1553
+ args.gallery_dir,
1554
+ api_key=args.api_key,
1555
+ allow_unauthenticated=args.allow_unauthenticated,
1556
+ )
multivue/cameras/source/src/depth_anything_3/services/gallery.py ADDED
@@ -0,0 +1,806 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # flake8: noqa: E501
3
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ """
18
+ Depth Anything 3 Gallery Server (two-level, single-file)
19
+ Now supports paginated depth preview (4 per page).
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import mimetypes
25
+ import os
26
+ import posixpath
27
+ import sys
28
+ from functools import partial
29
+ from http import HTTPStatus
30
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
31
+ from urllib.parse import quote, unquote
32
+
33
+ # ------------------------------ Embedded HTML ------------------------------ #
34
+
35
+ HTML_PAGE = r"""<!doctype html>
36
+ <html lang="en">
37
+ <head>
38
+ <meta charset="utf-8" />
39
+ <title>Depth Anything 3 Gallery</title>
40
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
41
+ <link rel="icon" href="https://i.postimg.cc/rFSzGJ7J/light-icon.jpg" media="(prefers-color-scheme: light)">
42
+ <link rel="icon" href="https://i.postimg.cc/P5gZfJsf/dark-icon.jpg" media="(prefers-color-scheme: dark)">
43
+ <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
44
+ <style>
45
+ :root {
46
+ --gap:16px; --card-radius:16px; --shadow:0 8px 24px rgba(0,0,0,.12);
47
+ --maxW:1036px; --maxH:518px;
48
+ --tech-blue: #00d4ff;
49
+ --tech-cyan: #00ffcc;
50
+ --tech-purple: #7877c6;
51
+ }
52
+
53
+ *{ box-sizing:border-box }
54
+
55
+ /* Dark mode tech theme */
56
+ @media (prefers-color-scheme: dark) {
57
+ body{
58
+ margin:0; font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
59
+ background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
60
+ color:#e8eaed;
61
+ position: relative;
62
+ overflow-x: hidden;
63
+ }
64
+
65
+ body::before {
66
+ content: '';
67
+ position: fixed;
68
+ top: 0;
69
+ left: 0;
70
+ right: 0;
71
+ bottom: 0;
72
+ background:
73
+ radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
74
+ radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
75
+ radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%);
76
+ animation: techPulse 8s ease-in-out infinite;
77
+ z-index: -1;
78
+ }
79
+ }
80
+
81
+ /* Light mode tech theme */
82
+ @media (prefers-color-scheme: light) {
83
+ body{
84
+ margin:0; font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
85
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
86
+ color:#1e293b;
87
+ position: relative;
88
+ overflow-x: hidden;
89
+ }
90
+
91
+ body::before {
92
+ content: '';
93
+ position: fixed;
94
+ top: 0;
95
+ left: 0;
96
+ right: 0;
97
+ bottom: 0;
98
+ background:
99
+ radial-gradient(circle at 20% 80%, rgba(0, 212, 255, 0.1) 0%, transparent 50%),
100
+ radial-gradient(circle at 80% 20%, rgba(0, 102, 255, 0.1) 0%, transparent 50%),
101
+ radial-gradient(circle at 40% 40%, rgba(0, 255, 204, 0.08) 0%, transparent 50%);
102
+ animation: techPulse 8s ease-in-out infinite;
103
+ z-index: -1;
104
+ }
105
+ }
106
+
107
+ @keyframes techPulse {
108
+ 0%, 100% { opacity: 0.5; }
109
+ 50% { opacity: 0.8; }
110
+ }
111
+
112
+ @keyframes techGradient {
113
+ 0% { background-position: 0% 50%; }
114
+ 50% { background-position: 100% 50%; }
115
+ 100% { background-position: 0% 50%; }
116
+ }
117
+
118
+ /* Dark mode header */
119
+ @media (prefers-color-scheme: dark) {
120
+ header{
121
+ padding:20px 24px; position:sticky; top:0;
122
+ background:linear-gradient(180deg,rgba(10,10,10,0.9) 60%,rgba(10,10,10,0));
123
+ z-index:2; border-bottom:1px solid rgba(0, 212, 255, 0.2);
124
+ backdrop-filter: blur(10px);
125
+ }
126
+
127
+ h1{
128
+ margin:0; font-size:22px;
129
+ background: linear-gradient(45deg, var(--tech-blue), var(--tech-cyan), var(--tech-purple));
130
+ background-size: 400% 400%;
131
+ -webkit-background-clip: text;
132
+ background-clip: text;
133
+ color: transparent;
134
+ animation: techGradient 3s ease infinite;
135
+ text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
136
+ }
137
+
138
+ .muted{ opacity:.7; font-size:13px; color: #a0a0a0; }
139
+
140
+ #backBtn{
141
+ display:none; padding:6px 10px; border-radius:10px;
142
+ border:1px solid rgba(0, 212, 255, 0.3);
143
+ background:rgba(0, 0, 0, 0.3);
144
+ color:#e8eaed; cursor:pointer;
145
+ transition: all 0.3s ease;
146
+ }
147
+
148
+ #backBtn:hover {
149
+ border-color: var(--tech-blue);
150
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.3);
151
+ }
152
+
153
+ #search{
154
+ flex:1 1 260px; min-width:240px; max-width:520px;
155
+ padding:10px 14px; border-radius:12px;
156
+ border:1px solid rgba(0, 212, 255, 0.3);
157
+ background:rgba(0, 0, 0, 0.3);
158
+ color:#e8eaed; outline:none;
159
+ transition: all 0.3s ease;
160
+ }
161
+
162
+ #search:focus {
163
+ border-color: var(--tech-blue);
164
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.3);
165
+ }
166
+ }
167
+
168
+ /* Light mode header */
169
+ @media (prefers-color-scheme: light) {
170
+ header{
171
+ padding:20px 24px; position:sticky; top:0;
172
+ background:linear-gradient(180deg,rgba(248,250,252,0.9) 60%,rgba(248,250,252,0));
173
+ z-index:2; border-bottom:1px solid rgba(0, 212, 255, 0.3);
174
+ backdrop-filter: blur(10px);
175
+ }
176
+
177
+ h1{
178
+ margin:0; font-size:22px;
179
+ background: linear-gradient(45deg, #0066ff, #00d4ff, #00ffcc);
180
+ background-size: 400% 400%;
181
+ -webkit-background-clip: text;
182
+ background-clip: text;
183
+ color: transparent;
184
+ animation: techGradient 3s ease infinite;
185
+ text-shadow: 0 0 20px rgba(0, 102, 255, 0.3);
186
+ }
187
+
188
+ .muted{ opacity:.7; font-size:13px; color: #64748b; }
189
+
190
+ #backBtn{
191
+ display:none; padding:6px 10px; border-radius:10px;
192
+ border:1px solid rgba(0, 212, 255, 0.4);
193
+ background:rgba(255, 255, 255, 0.8);
194
+ color:#1e293b; cursor:pointer;
195
+ transition: all 0.3s ease;
196
+ }
197
+
198
+ #backBtn:hover {
199
+ border-color: #0066ff;
200
+ box-shadow: 0 0 10px rgba(0, 102, 255, 0.3);
201
+ }
202
+
203
+ #search{
204
+ flex:1 1 260px; min-width:240px; max-width:520px;
205
+ padding:10px 14px; border-radius:12px;
206
+ border:1px solid rgba(0, 212, 255, 0.4);
207
+ background:rgba(255, 255, 255, 0.8);
208
+ color:#1e293b; outline:none;
209
+ transition: all 0.3s ease;
210
+ }
211
+
212
+ #search:focus {
213
+ border-color: #0066ff;
214
+ box-shadow: 0 0 10px rgba(0, 102, 255, 0.3);
215
+ }
216
+ }
217
+
218
+ .row{ display:flex; gap:12px; align-items:center; flex-wrap:wrap; justify-content:center; }
219
+
220
+ main{ padding:16px 24px 24px; display:grid; place-items:center; }
221
+
222
+ .group-wrap{ width:min(900px,100%); }
223
+ .group-list{ list-style:none; margin:0; padding:0; display:grid; gap:10px; }
224
+
225
+ /* Dark mode cards */
226
+ @media (prefers-color-scheme: dark) {
227
+ .group-item{
228
+ display:flex; align-items:center; gap:12px; padding:12px 14px;
229
+ background:rgba(0, 0, 0, 0.3); border:1px solid rgba(0, 212, 255, 0.2); border-radius:14px; cursor:pointer;
230
+ transition: all 0.3s ease;
231
+ backdrop-filter: blur(10px);
232
+ }
233
+ .group-item:hover{
234
+ transform: translateY(-1px);
235
+ border-color:var(--tech-blue);
236
+ box-shadow: 0 4px 15px rgba(0, 212, 255, 0.2);
237
+ }
238
+
239
+ .card{
240
+ background:rgba(0, 0, 0, 0.3); border:1px solid rgba(0, 212, 255, 0.2); border-radius:var(--card-radius);
241
+ overflow:hidden; box-shadow:var(--shadow);
242
+ transition:all 0.3s ease; cursor:pointer; display:flex; flex-direction:column; max-width:var(--maxW);
243
+ backdrop-filter: blur(10px);
244
+ }
245
+ .card:hover{
246
+ transform:translateY(-2px);
247
+ border-color:var(--tech-blue);
248
+ box-shadow: 0 8px 25px rgba(0, 212, 255, 0.2);
249
+ }
250
+ .thumb-box{
251
+ position:relative; width:100%; aspect-ratio:2/1;
252
+ background:linear-gradient(135deg, #0e121b 0%, #1a1a2e 100%);
253
+ display:grid; place-items:center; overflow:hidden;
254
+ border-bottom: 1px solid rgba(0, 212, 255, 0.1);
255
+ }
256
+ .open{
257
+ font-size:12px; opacity:.7; padding:6px 8px;
258
+ border:1px solid rgba(0, 212, 255, 0.3);
259
+ border-radius:10px;
260
+ background:rgba(0, 212, 255, 0.1);
261
+ transition: all 0.3s ease;
262
+ }
263
+ .open:hover {
264
+ background:rgba(0, 212, 255, 0.2);
265
+ border-color: var(--tech-blue);
266
+ }
267
+ }
268
+
269
+ /* Light mode cards */
270
+ @media (prefers-color-scheme: light) {
271
+ .group-item{
272
+ display:flex; align-items:center; gap:12px; padding:12px 14px;
273
+ background:rgba(255, 255, 255, 0.8); border:1px solid rgba(0, 212, 255, 0.3); border-radius:14px; cursor:pointer;
274
+ transition: all 0.3s ease;
275
+ backdrop-filter: blur(10px);
276
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
277
+ }
278
+ .group-item:hover{
279
+ transform: translateY(-1px);
280
+ border-color:#0066ff;
281
+ box-shadow: 0 4px 15px rgba(0, 102, 255, 0.2);
282
+ }
283
+
284
+ .card{
285
+ background:rgba(255, 255, 255, 0.8); border:1px solid rgba(0, 212, 255, 0.3); border-radius:var(--card-radius);
286
+ overflow:hidden; box-shadow:0 4px 6px rgba(0, 0, 0, 0.1);
287
+ transition:all 0.3s ease; cursor:pointer; display:flex; flex-direction:column; max-width:var(--maxW);
288
+ backdrop-filter: blur(10px);
289
+ }
290
+ .card:hover{
291
+ transform:translateY(-2px);
292
+ border-color:#0066ff;
293
+ box-shadow: 0 8px 25px rgba(0, 102, 255, 0.2);
294
+ }
295
+ .thumb-box{
296
+ position:relative; width:100%; aspect-ratio:2/1;
297
+ background:linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
298
+ display:grid; place-items:center; overflow:hidden;
299
+ border-bottom: 1px solid rgba(0, 212, 255, 0.2);
300
+ }
301
+ .open{
302
+ font-size:12px; opacity:.7; padding:6px 8px;
303
+ border:1px solid rgba(0, 212, 255, 0.4);
304
+ border-radius:10px;
305
+ background:rgba(0, 212, 255, 0.1);
306
+ transition: all 0.3s ease;
307
+ }
308
+ .open:hover {
309
+ background:rgba(0, 212, 255, 0.2);
310
+ border-color: #0066ff;
311
+ }
312
+ }
313
+
314
+ .gname{ font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; width:100%; }
315
+ .grid{
316
+ width:min(1200px,100%);
317
+ display:grid;
318
+ grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
319
+ gap:var(--gap);
320
+ align-items:start;
321
+ justify-items:stretch;
322
+ margin: 0 auto;
323
+ padding: 0 20px;
324
+ }
325
+ .thumb{ max-width:100%; max-height:100%; object-fit:contain; display:block; }
326
+ .meta{ padding:12px 14px; display:flex; justify-content:space-between; align-items:center; gap:8px; }
327
+ .title{ font-weight:600; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
328
+ .empty{ opacity:.6; padding:40px 0; text-align:center; }
329
+ .crumb{ font-size:13px; opacity:.8; }
330
+
331
+ .overlay{ position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; place-items:center; padding:20px; z-index:10; }
332
+ .overlay.show{ display:grid; }
333
+
334
+ /* Dark mode viewer */
335
+ @media (prefers-color-scheme: dark) {
336
+ .viewer{
337
+ inline-size:min(92vw,var(--maxW));
338
+ block-size:min(82vh,var(--maxH));
339
+ background:#0e121b; border:1px solid rgba(0, 212, 255, 0.3); border-radius:18px; overflow:hidden; position:relative; box-shadow:0 12px 36px rgba(0,0,0,.35);
340
+ display:grid;
341
+ }
342
+ .chip{ background:rgba(0,0,0,.45); border:1px solid rgba(0, 212, 255, 0.3); color:#e8eaed; padding:6px 10px; border-radius:12px; font-size:12px; max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
343
+ .btn{ margin-left:auto; background:rgba(0, 0, 0, 0.3); color:#e8eaed; border:1px solid rgba(0, 212, 255, 0.3); border-radius:10px; padding:6px 10px; cursor:pointer; transition: all 0.3s ease; }
344
+ .btn:hover { border-color: var(--tech-blue); box-shadow: 0 0 10px rgba(0, 212, 255, 0.3); }
345
+ .mv-box{ width:100%; aspect-ratio:1036/518; background:#0b0d12; border:1px solid rgba(0, 212, 255, 0.2); border-radius:12px; overflow:hidden; }
346
+ .mv-box model-viewer{ width:100%; height:100%; background:#0b0d12; }
347
+ .res-cell{ position:relative; width:100%; aspect-ratio:2/1; background:#0e121b; border:1px solid rgba(0, 212, 255, 0.2); border-radius:12px; overflow:hidden; display:grid; place-items:center; }
348
+ .res-empty{ position:absolute; inset:0; display:grid; place-items:center; opacity:.55; font-size:12px; color:#9aa0a6; }
349
+ .download-icon{ background:rgba(0, 0, 0, 0.6); border:1px solid rgba(0, 212, 255, 0.3); color:#e8eaed; box-shadow:0 4px 12px rgba(0,0,0,0.3); }
350
+ .download-icon:hover{ background:rgba(0, 212, 255, 0.2); border-color:var(--tech-blue); box-shadow:0 0 20px rgba(0, 212, 255, 0.4); transform:scale(1.05); }
351
+ }
352
+
353
+ /* Light mode viewer */
354
+ @media (prefers-color-scheme: light) {
355
+ .viewer{
356
+ inline-size:min(92vw,var(--maxW));
357
+ block-size:min(82vh,var(--maxH));
358
+ background:#f8fafc; border:1px solid rgba(0, 212, 255, 0.4); border-radius:18px; overflow:hidden; position:relative; box-shadow:0 12px 36px rgba(0,0,0,.15);
359
+ display:grid;
360
+ }
361
+ .chip{ background:rgba(255,255,255,0.8); border:1px solid rgba(0, 212, 255, 0.4); color:#1e293b; padding:6px 10px; border-radius:12px; font-size:12px; max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
362
+ .btn{ margin-left:auto; background:rgba(255, 255, 255, 0.8); color:#1e293b; border:1px solid rgba(0, 212, 255, 0.4); border-radius:10px; padding:6px 10px; cursor:pointer; transition: all 0.3s ease; }
363
+ .btn:hover { border-color: #0066ff; box-shadow: 0 0 10px rgba(0, 102, 255, 0.3); }
364
+ .mv-box{ width:100%; aspect-ratio:1036/518; background:#f8fafc; border:1px solid rgba(0, 212, 255, 0.3); border-radius:12px; overflow:hidden; }
365
+ .mv-box model-viewer{ width:100%; height:100%; background:#f8fafc; }
366
+ .res-cell{ position:relative; width:100%; aspect-ratio:2/1; background:#f8fafc; border:1px solid rgba(0, 212, 255, 0.3); border-radius:12px; overflow:hidden; display:grid; place-items:center; }
367
+ .res-empty{ position:absolute; inset:0; display:grid; place-items:center; opacity:.55; font-size:12px; color:#64748b; }
368
+ .download-icon{ background:rgba(255, 255, 255, 0.9); border:1px solid rgba(0, 212, 255, 0.4); color:#1e293b; box-shadow:0 4px 12px rgba(0,0,0,0.15); }
369
+ .download-icon:hover{ background:rgba(0, 212, 255, 0.2); border-color:#0066ff; box-shadow:0 0 20px rgba(0, 102, 255, 0.4); transform:scale(1.05); }
370
+ }
371
+
372
+ .viewer-header{ position:absolute; top:8px; left:8px; right:8px; display:flex; gap:8px; align-items:center; z-index:2; }
373
+ .viewer-body{ height:100%; display:grid; grid-template-rows:auto auto; gap:12px; padding:36px 8px 8px 8px; overflow:auto; }
374
+ .res-grid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
375
+ .res-img{ max-width:100%; max-height:100%; object-fit:contain; display:block; }
376
+ .download-icon{ position:absolute; bottom:16px; right:16px; width:44px; height:44px; border-radius:50%; display:grid; place-items:center; font-size:20px; cursor:pointer; z-index:3; transition:all 0.3s ease; }
377
+
378
+ /* Pagination controls */
379
+ .pager {
380
+ grid-column: 1 / -1;
381
+ justify-content: center;
382
+ align-items: center;
383
+ display: flex;
384
+ gap: 16px;
385
+ margin-top: 8px;
386
+ font-size: 13px;
387
+ text-align: center;
388
+ }
389
+
390
+ /* Dark mode pagination */
391
+ @media (prefers-color-scheme: dark) {
392
+ .pager {
393
+ color: #ccc;
394
+ }
395
+ .pager button {
396
+ padding: 4px 10px;
397
+ border-radius: 8px;
398
+ border: 1px solid rgba(0, 212, 255, 0.3);
399
+ background: rgba(0, 0, 0, 0.3);
400
+ color: #e8eaed;
401
+ cursor: pointer;
402
+ transition: all 0.3s ease;
403
+ }
404
+ .pager button:hover:not(:disabled) {
405
+ border-color: var(--tech-blue);
406
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.2);
407
+ }
408
+ .pager button:disabled {
409
+ opacity: 0.4;
410
+ cursor: not-allowed;
411
+ }
412
+ }
413
+
414
+ /* Light mode pagination */
415
+ @media (prefers-color-scheme: light) {
416
+ .pager {
417
+ color: #64748b;
418
+ }
419
+ .pager button {
420
+ padding: 4px 10px;
421
+ border-radius: 8px;
422
+ border: 1px solid rgba(0, 212, 255, 0.4);
423
+ background: rgba(255, 255, 255, 0.8);
424
+ color: #1e293b;
425
+ cursor: pointer;
426
+ transition: all 0.3s ease;
427
+ }
428
+ .pager button:hover:not(:disabled) {
429
+ border-color: #0066ff;
430
+ box-shadow: 0 0 8px rgba(0, 102, 255, 0.2);
431
+ }
432
+ .pager button:disabled {
433
+ opacity: 0.4;
434
+ cursor: not-allowed;
435
+ }
436
+ }
437
+
438
+ /* Intro card styles */
439
+ @media (prefers-color-scheme: dark) {
440
+ .intro-card {
441
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.1) 0%, rgba(0, 102, 255, 0.1) 100%);
442
+ border: 1px solid rgba(0, 212, 255, 0.2);
443
+ backdrop-filter: blur(10px);
444
+ }
445
+ .intro-title {
446
+ background: linear-gradient(45deg, var(--tech-blue), var(--tech-cyan), var(--tech-purple));
447
+ background-size: 400% 400%;
448
+ -webkit-background-clip: text;
449
+ background-clip: text;
450
+ color: transparent;
451
+ animation: techGradient 3s ease infinite;
452
+ text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
453
+ }
454
+ .intro-description {
455
+ color: #e0e0e0;
456
+ }
457
+ }
458
+
459
+ @media (prefers-color-scheme: light) {
460
+ .intro-card {
461
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.05) 0%, rgba(0, 102, 255, 0.05) 100%);
462
+ border: 1px solid rgba(0, 212, 255, 0.3);
463
+ backdrop-filter: blur(10px);
464
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
465
+ }
466
+ .intro-title {
467
+ background: linear-gradient(45deg, #0066ff, #00d4ff, #00ffcc);
468
+ background-size: 400% 400%;
469
+ -webkit-background-clip: text;
470
+ background-clip: text;
471
+ color: transparent;
472
+ animation: techGradient 3s ease infinite;
473
+ text-shadow: 0 0 15px rgba(0, 102, 255, 0.2);
474
+ }
475
+ .intro-description {
476
+ color: #334155;
477
+ }
478
+ }
479
+
480
+ footer{
481
+ opacity:.55;
482
+ font-size:12px;
483
+ padding:12px 24px 24px;
484
+ text-align:center;
485
+ display:flex;
486
+ justify-content:center;
487
+ align-items:center;
488
+ width:100%;
489
+ }
490
+ </style>
491
+ </head>
492
+ <body>
493
+ <header>
494
+ <div class="row">
495
+ <button id="backBtn">← Back</button>
496
+ <h1 id="pageTitle">Depth Anything 3 Gallery</h1>
497
+ <span id="crumb" class="crumb"></span>
498
+ <input id="search" placeholder="Search…" />
499
+ </div>
500
+ <div class="muted" id="hint" style="text-align: center;">Level 1 shows groups only; click a group to browse scenes and previews.</div>
501
+ </header>
502
+
503
+ <main>
504
+ <!-- Tech intro card -->
505
+ <div class="intro-card" style="margin-bottom: 30px; padding: 25px; border-radius: 15px; text-align: center; max-width: 800px;">
506
+ <h2 class="intro-title" style="margin: 0 0 15px 0; font-size: 1.8em; font-weight: 700;">
507
+ 🎯 Depth Anything 3 Gallery
508
+ </h2>
509
+ <p class="intro-description" style="margin: 0; font-size: 1.1em; line-height: 1.6;">
510
+ Explore 3D reconstructions and depth visualizations from Depth Anything 3.
511
+ Browse through groups of scenes, preview 3D models, and examine depth maps interactively.
512
+ </p>
513
+ </div>
514
+
515
+ <div id="level1" class="group-wrap" aria-live="polite">
516
+ <ul id="groupList" class="group-list"></ul>
517
+ <div id="groupEmpty" class="empty" style="display:none;">No available groups</div>
518
+ </div>
519
+
520
+ <div id="level2" style="display:none; width:100%;" aria-live="polite">
521
+ <div id="topPager" class="pager" style="margin-bottom: 16px;"></div>
522
+ <div id="grid" class="grid"></div>
523
+ <div id="sceneEmpty" class="empty" style="display:none;">No available scenes in this group</div>
524
+ </div>
525
+ </main>
526
+
527
+ <div id="overlay" class="overlay" role="dialog" aria-modal="true" aria-label="3D Preview">
528
+ <div class="viewer" id="viewer">
529
+ <div class="viewer-header">
530
+ <div id="viewerTitle" class="chip">Loading…</div>
531
+ <button id="toggleView" class="btn" title="Toggle between 3D-only and resource view">Resource View</button>
532
+ <button id="closeBtn" class="btn">Close</button>
533
+ </div>
534
+ <div id="downloadBtn" class="download-icon" title="Download GLB model">⬇</div>
535
+ <div class="viewer-body">
536
+ <div class="mv-box"><model-viewer id="mv"
537
+ src=""
538
+ ar
539
+ camera-controls
540
+ auto-rotate
541
+ interaction-prompt="auto"
542
+ shadow-intensity="0.7"
543
+ exposure="1.0"
544
+ alt="GLB Preview"></model-viewer></div>
545
+ <div class="res-grid" id="resGrid" hidden></div>
546
+ </div>
547
+ </div>
548
+ </div>
549
+
550
+ <footer>Depth Anything 3 Gallery. Copyright 2025 Depth Anything 3 authors.</footer>
551
+
552
+ <script>
553
+ const level1=document.getElementById('level1'),level2=document.getElementById('level2'),pageTitle=document.getElementById('pageTitle'),crumb=document.getElementById('crumb'),backBtn=document.getElementById('backBtn'),hint=document.getElementById('hint'),searchInput=document.getElementById('search'),groupList=document.getElementById('groupList'),groupEmpty=document.getElementById('groupEmpty'),topPager=document.getElementById('topPager'),grid=document.getElementById('grid'),sceneEmpty=document.getElementById('sceneEmpty'),overlay=document.getElementById('overlay'),viewer=document.getElementById('viewer'),mv=document.getElementById('mv'),viewerTitle=document.getElementById('viewerTitle'),downloadBtn=document.getElementById('downloadBtn'),toggleViewBtn=document.getElementById('toggleView'),closeBtn=document.getElementById('closeBtn'),resGrid=document.getElementById('resGrid');
554
+ let GROUPS=[],SCENES=[],currentGroup=null,currentScene=null,currentPage=1,currentScenePage=1;
555
+
556
+ const qs=()=>new URLSearchParams(location.search);
557
+ async function loadGroups(){const r=await fetch('/manifest.json',{cache:'no-store'});if(!r.ok)throw new Error(r.status+' '+r.statusText);const j=await r.json();GROUPS=j.groups||[];renderGroups(GROUPS);}
558
+ async function loadScenes(g){const r=await fetch('/manifest/'+encodeURIComponent(g)+'.json',{cache:'no-store'});if(!r.ok)throw new Error(r.status+' '+r.statusText);const j=await r.json();SCENES=j.items||[];const p=parseInt(qs().get('page'))||1;renderScenes(SCENES,p);}
559
+ function renderGroups(list){groupList.innerHTML='';const q=searchInput.value.trim().toLowerCase();const f=list.filter(g=>(g.title||g.id||'').toLowerCase().includes(q));if(!f.length){groupEmpty.style.display='';return;}groupEmpty.style.display='none';for(const g of f){const li=document.createElement('li');li.className='group-item';li.title=g.title||g.id;li.onclick=()=>enterLevel2(g.id,{push:true});const name=document.createElement('div');name.className='gname';name.textContent=g.title||g.id;li.appendChild(name);groupList.appendChild(li);}}
560
+ function renderScenes(list,page=1){topPager.innerHTML='';grid.innerHTML='';const q=searchInput.value.trim().toLowerCase();const f=list.filter(x=>(x.title||'').toLowerCase().includes(q)||(x.id||'').toLowerCase().includes(q));if(!f.length){sceneEmpty.style.display='';topPager.style.display='none';return;}sceneEmpty.style.display='none';topPager.style.display='flex';const perPage=16;const total=f.length;const totalPages=Math.max(1,Math.ceil(total/perPage));currentScenePage=page;const u=new URL(location.href);u.searchParams.set('page',page);history.replaceState(null,'',u);const subset=f.slice((page-1)*perPage,page*perPage);for(const i of subset){const c=document.createElement('div');c.className='card';c.title=i.title;const b=document.createElement('div');b.className='thumb-box';const img=document.createElement('img');img.className='thumb';img.loading='lazy';img.alt=i.title;img.src=i.thumbnail;b.appendChild(img);const m=document.createElement('div');m.className='meta';const t=document.createElement('div');t.className='title';t.textContent=i.title;const o=document.createElement('div');o.className='open';o.textContent='Preview';m.appendChild(t);m.appendChild(o);c.appendChild(b);c.appendChild(m);c.onclick=()=>openViewer(i,{push:true});grid.appendChild(c);}function buildPager(){const pg=document.createElement('div');pg.className='pager';const prev=document.createElement('button');prev.textContent='← Prev';prev.disabled=page<=1;prev.onclick=()=>renderScenes(list,page-1);const info=document.createElement('span');info.textContent=`${page} / ${totalPages}`;const next=document.createElement('button');next.textContent='Next →';next.disabled=page>=totalPages;next.onclick=()=>renderScenes(list,page+1);pg.appendChild(prev);pg.appendChild(info);pg.appendChild(next);return pg;}topPager.innerHTML='';topPager.appendChild(buildPager());grid.appendChild(buildPager());}
561
+ function enterLevel1({push=false}={}){currentGroup=null;pageTitle.textContent='Depth Anything 3 Gallery';crumb.textContent='';backBtn.style.display='none';hint.style.display='';level1.style.display='';level2.style.display='none';overlay.classList.remove('show');mv.src='';const u=new URL(location.href);u.searchParams.delete('group');u.searchParams.delete('id');u.searchParams.delete('page');push?history.pushState(null,'',u):history.replaceState(null,'',u);searchInput.value='';loadGroups().catch(e=>{groupList.innerHTML='';groupEmpty.style.display='';groupEmpty.textContent='Failed to load groups: '+e;});}
562
+ async function enterLevel2(g,{push=false}={}){currentGroup=g;pageTitle.textContent=g;crumb.textContent='(group)';backBtn.style.display='';hint.style.display='none';level1.style.display='none';level2.style.display='';overlay.classList.remove('show');mv.src='';const u=new URL(location.href);u.searchParams.set('group',g);u.searchParams.delete('id');push?history.pushState(null,'',u):history.replaceState(null,'',u);searchInput.value='';try{await loadScenes(g);const id=qs().get('id');if(id){const hit=SCENES.find(x=>x.id===id);if(hit)openViewer(hit,{push:false});}}catch(e){grid.innerHTML='';sceneEmpty.style.display='';sceneEmpty.textContent='Failed to load scenes: '+e;}}
563
+ function buildResGrid(i,page=1){
564
+ resGrid.innerHTML='';
565
+ const imgs=i.depth_images||[];
566
+ const perPage=4;
567
+ const total=imgs.length;
568
+ const totalPages=Math.max(1, Math.ceil(total/perPage));
569
+ currentPage=page;
570
+
571
+ const subset=imgs.slice((page-1)*perPage,(page-1)*perPage+perPage);
572
+ for(let k=0;k<4;k++){
573
+ const cell=document.createElement('div');
574
+ cell.className='res-cell';
575
+ if(subset[k]){
576
+ const im=document.createElement('img');
577
+ im.className='res-img';
578
+ im.src=subset[k];
579
+ im.alt=(i.title||'scene')+' depth '+(k+1+(page-1)*perPage);
580
+ im.loading='lazy';
581
+ cell.appendChild(im);
582
+ } else {
583
+ const ph=document.createElement('div');
584
+ ph.className='res-empty';
585
+ ph.textContent='N/A';
586
+ cell.appendChild(ph);
587
+ }
588
+ resGrid.appendChild(cell);
589
+ }
590
+
591
+ // pagination bar (always rebuilt)
592
+ const pager=document.createElement('div');
593
+ pager.className='pager';
594
+
595
+ const prev=document.createElement('button');
596
+ prev.textContent='← Prev';
597
+ prev.disabled=page<=1;
598
+ prev.onclick=()=>buildResGrid(i,page-1);
599
+
600
+ const info=document.createElement('span');
601
+ info.textContent=`${page} / ${totalPages}`;
602
+
603
+ const next=document.createElement('button');
604
+ next.textContent='Next →';
605
+ next.disabled=page>=totalPages;
606
+ next.onclick=()=>buildResGrid(i,page+1);
607
+
608
+ pager.appendChild(prev);
609
+ pager.appendChild(info);
610
+ pager.appendChild(next);
611
+ resGrid.appendChild(pager);
612
+ }
613
+ function openViewer(i,{push=false}={}){currentScene=i;viewerTitle.textContent=i.title;mv.src=i.model;overlay.classList.add('show');resGrid.hidden=true;toggleViewBtn.textContent='Resource View';viewer.style.blockSize='min(82vh,var(--maxH))';buildResGrid(i,1);downloadBtn.onclick=()=>{const a=document.createElement('a');a.href=i.model;a.download=i.title+'.glb';a.click();};if(push){const u=new URL(location.href);if(!u.searchParams.get('group'))u.searchParams.set('group',currentGroup||'');u.searchParams.set('id',i.id);history.pushState(null,'',u);}}
614
+ function toggleView(){const hidden=!resGrid.hidden;resGrid.hidden=hidden;toggleViewBtn.textContent=hidden?'Resource View':'3D Only';viewer.style.blockSize=hidden?'min(82vh,var(--maxH))':'min(92vh,900px)';}
615
+ function closeViewer(){const hasId=!!qs().get('id');if(hasId&&history.length>1){history.back();return;}const u=new URL(location.href);u.searchParams.delete('id');history.replaceState(null,'',u);overlay.classList.remove('show');mv.src='';}
616
+ overlay.onclick=e=>{if(e.target===overlay)closeViewer();};closeBtn.onclick=closeViewer;toggleViewBtn.onclick=toggleView;backBtn.onclick=()=>history.back();
617
+ searchInput.oninput=()=>{!qs().get('group')?renderGroups(GROUPS):renderScenes(SCENES,1);};
618
+ window.onpopstate=()=>routeFromURL();
619
+ async function routeFromURL(){if(location.pathname!="/")history.replaceState(null,'','/'+location.search);const g=qs().get('group');const id=qs().get('id');if(!g){enterLevel1({push:false});return;}await enterLevel2(g,{push:false});if(id){const hit=SCENES.find(x=>x.id===id);if(hit)openViewer(hit,{push:false});else{overlay.classList.remove('show');mv.src='';}}else{overlay.classList.remove('show');mv.src='';}}
620
+ routeFromURL();
621
+ </script>
622
+ </body>
623
+ </html>
624
+ """
625
+
626
+ # ------------------------------ Utilities ------------------------------ #
627
+
628
+ IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
629
+
630
+
631
+ def _url_join(*parts: str) -> str:
632
+ norm = posixpath.join(*[p.replace("\\", "/") for p in parts])
633
+ segs = [s for s in norm.split("/") if s not in ("", ".")]
634
+ return "/".join(quote(s) for s in segs)
635
+
636
+
637
+ def _is_plain_name(name: str) -> bool:
638
+ return all(c not in name for c in ("/", "\\")) and name not in (".", "..")
639
+
640
+
641
+ def build_group_list(root_dir: str) -> dict:
642
+ groups = []
643
+ try:
644
+ for gname in sorted(os.listdir(root_dir)):
645
+ gpath = os.path.join(root_dir, gname)
646
+ if not os.path.isdir(gpath):
647
+ continue
648
+ has_scene = False
649
+ try:
650
+ for sname in os.listdir(gpath):
651
+ spath = os.path.join(gpath, sname)
652
+ if not os.path.isdir(spath):
653
+ continue
654
+ if os.path.exists(os.path.join(spath, "scene.glb")) and os.path.exists(
655
+ os.path.join(spath, "scene.jpg")
656
+ ):
657
+ has_scene = True
658
+ break
659
+ except Exception:
660
+ pass
661
+ if has_scene:
662
+ groups.append({"id": gname, "title": gname})
663
+ except Exception as e:
664
+ print(f"[warn] build_group_list failed: {e}", file=sys.stderr)
665
+ return {"groups": groups}
666
+
667
+
668
+ def build_group_manifest(root_dir: str, group: str) -> dict:
669
+ items = []
670
+ gpath = os.path.join(root_dir, group)
671
+ try:
672
+ if not os.path.isdir(gpath):
673
+ return {"group": group, "items": []}
674
+ for sname in sorted(os.listdir(gpath)):
675
+ spath = os.path.join(gpath, sname)
676
+ if not os.path.isdir(spath):
677
+ continue
678
+ glb_fs = os.path.join(spath, "scene.glb")
679
+ jpg_fs = os.path.join(spath, "scene.jpg")
680
+ if not (os.path.exists(glb_fs) and os.path.exists(jpg_fs)):
681
+ continue
682
+ depth_images = []
683
+ dpath = os.path.join(spath, "depth_vis")
684
+ if os.path.isdir(dpath):
685
+ files = [
686
+ f for f in os.listdir(dpath) if os.path.splitext(f)[1].lower() in IMAGE_EXTS
687
+ ]
688
+ for fn in sorted(files):
689
+ depth_images.append("/" + _url_join(group, sname, "depth_vis", fn))
690
+ items.append(
691
+ {
692
+ "id": sname,
693
+ "title": sname,
694
+ "model": "/" + _url_join(group, sname, "scene.glb"),
695
+ "thumbnail": "/" + _url_join(group, sname, "scene.jpg"),
696
+ "depth_images": depth_images,
697
+ }
698
+ )
699
+ except Exception as e:
700
+ print(f"[warn] build_group_manifest failed for {group}: {e}", file=sys.stderr)
701
+ return {"group": group, "items": items}
702
+
703
+
704
+ class GalleryHandler(SimpleHTTPRequestHandler):
705
+ def __init__(self, *args, directory=None, **kwargs):
706
+ super().__init__(*args, directory=directory, **kwargs)
707
+
708
+ def do_GET(self):
709
+ if self.path in ("/", "/index.html") or self.path.startswith("/?"):
710
+ content = HTML_PAGE.encode("utf-8")
711
+ self.send_response(HTTPStatus.OK)
712
+ self.send_header("Content-Type", "text/html; charset=utf-8")
713
+ self.send_header("Content-Length", str(len(content)))
714
+ self.send_header("Cache-Control", "no-store")
715
+ self.end_headers()
716
+ self.wfile.write(content)
717
+ return
718
+ if self.path == "/manifest.json":
719
+ data = json.dumps(
720
+ build_group_list(self.directory), ensure_ascii=False, indent=2
721
+ ).encode("utf-8")
722
+ self.send_response(HTTPStatus.OK)
723
+ self.send_header("Content-Type", "application/json; charset=utf-8")
724
+ self.send_header("Content-Length", str(len(data)))
725
+ self.send_header("Cache-Control", "no-store")
726
+ self.end_headers()
727
+ self.wfile.write(data)
728
+ return
729
+ if self.path.startswith("/manifest/") and self.path.endswith(".json"):
730
+ group_enc = self.path[len("/manifest/") : -len(".json")]
731
+ try:
732
+ group = unquote(group_enc)
733
+ except Exception:
734
+ group = group_enc
735
+ if not _is_plain_name(group):
736
+ self.send_error(HTTPStatus.BAD_REQUEST, "Invalid group name")
737
+ return
738
+ data = json.dumps(
739
+ build_group_manifest(self.directory, group), ensure_ascii=False, indent=2
740
+ ).encode("utf-8")
741
+ self.send_response(HTTPStatus.OK)
742
+ self.send_header("Content-Type", "application/json; charset=utf-8")
743
+ self.send_header("Content-Length", str(len(data)))
744
+ self.send_header("Cache-Control", "no-store")
745
+ self.end_headers()
746
+ self.wfile.write(data)
747
+ return
748
+ if self.path == "/favicon.ico":
749
+ self.send_response(HTTPStatus.NO_CONTENT)
750
+ self.end_headers()
751
+ return
752
+ return super().do_GET()
753
+
754
+ def list_directory(self, path):
755
+ self.send_error(HTTPStatus.NOT_FOUND, "Directory listing disabled")
756
+ return None
757
+
758
+
759
+ def gallery():
760
+ parser = argparse.ArgumentParser(
761
+ description="Depth Anything 3 Gallery Server (two-level, with pagination)"
762
+ )
763
+ parser.add_argument(
764
+ "-d", "--dir", required=True, help="Gallery root directory (two-level: group/scene)"
765
+ )
766
+ parser.add_argument("-p", "--port", type=int, default=8000, help="Port (default 8000)")
767
+ parser.add_argument("--host", default="127.0.0.1", help="Host address (default 127.0.0.1)")
768
+ parser.add_argument("--open", action="store_true", help="Open browser after launch")
769
+ args = parser.parse_args()
770
+
771
+ root_dir = os.path.abspath(args.dir)
772
+ if not os.path.isdir(root_dir):
773
+ print(f"[error] Directory not found: {root_dir}", file=sys.stderr)
774
+ sys.exit(1)
775
+
776
+ Handler = partial(GalleryHandler, directory=root_dir)
777
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
778
+
779
+ addr = f"http://{args.host}:{args.port}/"
780
+ print(f"[info] Serving gallery from: {root_dir}")
781
+ print(f"[info] Open: {addr}")
782
+
783
+ if args.open:
784
+ try:
785
+ import webbrowser
786
+
787
+ webbrowser.open(addr)
788
+ except Exception as e:
789
+ print(f"[warn] Failed to open browser: {e}", file=sys.stderr)
790
+
791
+ try:
792
+ server.serve_forever()
793
+ except KeyboardInterrupt:
794
+ print("\n[info] Shutting down...")
795
+ finally:
796
+ server.server_close()
797
+
798
+
799
+ def main():
800
+ """Main entry point for gallery server."""
801
+ mimetypes.add_type("model/gltf-binary", ".glb")
802
+ gallery()
803
+
804
+
805
+ if __name__ == "__main__":
806
+ main()
multivue/cameras/source/src/depth_anything_3/services/inference_service.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Unified Inference Service
17
+ Provides unified interface for local and remote inference
18
+ """
19
+
20
+ import os
21
+
22
+ from typing import Any, Dict, List, Optional, Union
23
+ import numpy as np
24
+ import requests
25
+ import typer
26
+
27
+ from ..api import DepthAnything3
28
+
29
+
30
+ class InferenceService:
31
+ """Unified inference service class"""
32
+
33
+ def __init__(self, model_dir: str, device: str = "cuda"):
34
+ self.model_dir = model_dir
35
+ self.device = device
36
+ self.model = None
37
+
38
+ def load_model(self):
39
+ """Load model"""
40
+ if self.model is None:
41
+ typer.echo(f"Loading model from {self.model_dir}...")
42
+ self.model = DepthAnything3.from_pretrained(self.model_dir).to(self.device)
43
+ return self.model
44
+
45
+ def run_local_inference(
46
+ self,
47
+ image_paths: List[str],
48
+ export_dir: str,
49
+ export_format: str = "mini_npz-glb",
50
+ process_res: int = 504,
51
+ process_res_method: str = "upper_bound_resize",
52
+ export_feat_layers: List[int] = None,
53
+ extrinsics: Optional[np.ndarray] = None,
54
+ intrinsics: Optional[np.ndarray] = None,
55
+ align_to_input_ext_scale: bool = True,
56
+ use_ray_pose: bool = False,
57
+ ref_view_strategy: str = "saddle_balanced",
58
+ conf_thresh_percentile: float = 40.0,
59
+ num_max_points: int = 1_000_000,
60
+ show_cameras: bool = True,
61
+ feat_vis_fps: int = 15,
62
+ ) -> Any:
63
+ """Run local inference"""
64
+ if export_feat_layers is None:
65
+ export_feat_layers = []
66
+
67
+ model = self.load_model()
68
+
69
+ # Prepare inference parameters
70
+ inference_kwargs = {
71
+ "image": image_paths,
72
+ "export_dir": export_dir,
73
+ "export_format": export_format,
74
+ "process_res": process_res,
75
+ "process_res_method": process_res_method,
76
+ "export_feat_layers": export_feat_layers,
77
+ "align_to_input_ext_scale": align_to_input_ext_scale,
78
+ "use_ray_pose": use_ray_pose,
79
+ "ref_view_strategy": ref_view_strategy,
80
+ "conf_thresh_percentile": conf_thresh_percentile,
81
+ "num_max_points": num_max_points,
82
+ "show_cameras": show_cameras,
83
+ "feat_vis_fps": feat_vis_fps,
84
+ }
85
+
86
+ # Add pose data (if exists)
87
+ if extrinsics is not None:
88
+ inference_kwargs["extrinsics"] = extrinsics
89
+ if intrinsics is not None:
90
+ inference_kwargs["intrinsics"] = intrinsics
91
+
92
+ # Run inference
93
+ typer.echo(f"Running inference on {len(image_paths)} images...")
94
+ prediction = model.inference(**inference_kwargs)
95
+
96
+ typer.echo(f"Results saved to {export_dir}")
97
+ typer.echo(f"Export format: {export_format}")
98
+
99
+ return prediction
100
+
101
+ def run_backend_inference(
102
+ self,
103
+ image_paths: List[str],
104
+ export_dir: str,
105
+ backend_url: str,
106
+ export_format: str = "mini_npz-glb",
107
+ process_res: int = 504,
108
+ process_res_method: str = "upper_bound_resize",
109
+ export_feat_layers: List[int] = None,
110
+ extrinsics: Optional[np.ndarray] = None,
111
+ intrinsics: Optional[np.ndarray] = None,
112
+ align_to_input_ext_scale: bool = True,
113
+ use_ray_pose: bool = False,
114
+ ref_view_strategy: str = "saddle_balanced",
115
+ conf_thresh_percentile: float = 40.0,
116
+ num_max_points: int = 1_000_000,
117
+ show_cameras: bool = True,
118
+ feat_vis_fps: int = 15,
119
+ ) -> Dict[str, Any]:
120
+ """Run backend inference"""
121
+ if export_feat_layers is None:
122
+ export_feat_layers = []
123
+
124
+ # Check backend status
125
+ if not self._check_backend_status(backend_url):
126
+ raise typer.BadParameter(f"Backend service is not running at {backend_url}")
127
+
128
+ # Prepare payload
129
+ payload = {
130
+ "image_paths": image_paths,
131
+ "export_dir": export_dir,
132
+ "export_format": export_format,
133
+ "process_res": process_res,
134
+ "process_res_method": process_res_method,
135
+ "export_feat_layers": export_feat_layers,
136
+ "align_to_input_ext_scale": align_to_input_ext_scale,
137
+ "use_ray_pose": use_ray_pose,
138
+ "ref_view_strategy": ref_view_strategy,
139
+ "conf_thresh_percentile": conf_thresh_percentile,
140
+ "num_max_points": num_max_points,
141
+ "show_cameras": show_cameras,
142
+ "feat_vis_fps": feat_vis_fps,
143
+ }
144
+
145
+ # Add pose data (if exists)
146
+ if extrinsics is not None:
147
+ payload["extrinsics"] = [ext.astype(np.float64).tolist() for ext in extrinsics]
148
+ if intrinsics is not None:
149
+ payload["intrinsics"] = [intr.astype(np.float64).tolist() for intr in intrinsics]
150
+
151
+ # Submit task
152
+ typer.echo("Submitting inference task to backend...")
153
+ # Sent automatically when the backend was started with a matching --api-key /
154
+ # DA3_BACKEND_API_KEY, so client and server share the same env-var convention.
155
+ api_key = os.environ.get("DA3_BACKEND_API_KEY")
156
+ headers = {"X-API-Key": api_key} if api_key else None
157
+ try:
158
+ response = requests.post(
159
+ f"{backend_url}/inference", json=payload, headers=headers, timeout=30
160
+ )
161
+ response.raise_for_status()
162
+ result = response.json()
163
+
164
+ if result["success"]:
165
+ task_id = result["task_id"]
166
+ typer.echo("Task submitted successfully!")
167
+ typer.echo(f"Task ID: {task_id}")
168
+ typer.echo(f"Results will be saved to: {export_dir}")
169
+ typer.echo(f"Check backend logs for progress updates with task ID: {task_id}")
170
+ return result
171
+ else:
172
+ raise typer.BadParameter(
173
+ f"Backend inference submission failed: {result['message']}"
174
+ )
175
+ except requests.exceptions.RequestException as e:
176
+ raise typer.BadParameter(f"Backend inference submission failed: {e}")
177
+
178
+ def _check_backend_status(self, backend_url: str) -> bool:
179
+ """Check backend status"""
180
+ try:
181
+ response = requests.get(f"{backend_url}/status", timeout=5)
182
+ return response.status_code == 200
183
+ except Exception:
184
+ return False
185
+
186
+
187
+ def run_inference(
188
+ image_paths: List[str],
189
+ export_dir: str,
190
+ model_dir: str,
191
+ device: str = "cuda",
192
+ backend_url: Optional[str] = None,
193
+ export_format: str = "mini_npz-glb",
194
+ process_res: int = 504,
195
+ process_res_method: str = "upper_bound_resize",
196
+ export_feat_layers: List[int] = None,
197
+ extrinsics: Optional[np.ndarray] = None,
198
+ intrinsics: Optional[np.ndarray] = None,
199
+ align_to_input_ext_scale: bool = True,
200
+ use_ray_pose: bool = False,
201
+ ref_view_strategy: str = "saddle_balanced",
202
+ conf_thresh_percentile: float = 40.0,
203
+ num_max_points: int = 1_000_000,
204
+ show_cameras: bool = True,
205
+ feat_vis_fps: int = 15,
206
+ ) -> Union[Any, Dict[str, Any]]:
207
+ """Unified inference interface"""
208
+
209
+ service = InferenceService(model_dir, device)
210
+
211
+ if backend_url:
212
+ return service.run_backend_inference(
213
+ image_paths=image_paths,
214
+ export_dir=export_dir,
215
+ backend_url=backend_url,
216
+ export_format=export_format,
217
+ process_res=process_res,
218
+ process_res_method=process_res_method,
219
+ export_feat_layers=export_feat_layers,
220
+ extrinsics=extrinsics,
221
+ intrinsics=intrinsics,
222
+ align_to_input_ext_scale=align_to_input_ext_scale,
223
+ use_ray_pose=use_ray_pose,
224
+ ref_view_strategy=ref_view_strategy,
225
+ conf_thresh_percentile=conf_thresh_percentile,
226
+ num_max_points=num_max_points,
227
+ show_cameras=show_cameras,
228
+ feat_vis_fps=feat_vis_fps,
229
+ )
230
+ else:
231
+ return service.run_local_inference(
232
+ image_paths=image_paths,
233
+ export_dir=export_dir,
234
+ export_format=export_format,
235
+ process_res=process_res,
236
+ process_res_method=process_res_method,
237
+ export_feat_layers=export_feat_layers,
238
+ extrinsics=extrinsics,
239
+ intrinsics=intrinsics,
240
+ align_to_input_ext_scale=align_to_input_ext_scale,
241
+ use_ray_pose=use_ray_pose,
242
+ ref_view_strategy=ref_view_strategy,
243
+ conf_thresh_percentile=conf_thresh_percentile,
244
+ num_max_points=num_max_points,
245
+ show_cameras=show_cameras,
246
+ feat_vis_fps=feat_vis_fps,
247
+ )
multivue/cameras/source/src/depth_anything_3/services/input_handlers.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Input Processing Service
17
+ Handles different types of inputs (image, images, colmap, video)
18
+ """
19
+
20
+ import glob
21
+ import os
22
+ from typing import List, Tuple
23
+ import cv2
24
+ import numpy as np
25
+ import typer
26
+
27
+ from ..utils.read_write_model import read_model
28
+
29
+
30
+ class InputHandler:
31
+ """Base input handler class"""
32
+
33
+ @staticmethod
34
+ def validate_path(path: str, path_type: str = "file") -> str:
35
+ """Validate path"""
36
+ if not os.path.exists(path):
37
+ raise typer.BadParameter(f"{path_type} not found: {path}")
38
+ return path
39
+
40
+ @staticmethod
41
+ def handle_export_dir(export_dir: str, auto_cleanup: bool = False) -> str:
42
+ """Handle export directory"""
43
+ if os.path.exists(export_dir):
44
+ if auto_cleanup:
45
+ typer.echo(f"Auto-cleaning existing export directory: {export_dir}")
46
+ import shutil
47
+
48
+ shutil.rmtree(export_dir)
49
+ os.makedirs(export_dir, exist_ok=True)
50
+ else:
51
+ typer.echo(f"Export directory '{export_dir}' already exists.")
52
+ if typer.confirm("Do you want to clean it and continue?"):
53
+ import shutil
54
+
55
+ shutil.rmtree(export_dir)
56
+ os.makedirs(export_dir, exist_ok=True)
57
+ typer.echo(f"Cleaned export directory: {export_dir}")
58
+ else:
59
+ typer.echo("Operation cancelled.")
60
+ raise typer.Exit(0)
61
+ else:
62
+ os.makedirs(export_dir, exist_ok=True)
63
+ return export_dir
64
+
65
+
66
+ class ImageHandler(InputHandler):
67
+ """Single image handler"""
68
+
69
+ @staticmethod
70
+ def process(image_path: str) -> List[str]:
71
+ """Process single image"""
72
+ InputHandler.validate_path(image_path, "Image file")
73
+ return [image_path]
74
+
75
+
76
+ class ImagesHandler(InputHandler):
77
+ """Image directory handler"""
78
+
79
+ @staticmethod
80
+ def process(images_dir: str, image_extensions: str = "png,jpg,jpeg") -> List[str]:
81
+ """Process image directory"""
82
+ InputHandler.validate_path(images_dir, "Images directory")
83
+
84
+ # Parse extensions
85
+ extensions = [ext.strip().lower() for ext in image_extensions.split(",")]
86
+ extensions = [ext if ext.startswith(".") else f".{ext}" for ext in extensions]
87
+
88
+ # Find image files
89
+ image_files = []
90
+ for ext in extensions:
91
+ pattern = f"*{ext}"
92
+ image_files.extend(glob.glob(os.path.join(images_dir, pattern)))
93
+ image_files.extend(glob.glob(os.path.join(images_dir, pattern.upper())))
94
+
95
+ image_files = sorted(list(set(image_files))) # Remove duplicates and sort
96
+
97
+ if not image_files:
98
+ raise typer.BadParameter(
99
+ f"No image files found in {images_dir} with extensions: {extensions}"
100
+ )
101
+
102
+ typer.echo(f"Found {len(image_files)} images to process")
103
+ return image_files
104
+
105
+
106
+ class ColmapHandler(InputHandler):
107
+ """COLMAP data handler"""
108
+
109
+ @staticmethod
110
+ def process(
111
+ colmap_dir: str, sparse_subdir: str = ""
112
+ ) -> Tuple[List[str], np.ndarray, np.ndarray]:
113
+ """Process COLMAP data"""
114
+ InputHandler.validate_path(colmap_dir, "COLMAP directory")
115
+
116
+ # Build paths
117
+ images_dir = os.path.join(colmap_dir, "images")
118
+ if sparse_subdir:
119
+ sparse_dir = os.path.join(colmap_dir, "sparse", sparse_subdir)
120
+ else:
121
+ sparse_dir = os.path.join(colmap_dir, "sparse")
122
+
123
+ InputHandler.validate_path(images_dir, "Images directory")
124
+ InputHandler.validate_path(sparse_dir, "Sparse reconstruction directory")
125
+
126
+ # Load COLMAP data
127
+ typer.echo("Loading COLMAP reconstruction data...")
128
+ try:
129
+ cameras, images, points3D = read_model(sparse_dir)
130
+
131
+ typer.echo(
132
+ f"Loaded COLMAP data: {len(cameras)} cameras, {len(images)} images, "
133
+ f"{len(points3D)} 3D points."
134
+ )
135
+
136
+ # Get image files and pose data
137
+ image_files = []
138
+ extrinsics = []
139
+ intrinsics = []
140
+
141
+ for image_id, image_data in images.items():
142
+ image_name = image_data.name
143
+ image_path = os.path.join(images_dir, image_name)
144
+
145
+ if os.path.exists(image_path):
146
+ image_files.append(image_path)
147
+
148
+ # Get camera parameters
149
+ camera = cameras[image_data.camera_id]
150
+
151
+ # Convert quaternion to rotation matrix
152
+ R = image_data.qvec2rotmat()
153
+ t = image_data.tvec
154
+
155
+ # Create extrinsic matrix (world to camera)
156
+ extrinsic = np.eye(4)
157
+ extrinsic[:3, :3] = R
158
+ extrinsic[:3, 3] = t
159
+ extrinsics.append(extrinsic)
160
+
161
+ # Create intrinsic matrix
162
+ if camera.model == "PINHOLE":
163
+ fx, fy, cx, cy = camera.params
164
+ elif camera.model == "SIMPLE_PINHOLE":
165
+ f, cx, cy = camera.params
166
+ fx = fy = f
167
+ else:
168
+ # For other models, use basic pinhole approximation
169
+ fx = fy = camera.params[0] if len(camera.params) > 0 else 1000
170
+ cx = camera.width / 2
171
+ cy = camera.height / 2
172
+
173
+ intrinsic = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]])
174
+ intrinsics.append(intrinsic)
175
+
176
+ if not image_files:
177
+ raise typer.BadParameter("No valid images found in COLMAP data")
178
+
179
+ typer.echo(f"Found {len(image_files)} valid images with pose data")
180
+
181
+ return image_files, np.array(extrinsics), np.array(intrinsics)
182
+
183
+ except Exception as e:
184
+ raise typer.BadParameter(f"Failed to load COLMAP data: {e}")
185
+
186
+
187
+ class VideoHandler(InputHandler):
188
+ """Video handler"""
189
+
190
+ @staticmethod
191
+ def process(video_path: str, output_dir: str, fps: float = 1.0) -> List[str]:
192
+ """Process video, extract frames"""
193
+ InputHandler.validate_path(video_path, "Video file")
194
+
195
+ cap = cv2.VideoCapture(video_path)
196
+ if not cap.isOpened():
197
+ raise typer.BadParameter(f"Cannot open video: {video_path}")
198
+
199
+ # Get video properties
200
+ video_fps = cap.get(cv2.CAP_PROP_FPS)
201
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
202
+ duration = total_frames / video_fps
203
+
204
+ # Calculate frame interval (ensure at least 1)
205
+ frame_interval = max(1, int(video_fps / fps))
206
+ actual_fps = video_fps / frame_interval
207
+
208
+ typer.echo(f"Video FPS: {video_fps:.2f}, Duration: {duration:.2f}s")
209
+
210
+ # Warn if requested FPS is higher than video FPS
211
+ if fps > video_fps:
212
+ typer.echo(
213
+ f"⚠️ Warning: Requested sampling FPS ({fps:.2f}) exceeds video FPS ({video_fps:.2f})", # noqa: E501
214
+ err=True,
215
+ )
216
+ typer.echo(
217
+ f"⚠️ Using maximum available FPS: {actual_fps:.2f} (extracting every frame)",
218
+ err=True,
219
+ )
220
+
221
+ typer.echo(f"Extracting frames at {actual_fps:.2f} FPS (every {frame_interval} frame(s))")
222
+
223
+ # Create output directory
224
+ frames_dir = os.path.join(output_dir, "input_images")
225
+ os.makedirs(frames_dir, exist_ok=True)
226
+
227
+ frame_count = 0
228
+ saved_count = 0
229
+
230
+ while True:
231
+ ret, frame = cap.read()
232
+ if not ret:
233
+ break
234
+
235
+ if frame_count % frame_interval == 0:
236
+ frame_path = os.path.join(frames_dir, f"{saved_count:06d}.png")
237
+ cv2.imwrite(frame_path, frame)
238
+ saved_count += 1
239
+
240
+ frame_count += 1
241
+
242
+ cap.release()
243
+ typer.echo(f"Extracted {saved_count} frames to {frames_dir}")
244
+
245
+ # Get frame file list
246
+ frame_files = sorted(
247
+ [f for f in os.listdir(frames_dir) if f.endswith((".png", ".jpg", ".jpeg"))]
248
+ )
249
+ if not frame_files:
250
+ raise typer.BadParameter("No frames extracted from video")
251
+
252
+ return [os.path.join(frames_dir, f) for f in frame_files]
253
+
254
+
255
+ def parse_export_feat(export_feat_str: str) -> List[int]:
256
+ """Parse export_feat parameter"""
257
+ if not export_feat_str:
258
+ return []
259
+
260
+ try:
261
+ return [int(x.strip()) for x in export_feat_str.split(",") if x.strip()]
262
+ except ValueError:
263
+ raise typer.BadParameter(
264
+ f"Invalid export_feat format: {export_feat_str}. "
265
+ "Use comma-separated integers like '0,1,2'"
266
+ )
multivue/cameras/source/src/depth_anything_3/specs.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Any, Optional
19
+ import numpy as np
20
+ import torch
21
+
22
+
23
+ @dataclass
24
+ class Gaussians:
25
+ """3DGS parameters, all in world space"""
26
+
27
+ means: torch.Tensor # world points, "batch gaussian dim"
28
+ scales: torch.Tensor # scales_std, "batch gaussian 3"
29
+ rotations: torch.Tensor # world_quat_wxyz, "batch gaussian 4"
30
+ harmonics: torch.Tensor # world SH, "batch gaussian 3 d_sh"
31
+ opacities: torch.Tensor # opacity | opacity SH, "batch gaussian" | "batch gaussian 1 d_sh"
32
+
33
+
34
+ @dataclass
35
+ class Prediction:
36
+ depth: np.ndarray # N, H, W
37
+ is_metric: int
38
+ sky: np.ndarray | None = None # N, H, W
39
+ conf: np.ndarray | None = None # N, H, W
40
+ extrinsics: np.ndarray | None = None # N, 4, 4
41
+ intrinsics: np.ndarray | None = None # N, 3, 3
42
+ processed_images: np.ndarray | None = None # N, H, W, 3 - processed images for visualization
43
+ gaussians: Gaussians | None = None # 3D gaussians
44
+ aux: dict[str, Any] = None #
45
+ scale_factor: Optional[float] = None # metric scale
multivue/cameras/source/src/depth_anything_3/utils/__pycache__/alignment.cpython-312.pyc ADDED
Binary file (6.05 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/__pycache__/constants.cpython-312.pyc ADDED
Binary file (3.83 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/__pycache__/geometry.cpython-312.pyc ADDED
Binary file (20.3 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/__pycache__/logger.cpython-312.pyc ADDED
Binary file (3.65 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/__pycache__/parallel_utils.cpython-312.pyc ADDED
Binary file (5 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/__pycache__/ray_utils.cpython-312.pyc ADDED
Binary file (21.8 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/alignment.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Alignment utilities for depth estimation and metric scaling.
17
+ """
18
+
19
+ from typing import Tuple
20
+ import torch
21
+
22
+
23
+ def least_squares_scale_scalar(
24
+ a: torch.Tensor, b: torch.Tensor, eps: float = 1e-12
25
+ ) -> torch.Tensor:
26
+ """
27
+ Compute least squares scale factor s such that a ≈ s * b.
28
+
29
+ Args:
30
+ a: First tensor
31
+ b: Second tensor
32
+ eps: Small epsilon for numerical stability
33
+
34
+ Returns:
35
+ Scalar tensor containing the scale factor
36
+
37
+ Raises:
38
+ ValueError: If tensors have mismatched shapes or devices
39
+ TypeError: If tensors are not floating point
40
+ """
41
+ if a.shape != b.shape:
42
+ raise ValueError(f"Shape mismatch: {a.shape} vs {b.shape}")
43
+ if a.device != b.device:
44
+ raise ValueError(f"Device mismatch: {a.device} vs {b.device}")
45
+ if not a.is_floating_point() or not b.is_floating_point():
46
+ raise TypeError("Tensors must be floating point type")
47
+
48
+ # Compute dot products for least squares solution
49
+ num = torch.dot(a.reshape(-1), b.reshape(-1))
50
+ den = torch.dot(b.reshape(-1), b.reshape(-1)).clamp_min(eps)
51
+ return num / den
52
+
53
+
54
+ def compute_sky_mask(sky_prediction: torch.Tensor, threshold: float = 0.3) -> torch.Tensor:
55
+ """
56
+ Compute non-sky mask from sky prediction.
57
+
58
+ Args:
59
+ sky_prediction: Sky prediction tensor
60
+ threshold: Threshold for sky classification
61
+
62
+ Returns:
63
+ Boolean mask where True indicates non-sky regions
64
+ """
65
+ return sky_prediction < threshold
66
+
67
+
68
+ def compute_alignment_mask(
69
+ depth_conf: torch.Tensor,
70
+ non_sky_mask: torch.Tensor,
71
+ depth: torch.Tensor,
72
+ metric_depth: torch.Tensor,
73
+ median_conf: torch.Tensor,
74
+ min_depth_threshold: float = 1e-3,
75
+ min_metric_depth_threshold: float = 1e-2,
76
+ ) -> torch.Tensor:
77
+ """
78
+ Compute mask for depth alignment based on confidence and depth thresholds.
79
+
80
+ Args:
81
+ depth_conf: Depth confidence tensor
82
+ non_sky_mask: Non-sky region mask
83
+ depth: Predicted depth tensor
84
+ metric_depth: Metric depth tensor
85
+ median_conf: Median confidence threshold
86
+ min_depth_threshold: Minimum depth threshold
87
+ min_metric_depth_threshold: Minimum metric depth threshold
88
+
89
+ Returns:
90
+ Boolean mask for valid alignment regions
91
+ """
92
+ return (
93
+ (depth_conf >= median_conf)
94
+ & non_sky_mask
95
+ & (metric_depth > min_metric_depth_threshold)
96
+ & (depth > min_depth_threshold)
97
+ )
98
+
99
+
100
+ def sample_tensor_for_quantile(tensor: torch.Tensor, max_samples: int = 100000) -> torch.Tensor:
101
+ """
102
+ Sample tensor elements for quantile computation to reduce memory usage.
103
+
104
+ Args:
105
+ tensor: Input tensor to sample
106
+ max_samples: Maximum number of samples to take
107
+
108
+ Returns:
109
+ Sampled tensor
110
+ """
111
+ if tensor.numel() <= max_samples:
112
+ return tensor
113
+
114
+ idx = torch.randperm(tensor.numel(), device=tensor.device)[:max_samples]
115
+ return tensor.flatten()[idx]
116
+
117
+
118
+ def apply_metric_scaling(
119
+ depth: torch.Tensor, intrinsics: torch.Tensor, scale_factor: float = 300.0
120
+ ) -> torch.Tensor:
121
+ """
122
+ Apply metric scaling to depth based on camera intrinsics.
123
+
124
+ Args:
125
+ depth: Input depth tensor
126
+ intrinsics: Camera intrinsics tensor
127
+ scale_factor: Scaling factor for metric conversion
128
+
129
+ Returns:
130
+ Scaled depth tensor
131
+ """
132
+ focal_length = (intrinsics[:, :, 0, 0] + intrinsics[:, :, 1, 1]) / 2
133
+ return depth * (focal_length[:, :, None, None] / scale_factor)
134
+
135
+
136
+ def set_sky_regions_to_max_depth(
137
+ depth: torch.Tensor,
138
+ depth_conf: torch.Tensor,
139
+ non_sky_mask: torch.Tensor,
140
+ max_depth: float = 200.0,
141
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
142
+ """
143
+ Set sky regions to maximum depth and high confidence.
144
+
145
+ Args:
146
+ depth: Depth tensor
147
+ depth_conf: Depth confidence tensor
148
+ non_sky_mask: Non-sky region mask
149
+ max_depth: Maximum depth value for sky regions
150
+
151
+ Returns:
152
+ Tuple of (updated_depth, updated_depth_conf)
153
+ """
154
+ depth = depth.clone()
155
+
156
+ # Set sky regions to max depth and high confidence
157
+ depth[~non_sky_mask] = max_depth
158
+ if depth_conf is not None:
159
+ depth_conf = depth_conf.clone()
160
+ depth_conf[~non_sky_mask] = 1.0
161
+ return depth, depth_conf
162
+ else:
163
+ return depth, None
multivue/cameras/source/src/depth_anything_3/utils/api_helpers.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+
3
+
4
+ def parse_scalar(s):
5
+ if not isinstance(s, str):
6
+ return s
7
+ t = s.strip()
8
+ l = t.lower()
9
+ if l == "true":
10
+ return True
11
+ if l == "false":
12
+ return False
13
+ if l in ("none", "null"):
14
+ return None
15
+ try:
16
+ return int(t, 10)
17
+ except Exception:
18
+ pass
19
+ try:
20
+ return float(t)
21
+ except Exception:
22
+ return s
23
+
24
+
25
+ def fn_kv_csv(s: str) -> dict[str, dict[str, object]]:
26
+ """
27
+ Parse a string of comma-separated triplets: fn:key:value
28
+
29
+ Returns:
30
+ dict[fn_name] -> dict[key] = parsed_value
31
+
32
+ Example:
33
+ "fn1:width:1920,fn1:height:1080,fn2:quality:0.8"
34
+ -> {"fn1": {"width": 1920, "height": 1080}, "fn2": {"quality": 0.8}}
35
+ """
36
+ result: dict[str, dict[str, object]] = {}
37
+ if not s:
38
+ return result
39
+
40
+ for item in s.split(","):
41
+ if not item:
42
+ continue
43
+ parts = item.split(":", 2) # allow value to contain ":" beyond first two separators
44
+ if len(parts) < 3:
45
+ raise argparse.ArgumentTypeError(f"Bad item '{item}', expected FN:KEY:VALUE")
46
+ fn, key, raw_val = parts[0], parts[1], parts[2]
47
+ # If you need to allow colons in values, join leftover parts:
48
+ # fn, key, raw_val = parts[0], parts[1], ":".join(parts[2:])
49
+
50
+ if not fn:
51
+ raise argparse.ArgumentTypeError(f"Bad item '{item}': empty function name")
52
+ if not key:
53
+ raise argparse.ArgumentTypeError(f"Bad item '{item}': empty key")
54
+
55
+ val = parse_scalar(raw_val)
56
+ bucket = result.setdefault(fn, {})
57
+ bucket[key] = val
58
+ return result
multivue/cameras/source/src/depth_anything_3/utils/camera_trj_helpers.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import cv2
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn.functional as F
19
+ from einops import einsum, rearrange, reduce
20
+
21
+ try:
22
+ from scipy.spatial.transform import Rotation as R
23
+ except ImportError:
24
+ from depth_anything_3.utils.logger import logger
25
+
26
+ logger.warn("Dependency 'scipy' not found. Required for interpolating camera trajectory.")
27
+
28
+ from depth_anything_3.utils.geometry import as_homogeneous
29
+
30
+
31
+ @torch.no_grad()
32
+ def render_stabilization_path(poses, k_size=45):
33
+ """Rendering stabilized camera path.
34
+ poses: [batch, 4, 4] or [batch, 3, 4],
35
+ return:
36
+ smooth path: [batch 4 4]"""
37
+ num_frames = poses.shape[0]
38
+ device = poses.device
39
+ dtype = poses.dtype
40
+
41
+ # Early exit for trivial cases
42
+ if num_frames <= 1:
43
+ return as_homogeneous(poses)
44
+
45
+ # Make k_size safe: positive odd and not larger than num_frames
46
+ # 1) Ensure odd
47
+ if k_size < 1:
48
+ k_size = 1
49
+ if k_size % 2 == 0:
50
+ k_size += 1
51
+ # 2) Cap to num_frames (keep odd)
52
+ max_odd = num_frames if (num_frames % 2 == 1) else (num_frames - 1)
53
+ if max_odd < 1:
54
+ max_odd = 1 # covers num_frames == 0 theoretically
55
+ k_size = min(k_size, max_odd)
56
+ # 3) enforce a minimum of 3 when possible (for better smoothing)
57
+ if num_frames >= 3 and k_size < 3:
58
+ k_size = 3
59
+
60
+ input_poses = []
61
+ for i in range(num_frames):
62
+ input_poses.append(
63
+ torch.cat([poses[i, :3, 0:1], poses[i, :3, 1:2], poses[i, :3, 3:4]], dim=-1)
64
+ )
65
+ input_poses = torch.stack(input_poses) # (num_frames, 3, 3)
66
+
67
+ # Prepare Gaussian kernel
68
+ gaussian_kernel = cv2.getGaussianKernel(ksize=k_size, sigma=-1).astype(np.float32).squeeze()
69
+ gaussian_kernel = torch.tensor(gaussian_kernel, dtype=dtype, device=device).view(1, 1, -1)
70
+ pad = k_size // 2
71
+
72
+ output_vectors = []
73
+ for idx in range(3): # For r1, r2, t
74
+ vec = (
75
+ input_poses[:, :, idx].T.unsqueeze(0).unsqueeze(0)
76
+ ) # (1, 1, 3, num_frames) -> (1, 1, 3, num_frames)
77
+ # But actually, we want (batch=3, channel=1, width=num_frames)
78
+ # So:
79
+ vec = input_poses[:, :, idx].T.unsqueeze(1) # (3, 1, num_frames)
80
+ vec_padded = F.pad(vec, (pad, pad), mode="reflect")
81
+ filtered = F.conv1d(vec_padded, gaussian_kernel)
82
+ output_vectors.append(filtered.squeeze(1).T) # (num_frames, 3)
83
+
84
+ output_r1, output_r2, output_t = output_vectors # Each is (num_frames, 3)
85
+
86
+ # Normalize r1 and r2
87
+ output_r1 = output_r1 / output_r1.norm(dim=-1, keepdim=True)
88
+ output_r2 = output_r2 / output_r2.norm(dim=-1, keepdim=True)
89
+
90
+ output_poses = []
91
+ for i in range(num_frames):
92
+ output_r3 = torch.linalg.cross(output_r1[i], output_r2[i])
93
+ render_pose = torch.cat(
94
+ [
95
+ output_r1[i].unsqueeze(-1),
96
+ output_r2[i].unsqueeze(-1),
97
+ output_r3.unsqueeze(-1),
98
+ output_t[i].unsqueeze(-1),
99
+ ],
100
+ dim=-1,
101
+ )
102
+ output_poses.append(render_pose[:3, :])
103
+ output_poses = as_homogeneous(torch.stack(output_poses, dim=0))
104
+
105
+ return output_poses
106
+
107
+
108
+ @torch.no_grad()
109
+ def render_wander_path(
110
+ cam2world: torch.Tensor,
111
+ intrinsic: torch.Tensor,
112
+ h: int,
113
+ w: int,
114
+ num_frames: int = 120,
115
+ max_disp: float = 48.0,
116
+ ):
117
+ device, dtype = cam2world.device, cam2world.dtype
118
+ fx = intrinsic[0, 0] * w
119
+ r = max_disp / fx
120
+ th = torch.linspace(0, 2.0 * torch.pi, steps=num_frames, device=device, dtype=dtype)
121
+ x = r * torch.sin(th)
122
+ yz = r * torch.cos(th) / 3.0
123
+ T = torch.eye(4, device=device, dtype=dtype).unsqueeze(0).repeat(num_frames, 1, 1)
124
+ T[:, :3, 3] = torch.stack([x, yz, yz], dim=-1) * -1.0
125
+ c2ws = cam2world.unsqueeze(0) @ T
126
+ # Start at reference pose and end back at reference pose
127
+ c2ws = torch.cat([cam2world.unsqueeze(0), c2ws, cam2world.unsqueeze(0)], dim=0)
128
+ Ks = intrinsic.unsqueeze(0).repeat(c2ws.shape[0], 1, 1)
129
+ return c2ws, Ks
130
+
131
+
132
+ @torch.no_grad()
133
+ def render_dolly_zoom_path(
134
+ cam2world: torch.Tensor,
135
+ intrinsic: torch.Tensor,
136
+ h: int,
137
+ w: int,
138
+ num_frames: int = 120,
139
+ max_disp: float = 0.1,
140
+ D_focus: float = 10.0,
141
+ ):
142
+ device, dtype = cam2world.device, cam2world.dtype
143
+ fx0, fy0 = intrinsic[0, 0] * w, intrinsic[1, 1] * h
144
+ t = torch.linspace(0.0, 2.0, steps=num_frames, device=device, dtype=dtype)
145
+ z = 0.5 * (1.0 - torch.cos(torch.pi * t)) * max_disp
146
+ T = torch.eye(4, device=device, dtype=dtype).unsqueeze(0).repeat(num_frames, 1, 1)
147
+ T[:, 2, 3] = -z
148
+ c2ws = cam2world.unsqueeze(0) @ T
149
+ Df = torch.as_tensor(D_focus, device=device, dtype=dtype)
150
+ scale = (Df / (Df + z)).clamp(min=1e-6)
151
+ Ks = intrinsic.unsqueeze(0).repeat(num_frames, 1, 1)
152
+ Ks[:, 0, 0] = (fx0 * scale) / w
153
+ Ks[:, 1, 1] = (fy0 * scale) / h
154
+ return c2ws, Ks
155
+
156
+
157
+ @torch.no_grad()
158
+ def interpolate_intrinsics(
159
+ initial: torch.Tensor, # "*#batch 3 3"
160
+ final: torch.Tensor, # "*#batch 3 3"
161
+ t: torch.Tensor, # " time_step"
162
+ ) -> torch.Tensor: # "*batch time_step 3 3"
163
+ initial = rearrange(initial, "... i j -> ... () i j")
164
+ final = rearrange(final, "... i j -> ... () i j")
165
+ t = rearrange(t, "t -> t () ()")
166
+ return initial + (final - initial) * t
167
+
168
+
169
+ def intersect_rays(
170
+ a_origins: torch.Tensor, # "*#batch dim"
171
+ a_directions: torch.Tensor, # "*#batch dim"
172
+ b_origins: torch.Tensor, # "*#batch dim"
173
+ b_directions: torch.Tensor, # "*#batch dim"
174
+ ) -> torch.Tensor: # "*batch dim"
175
+ """Compute the least-squares intersection of rays. Uses the math from here:
176
+ https://math.stackexchange.com/a/1762491/286022
177
+ """
178
+
179
+ # Broadcast and stack the tensors.
180
+ a_origins, a_directions, b_origins, b_directions = torch.broadcast_tensors(
181
+ a_origins, a_directions, b_origins, b_directions
182
+ )
183
+ origins = torch.stack((a_origins, b_origins), dim=-2)
184
+ directions = torch.stack((a_directions, b_directions), dim=-2)
185
+
186
+ # Compute n_i * n_i^T - eye(3) from the equation.
187
+ n = einsum(directions, directions, "... n i, ... n j -> ... n i j")
188
+ n = n - torch.eye(3, dtype=origins.dtype, device=origins.device)
189
+
190
+ # Compute the left-hand side of the equation.
191
+ lhs = reduce(n, "... n i j -> ... i j", "sum")
192
+
193
+ # Compute the right-hand side of the equation.
194
+ rhs = einsum(n, origins, "... n i j, ... n j -> ... n i")
195
+ rhs = reduce(rhs, "... n i -> ... i", "sum")
196
+
197
+ # Left-matrix-multiply both sides by the inverse of lhs to find p.
198
+ return torch.linalg.lstsq(lhs, rhs).solution
199
+
200
+
201
+ def normalize(a: torch.Tensor) -> torch.Tensor: # "*#batch dim" -> "*#batch dim"
202
+ return a / a.norm(dim=-1, keepdim=True)
203
+
204
+
205
+ def generate_coordinate_frame(
206
+ y: torch.Tensor, # "*#batch 3"
207
+ z: torch.Tensor, # "*#batch 3"
208
+ ) -> torch.Tensor: # "*batch 3 3"
209
+ """Generate a coordinate frame given perpendicular, unit-length Y and Z vectors."""
210
+ y, z = torch.broadcast_tensors(y, z)
211
+ return torch.stack([y.cross(z, dim=-1), y, z], dim=-1)
212
+
213
+
214
+ def generate_rotation_coordinate_frame(
215
+ a: torch.Tensor, # "*#batch 3"
216
+ b: torch.Tensor, # "*#batch 3"
217
+ eps: float = 1e-4,
218
+ ) -> torch.Tensor: # "*batch 3 3"
219
+ """Generate a coordinate frame where the Y direction is normal to the plane defined
220
+ by unit vectors a and b. The other axes are arbitrary."""
221
+ device = a.device
222
+
223
+ # Replace every entry in b that's parallel to the corresponding entry in a with an
224
+ # arbitrary vector.
225
+ b = b.detach().clone()
226
+ parallel = (einsum(a, b, "... i, ... i -> ...").abs() - 1).abs() < eps
227
+ b[parallel] = torch.tensor([0, 0, 1], dtype=b.dtype, device=device)
228
+ parallel = (einsum(a, b, "... i, ... i -> ...").abs() - 1).abs() < eps
229
+ b[parallel] = torch.tensor([0, 1, 0], dtype=b.dtype, device=device)
230
+
231
+ # Generate the coordinate frame. The initial cross product defines the plane.
232
+ return generate_coordinate_frame(normalize(torch.linalg.cross(a, b)), a)
233
+
234
+
235
+ def matrix_to_euler(
236
+ rotations: torch.Tensor, # "*batch 3 3"
237
+ pattern: str,
238
+ ) -> torch.Tensor: # "*batch 3"
239
+ *batch, _, _ = rotations.shape
240
+ rotations = rotations.reshape(-1, 3, 3)
241
+ angles_np = R.from_matrix(rotations.detach().cpu().numpy()).as_euler(pattern)
242
+ rotations = torch.tensor(angles_np, dtype=rotations.dtype, device=rotations.device)
243
+ return rotations.reshape(*batch, 3)
244
+
245
+
246
+ def euler_to_matrix(
247
+ rotations: torch.Tensor, # "*batch 3"
248
+ pattern: str,
249
+ ) -> torch.Tensor: # "*batch 3 3"
250
+ *batch, _ = rotations.shape
251
+ rotations = rotations.reshape(-1, 3)
252
+ matrix_np = R.from_euler(pattern, rotations.detach().cpu().numpy()).as_matrix()
253
+ rotations = torch.tensor(matrix_np, dtype=rotations.dtype, device=rotations.device)
254
+ return rotations.reshape(*batch, 3, 3)
255
+
256
+
257
+ def extrinsics_to_pivot_parameters(
258
+ extrinsics: torch.Tensor, # "*#batch 4 4"
259
+ pivot_coordinate_frame: torch.Tensor, # "*#batch 3 3"
260
+ pivot_point: torch.Tensor, # "*#batch 3"
261
+ ) -> torch.Tensor: # "*batch 5"
262
+ """Convert the extrinsics to a representation with 5 degrees of freedom:
263
+ 1. Distance from pivot point in the "X" (look cross pivot axis) direction.
264
+ 2. Distance from pivot point in the "Y" (pivot axis) direction.
265
+ 3. Distance from pivot point in the Z (look) direction
266
+ 4. Angle in plane
267
+ 5. Twist (rotation not in plane)
268
+ """
269
+
270
+ # The pivot coordinate frame's Z axis is normal to the plane.
271
+ pivot_axis = pivot_coordinate_frame[..., :, 1]
272
+
273
+ # Compute the translation elements of the pivot parametrization.
274
+ translation_frame = generate_coordinate_frame(pivot_axis, extrinsics[..., :3, 2])
275
+ origin = extrinsics[..., :3, 3]
276
+ delta = pivot_point - origin
277
+ translation = einsum(translation_frame, delta, "... i j, ... i -> ... j")
278
+
279
+ # Add the rotation elements of the pivot parametrization.
280
+ inverted = pivot_coordinate_frame.inverse() @ extrinsics[..., :3, :3]
281
+ y, _, z = matrix_to_euler(inverted, "YXZ").unbind(dim=-1)
282
+
283
+ return torch.cat([translation, y[..., None], z[..., None]], dim=-1)
284
+
285
+
286
+ def pivot_parameters_to_extrinsics(
287
+ parameters: torch.Tensor, # "*#batch 5"
288
+ pivot_coordinate_frame: torch.Tensor, # "*#batch 3 3"
289
+ pivot_point: torch.Tensor, # "*#batch 3"
290
+ ) -> torch.Tensor: # "*batch 4 4"
291
+ translation, y, z = parameters.split((3, 1, 1), dim=-1)
292
+
293
+ euler = torch.cat((y, torch.zeros_like(y), z), dim=-1)
294
+ rotation = pivot_coordinate_frame @ euler_to_matrix(euler, "YXZ")
295
+
296
+ # The pivot coordinate frame's Z axis is normal to the plane.
297
+ pivot_axis = pivot_coordinate_frame[..., :, 1]
298
+
299
+ translation_frame = generate_coordinate_frame(pivot_axis, rotation[..., :3, 2])
300
+ delta = einsum(translation_frame, translation, "... i j, ... j -> ... i")
301
+ origin = pivot_point - delta
302
+
303
+ *batch, _ = origin.shape
304
+ extrinsics = torch.eye(4, dtype=parameters.dtype, device=parameters.device)
305
+ extrinsics = extrinsics.broadcast_to((*batch, 4, 4)).clone()
306
+ extrinsics[..., 3, 3] = 1
307
+ extrinsics[..., :3, :3] = rotation
308
+ extrinsics[..., :3, 3] = origin
309
+ return extrinsics
310
+
311
+
312
+ def interpolate_circular(
313
+ a: torch.Tensor, # "*#batch"
314
+ b: torch.Tensor, # "*#batch"
315
+ t: torch.Tensor, # "*#batch"
316
+ ) -> torch.Tensor: # " *batch"
317
+ a, b, t = torch.broadcast_tensors(a, b, t)
318
+
319
+ tau = 2 * torch.pi
320
+ a = a % tau
321
+ b = b % tau
322
+
323
+ # Consider piecewise edge cases.
324
+ d = (b - a).abs()
325
+ a_left = a - tau
326
+ d_left = (b - a_left).abs()
327
+ a_right = a + tau
328
+ d_right = (b - a_right).abs()
329
+ use_d = (d < d_left) & (d < d_right)
330
+ use_d_left = (d_left < d_right) & (~use_d)
331
+ use_d_right = (~use_d) & (~use_d_left)
332
+
333
+ result = a + (b - a) * t
334
+ result[use_d_left] = (a_left + (b - a_left) * t)[use_d_left]
335
+ result[use_d_right] = (a_right + (b - a_right) * t)[use_d_right]
336
+
337
+ return result
338
+
339
+
340
+ def interpolate_pivot_parameters(
341
+ initial: torch.Tensor, # "*#batch 5"
342
+ final: torch.Tensor, # "*#batch 5"
343
+ t: torch.Tensor, # " time_step"
344
+ ) -> torch.Tensor: # "*batch time_step 5"
345
+ initial = rearrange(initial, "... d -> ... () d")
346
+ final = rearrange(final, "... d -> ... () d")
347
+ t = rearrange(t, "t -> t ()")
348
+ ti, ri = initial.split((3, 2), dim=-1)
349
+ tf, rf = final.split((3, 2), dim=-1)
350
+
351
+ t_lerp = ti + (tf - ti) * t
352
+ r_lerp = interpolate_circular(ri, rf, t)
353
+
354
+ return torch.cat((t_lerp, r_lerp), dim=-1)
355
+
356
+
357
+ @torch.no_grad()
358
+ def interpolate_extrinsics(
359
+ initial: torch.Tensor, # "*#batch 4 4"
360
+ final: torch.Tensor, # "*#batch 4 4"
361
+ t: torch.Tensor, # " time_step"
362
+ eps: float = 1e-4,
363
+ ) -> torch.Tensor: # "*batch time_step 4 4"
364
+ """Interpolate extrinsics by rotating around their "focus point," which is the
365
+ least-squares intersection between the look vectors of the initial and final
366
+ extrinsics.
367
+ """
368
+
369
+ initial = initial.type(torch.float64)
370
+ final = final.type(torch.float64)
371
+ t = t.type(torch.float64)
372
+
373
+ # Based on the dot product between the look vectors, pick from one of two cases:
374
+ # 1. Look vectors are parallel: interpolate about their origins' midpoint.
375
+ # 3. Look vectors aren't parallel: interpolate about their focus point.
376
+ initial_look = initial[..., :3, 2]
377
+ final_look = final[..., :3, 2]
378
+ dot_products = einsum(initial_look, final_look, "... i, ... i -> ...")
379
+ parallel_mask = (dot_products.abs() - 1).abs() < eps
380
+
381
+ # Pick focus points.
382
+ initial_origin = initial[..., :3, 3]
383
+ final_origin = final[..., :3, 3]
384
+ pivot_point = 0.5 * (initial_origin + final_origin)
385
+ pivot_point[~parallel_mask] = intersect_rays(
386
+ initial_origin[~parallel_mask],
387
+ initial_look[~parallel_mask],
388
+ final_origin[~parallel_mask],
389
+ final_look[~parallel_mask],
390
+ )
391
+
392
+ # Convert to pivot parameters.
393
+ pivot_frame = generate_rotation_coordinate_frame(initial_look, final_look, eps=eps)
394
+ initial_params = extrinsics_to_pivot_parameters(initial, pivot_frame, pivot_point)
395
+ final_params = extrinsics_to_pivot_parameters(final, pivot_frame, pivot_point)
396
+
397
+ # Interpolate the pivot parameters.
398
+ interpolated_params = interpolate_pivot_parameters(initial_params, final_params, t)
399
+
400
+ # Convert back.
401
+ return pivot_parameters_to_extrinsics(
402
+ interpolated_params.type(torch.float32),
403
+ rearrange(pivot_frame, "... i j -> ... () i j").type(torch.float32),
404
+ rearrange(pivot_point, "... xyz -> ... () xyz").type(torch.float32),
405
+ )
406
+
407
+
408
+ @torch.no_grad()
409
+ def generate_wobble_transformation(
410
+ radius: torch.Tensor, # "*#batch"
411
+ t: torch.Tensor, # " time_step"
412
+ num_rotations: int = 1,
413
+ scale_radius_with_t: bool = True,
414
+ ) -> torch.Tensor: # "*batch time_step 4 4"]:
415
+ # Generate a translation in the image plane.
416
+ tf = torch.eye(4, dtype=torch.float32, device=t.device)
417
+ tf = tf.broadcast_to((*radius.shape, t.shape[0], 4, 4)).clone()
418
+ radius = radius[..., None]
419
+ if scale_radius_with_t:
420
+ radius = radius * t
421
+ tf[..., 0, 3] = torch.sin(2 * torch.pi * num_rotations * t) * radius
422
+ tf[..., 1, 3] = -torch.cos(2 * torch.pi * num_rotations * t) * radius
423
+ return tf
424
+
425
+
426
+ @torch.no_grad()
427
+ def render_wobble_inter_path(
428
+ cam2world: torch.Tensor, intr_normed: torch.Tensor, inter_len: int, n_skip: int = 3
429
+ ):
430
+ """
431
+ cam2world: [batch, 4, 4],
432
+ intr_normed: [batch, 3, 3]
433
+ """
434
+ frame_per_round = n_skip * inter_len
435
+ num_rotations = 1
436
+
437
+ t = torch.linspace(0, 1, frame_per_round, dtype=torch.float32, device=cam2world.device)
438
+ # t = (torch.cos(torch.pi * (t + 1)) + 1) / 2
439
+ tgt_c2w_b = []
440
+ tgt_intr_b = []
441
+ for b_idx in range(cam2world.shape[0]):
442
+ tgt_c2w = []
443
+ tgt_intr = []
444
+ for cur_idx in range(0, cam2world.shape[1] - n_skip, n_skip):
445
+ origin_a = cam2world[b_idx, cur_idx, :3, 3]
446
+ origin_b = cam2world[b_idx, cur_idx + n_skip, :3, 3]
447
+ delta = (origin_a - origin_b).norm(dim=-1)
448
+ if cur_idx == 0:
449
+ delta_prev = delta
450
+ else:
451
+ delta = (delta_prev + delta) / 2
452
+ delta_prev = delta
453
+ tf = generate_wobble_transformation(
454
+ radius=delta * 0.5,
455
+ t=t,
456
+ num_rotations=num_rotations,
457
+ scale_radius_with_t=False,
458
+ )
459
+ cur_extrs = (
460
+ interpolate_extrinsics(
461
+ cam2world[b_idx, cur_idx],
462
+ cam2world[b_idx, cur_idx + n_skip],
463
+ t,
464
+ )
465
+ @ tf
466
+ )
467
+ tgt_c2w.append(cur_extrs[(0 if cur_idx == 0 else 1) :])
468
+ tgt_intr.append(
469
+ interpolate_intrinsics(
470
+ intr_normed[b_idx, cur_idx],
471
+ intr_normed[b_idx, cur_idx + n_skip],
472
+ t,
473
+ )[(0 if cur_idx == 0 else 1) :]
474
+ )
475
+ tgt_c2w_b.append(torch.cat(tgt_c2w))
476
+ tgt_intr_b.append(torch.cat(tgt_intr))
477
+ tgt_c2w = torch.stack(tgt_c2w_b) # b v 4 4
478
+ tgt_intr = torch.stack(tgt_intr_b) # b v 3 3
479
+ return tgt_c2w, tgt_intr
multivue/cameras/source/src/depth_anything_3/utils/constants.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ DEFAULT_MODEL = "depth-anything/DA3NESTED-GIANT-LARGE-1.1"
16
+ DEFAULT_EXPORT_DIR = "workspace/gallery/scene"
17
+ DEFAULT_GALLERY_DIR = "workspace/gallery"
18
+ DEFAULT_GRADIO_DIR = "workspace/gradio"
19
+ THRESH_FOR_REF_SELECTION = 3
20
+
21
+ # =============================================================================
22
+ # Benchmark Evaluation Constants
23
+ # =============================================================================
24
+
25
+ # Default evaluation workspace directory
26
+ DEFAULT_EVAL_WORKSPACE = "workspace/evaluation"
27
+
28
+ # Default reference view selection strategy for evaluation
29
+ # Use "first" for consistent and reproducible evaluation results
30
+ # Other options: "saddle_balanced", "auto", "mid"
31
+ EVAL_REF_VIEW_STRATEGY = "first"
32
+
33
+ # -----------------------------------------------------------------------------
34
+ # DTU Dataset Configuration
35
+ # Reference: https://roboimagedata.compute.dtu.dk/
36
+ # Note: DepthAnything3 was never trained on any images from DTU.
37
+ # -----------------------------------------------------------------------------
38
+
39
+ # Root directory for DTU evaluation data (MVSNet format)
40
+ # Download from: https://drive.google.com/file/d/1rX0EXlUL4prRxrRu2DgLJv2j7-tpUD4D/view
41
+ DTU_EVAL_DATA_ROOT = "workspace/benchmark_dataset/dtu"
42
+
43
+ # List of DTU evaluation scenes
44
+ DTU_SCENES = [
45
+ "scan1",
46
+ "scan4",
47
+ "scan9",
48
+ "scan10",
49
+ "scan11",
50
+ "scan12",
51
+ "scan13",
52
+ "scan15",
53
+ "scan23",
54
+ "scan24",
55
+ "scan29",
56
+ "scan32",
57
+ "scan33",
58
+ "scan34",
59
+ "scan48",
60
+ "scan49",
61
+ "scan62",
62
+ "scan75",
63
+ "scan77",
64
+ "scan110",
65
+ "scan114",
66
+ "scan118",
67
+ ]
68
+
69
+ # Point cloud fusion hyperparameters
70
+ DTU_DIST_THRESH = 0.2 # Distance threshold for geometric consistency (mm)
71
+ DTU_NUM_CONSIST = 4 # Minimum number of consistent views for a point
72
+ DTU_MAX_POINTS = 4_000_000 # Maximum points in fused point cloud
73
+
74
+ # 3D reconstruction evaluation hyperparameters
75
+ DTU_DOWN_DENSE = 0.2 # Downsample density for evaluation (mm)
76
+ DTU_PATCH_SIZE = 60 # Patch size for boundary handling
77
+ DTU_MAX_DIST = 20 # Outlier threshold for accuracy/completeness (mm)
78
+
79
+ # -----------------------------------------------------------------------------
80
+ # DTU-64 Dataset Configuration (Pose Evaluation Only)
81
+ # This is a subset of DTU with 64 images per scene for pose evaluation.
82
+ # Note: This dataset is ONLY for pose evaluation, not 3D reconstruction.
83
+ # -----------------------------------------------------------------------------
84
+
85
+ # Root directory for DTU-64 evaluation data
86
+ DTU64_EVAL_DATA_ROOT = "workspace/benchmark_dataset/dtu64"
87
+ DTU64_CAMERA_ROOT = "workspace/benchmark_dataset/dtu64/Cameras"
88
+
89
+ # List of DTU-64 evaluation scenes (13 scenes)
90
+ DTU64_SCENES = [
91
+ "scan105",
92
+ "scan114",
93
+ "scan118",
94
+ "scan122",
95
+ "scan24",
96
+ "scan37",
97
+ "scan40",
98
+ "scan55",
99
+ "scan63",
100
+ "scan65",
101
+ "scan69",
102
+ "scan83",
103
+ "scan97",
104
+ ]
105
+
106
+ # -----------------------------------------------------------------------------
107
+ # ETH3D Dataset Configuration
108
+ # Reference: https://www.eth3d.net/
109
+ # High-resolution multi-view stereo benchmark with laser-scanned ground truth.
110
+ # Note: DepthAnything3 was never trained on any images from ETH3D.
111
+ # -----------------------------------------------------------------------------
112
+
113
+ # Root directory for ETH3D evaluation data
114
+ ETH3D_EVAL_DATA_ROOT = "workspace/benchmark_dataset/eth3d"
115
+
116
+ # List of ETH3D evaluation scenes (indoor and outdoor)
117
+ ETH3D_SCENES = [
118
+ "courtyard",
119
+ "electro",
120
+ "kicker",
121
+ "pipes",
122
+ "relief",
123
+ # "terrace", # Excluded: known issues
124
+ "delivery_area",
125
+ "facade",
126
+ # "meadow", # Excluded: known issues
127
+ "office",
128
+ "playground",
129
+ "relief_2",
130
+ "terrains",
131
+ ]
132
+
133
+ # Images to filter out (known problematic views per scene)
134
+ ETH3D_FILTER_KEYS = {
135
+ "delivery_area": ["711.JPG", "712.JPG", "713.JPG", "714.JPG"],
136
+ "electro": ["9289.JPG", "9290.JPG", "9291.JPG", "9292.JPG", "9293.JPG", "9298.JPG"],
137
+ "playground": ["587.JPG", "588.JPG", "589.JPG", "590.JPG", "591.JPG", "592.JPG"],
138
+ "relief": [
139
+ "427.JPG", "428.JPG", "429.JPG", "430.JPG", "431.JPG", "432.JPG",
140
+ "433.JPG", "434.JPG", "435.JPG", "436.JPG", "437.JPG", "438.JPG",
141
+ ],
142
+ "relief_2": [
143
+ "458.JPG", "459.JPG", "460.JPG", "461.JPG", "462.JPG", "463.JPG",
144
+ "464.JPG", "465.JPG", "466.JPG", "467.JPG", "468.JPG",
145
+ ],
146
+ }
147
+
148
+ # TSDF fusion hyperparameters (scaled for outdoor scenes)
149
+ ETH3D_VOXEL_LENGTH = 4.0 / 512.0 * 5 # Voxel size for TSDF (meters)
150
+ ETH3D_SDF_TRUNC = 0.04 * 5 # SDF truncation distance (meters)
151
+ ETH3D_MAX_DEPTH = 100000.0 # Maximum depth for integration (effectively no truncation)
152
+
153
+ # Point cloud sampling
154
+ ETH3D_SAMPLING_NUMBER = 1_000_000 # Number of points to sample from mesh
155
+
156
+ # 3D reconstruction evaluation hyperparameters
157
+ ETH3D_EVAL_THRESHOLD = 0.05 * 5 # Distance threshold for precision/recall (meters)
158
+ ETH3D_DOWN_SAMPLE = 4.0 / 512.0 * 5 # Voxel size for evaluation downsampling (meters)
159
+
160
+
161
+ # ==============================================================================
162
+ # 7Scenes Dataset Configuration
163
+ # ==============================================================================
164
+ # Reference: https://www.microsoft.com/en-us/research/project/rgb-d-dataset-7-scenes/
165
+ # Note: Indoor RGB-D dataset with ground truth poses and meshes.
166
+
167
+ # Root directory for 7Scenes evaluation data
168
+ SEVENSCENES_EVAL_DATA_ROOT = "workspace/benchmark_dataset/7scenes"
169
+
170
+ # List of 7Scenes evaluation scenes
171
+ SEVENSCENES_SCENES = [
172
+ "chess",
173
+ "fire",
174
+ "heads",
175
+ "office",
176
+ "pumpkin",
177
+ "redkitchen",
178
+ "stairs",
179
+ ]
180
+
181
+ # Fixed camera intrinsics for 7Scenes (all images share same intrinsics)
182
+ SEVENSCENES_FX = 585.0
183
+ SEVENSCENES_FY = 585.0
184
+ SEVENSCENES_CX = 320.0
185
+ SEVENSCENES_CY = 240.0
186
+
187
+ # TSDF fusion hyperparameters (indoor scenes, smaller voxels)
188
+ SEVENSCENES_VOXEL_LENGTH = 4.0 / 512.0 # Voxel size for TSDF (meters)
189
+ SEVENSCENES_SDF_TRUNC = 0.04 # SDF truncation distance (meters)
190
+ SEVENSCENES_MAX_DEPTH = 1000000.0 # Maximum depth for integration (no truncation)
191
+
192
+ # Point cloud sampling
193
+ SEVENSCENES_SAMPLING_NUMBER = 1_000_000 # Number of points to sample from mesh
194
+
195
+ # 3D reconstruction evaluation hyperparameters
196
+ SEVENSCENES_EVAL_THRESHOLD = 0.05 # Distance threshold for precision/recall (meters)
197
+ SEVENSCENES_DOWN_SAMPLE = 4.0 / 512.0 # Voxel size for evaluation downsampling (meters)
198
+
199
+
200
+ # ==============================================================================
201
+ # ScanNet++ Dataset Configuration
202
+ # ==============================================================================
203
+ # Reference: https://kaldir.vc.in.tum.de/scannetpp/
204
+ # Note: High-quality indoor RGB-D dataset with iPhone and DSLR images.
205
+
206
+ # Root directory for ScanNet++ evaluation data
207
+ SCANNETPP_EVAL_DATA_ROOT = "workspace/benchmark_dataset/scannetpp"
208
+
209
+ # List of ScanNet++ evaluation scenes
210
+ SCANNETPP_SCENES = [
211
+ "09c1414f1b",
212
+ "1ada7a0617",
213
+ "40aec5fffa",
214
+ "3e8bba0176",
215
+ "acd95847c5",
216
+ "578511c8a9",
217
+ "5f99900f09",
218
+ "c4c04e6d6c",
219
+ "f3d64c30f8",
220
+ "7bc286c1b6",
221
+ "c5439f4607",
222
+ "286b55a2bf",
223
+ "fb5a96b1a2",
224
+ "7831862f02",
225
+ "38d58a7a31",
226
+ "bde1e479ad",
227
+ "9071e139d9",
228
+ "21d970d8de",
229
+ "bcd2436daf",
230
+ "cc5237fd77",
231
+ ]
232
+
233
+ # Input resolution for ScanNet++ (after undistortion and resize)
234
+ SCANNETPP_INPUT_H = 768
235
+ SCANNETPP_INPUT_W = 1024
236
+
237
+ # TSDF fusion hyperparameters (indoor scenes)
238
+ SCANNETPP_VOXEL_LENGTH = 0.02 # Voxel size for TSDF (meters)
239
+ SCANNETPP_SDF_TRUNC = 0.15 # SDF truncation distance (meters)
240
+ SCANNETPP_MAX_DEPTH = 5.0 # Maximum depth for integration (meters)
241
+
242
+ # Point cloud sampling
243
+ SCANNETPP_SAMPLING_NUMBER = 1_000_000 # Number of points to sample from mesh
244
+
245
+ # 3D reconstruction evaluation hyperparameters
246
+ SCANNETPP_EVAL_THRESHOLD = 0.05 # Distance threshold for precision/recall (meters)
247
+ SCANNETPP_DOWN_SAMPLE = 0.02 # Voxel size for evaluation downsampling (meters)
248
+
249
+
250
+ # ==============================================================================
251
+ # HiRoom Dataset Configuration
252
+ # ==============================================================================
253
+ # Note: Indoor RGB-D dataset.
254
+
255
+ # Root directory for HiRoom evaluation data
256
+ HIROOM_EVAL_DATA_ROOT = "workspace/benchmark_dataset/hiroom/data"
257
+ HIROOM_GT_ROOT_PATH = "workspace/benchmark_dataset/hiroom/fused_pcd"
258
+ HIROOM_SCENE_LIST_PATH = "workspace/benchmark_dataset/hiroom/selected_scene_list_val.txt"
259
+
260
+ # TSDF fusion hyperparameters (indoor scenes)
261
+ HIROOM_VOXEL_LENGTH = 4.0 / 512.0 # Voxel size for TSDF (meters)
262
+ HIROOM_SDF_TRUNC = 0.04 # SDF truncation distance (meters)
263
+ HIROOM_MAX_DEPTH = 10000.0 # Maximum depth for integration (no truncation)
264
+
265
+ # Point cloud sampling
266
+ HIROOM_SAMPLING_NUMBER = 1_000_000 # Number of points to sample from mesh
267
+
268
+ # 3D reconstruction evaluation hyperparameters
269
+ HIROOM_EVAL_THRESHOLD = 0.05 # Distance threshold for precision/recall (meters)
270
+ HIROOM_DOWN_SAMPLE = 4.0 / 512.0 # Voxel size for evaluation downsampling (meters)
multivue/cameras/source/src/depth_anything_3/utils/export/__init__.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from depth_anything_3.specs import Prediction
16
+ from depth_anything_3.utils.export.gs import export_to_gs_ply, export_to_gs_video
17
+
18
+ from .colmap import export_to_colmap
19
+ from .depth_vis import export_to_depth_vis
20
+ from .feat_vis import export_to_feat_vis
21
+ from .glb import export_to_glb
22
+ from .npz import export_to_mini_npz, export_to_npz
23
+
24
+ # Canonical set of export formats this dispatcher understands. Treat this as the
25
+ # server-trusted enum for any request-facing validation (e.g. the HTTP backend) instead
26
+ # of letting a client-supplied export_format string reach the dispatcher unchecked.
27
+ SUPPORTED_EXPORT_FORMATS = frozenset(
28
+ {
29
+ "glb",
30
+ "mini_npz",
31
+ "npz",
32
+ "feat_vis",
33
+ "depth_vis",
34
+ "gs_ply",
35
+ "gs_video",
36
+ "colmap",
37
+ }
38
+ )
39
+
40
+
41
+ def export(
42
+ prediction: Prediction,
43
+ export_format: str,
44
+ export_dir: str,
45
+ **kwargs,
46
+ ):
47
+ if "-" in export_format:
48
+ export_formats = export_format.split("-")
49
+ for export_format in export_formats:
50
+ export(prediction, export_format, export_dir, **kwargs)
51
+ return # Prevent falling through to single-format handling
52
+
53
+ if export_format == "glb":
54
+ export_to_glb(prediction, export_dir, **kwargs.get(export_format, {}))
55
+ elif export_format == "mini_npz":
56
+ export_to_mini_npz(prediction, export_dir)
57
+ elif export_format == "npz":
58
+ export_to_npz(prediction, export_dir)
59
+ elif export_format == "feat_vis":
60
+ export_to_feat_vis(prediction, export_dir, **kwargs.get(export_format, {}))
61
+ elif export_format == "depth_vis":
62
+ export_to_depth_vis(prediction, export_dir)
63
+ elif export_format == "gs_ply":
64
+ export_to_gs_ply(prediction, export_dir, **kwargs.get(export_format, {}))
65
+ elif export_format == "gs_video":
66
+ export_to_gs_video(prediction, export_dir, **kwargs.get(export_format, {}))
67
+ elif export_format == "colmap":
68
+ export_to_colmap(prediction, export_dir, **kwargs.get(export_format, {}))
69
+ else:
70
+ raise ValueError(f"Unsupported export format: {export_format}")
71
+
72
+
73
+ __all__ = [
74
+ export,
75
+ ]
multivue/cameras/source/src/depth_anything_3/utils/export/colmap.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ import pycolmap
17
+ import cv2 as cv
18
+ import numpy as np
19
+
20
+ from PIL import Image
21
+
22
+ from depth_anything_3.specs import Prediction
23
+ from depth_anything_3.utils.logger import logger
24
+
25
+ from .glb import _depths_to_world_points_with_colors
26
+
27
+
28
+ def export_to_colmap(
29
+ prediction: Prediction,
30
+ export_dir: str,
31
+ image_paths: list[str],
32
+ conf_thresh_percentile: float = 40.0,
33
+ process_res_method: str = "upper_bound_resize",
34
+ ) -> None:
35
+ # 1. Data preparation
36
+ conf_thresh = np.percentile(prediction.conf, conf_thresh_percentile)
37
+ points, colors = _depths_to_world_points_with_colors(
38
+ prediction.depth,
39
+ prediction.intrinsics,
40
+ prediction.extrinsics, # w2c
41
+ prediction.processed_images,
42
+ prediction.conf,
43
+ conf_thresh,
44
+ )
45
+ num_points = len(points)
46
+ logger.info(f"Exporting to COLMAP with {num_points} points")
47
+ num_frames = len(prediction.processed_images)
48
+ h, w = prediction.processed_images.shape[1:3]
49
+ points_xyf = _create_xyf(num_frames, h, w)
50
+ points_xyf = points_xyf[prediction.conf >= conf_thresh]
51
+
52
+ # 2. Set Reconstruction
53
+ reconstruction = pycolmap.Reconstruction()
54
+
55
+ point3d_ids = []
56
+ for vidx in range(num_points):
57
+ point3d_id = reconstruction.add_point3D(points[vidx], pycolmap.Track(), colors[vidx])
58
+ point3d_ids.append(point3d_id)
59
+
60
+ for fidx in range(num_frames):
61
+ orig_w, orig_h = Image.open(image_paths[fidx]).size
62
+
63
+ intrinsic = prediction.intrinsics[fidx]
64
+ if process_res_method.endswith("resize"):
65
+ intrinsic[:1] *= orig_w / w
66
+ intrinsic[1:2] *= orig_h / h
67
+ elif process_res_method == "crop":
68
+ raise NotImplementedError("COLMAP export for crop method is not implemented")
69
+ else:
70
+ raise ValueError(f"Unknown process_res_method: {process_res_method}")
71
+
72
+ pycolmap_intri = np.array(
73
+ [intrinsic[0, 0], intrinsic[1, 1], intrinsic[0, 2], intrinsic[1, 2]]
74
+ )
75
+
76
+ extrinsic = prediction.extrinsics[fidx]
77
+ cam_from_world = pycolmap.Rigid3d(pycolmap.Rotation3d(extrinsic[:3, :3]), extrinsic[:3, 3])
78
+
79
+ # set and add camera
80
+ camera = pycolmap.Camera()
81
+ camera.camera_id = fidx + 1
82
+ camera.model = pycolmap.CameraModelId.PINHOLE
83
+ camera.width = orig_w
84
+ camera.height = orig_h
85
+ camera.params = pycolmap_intri
86
+ reconstruction.add_camera(camera)
87
+
88
+ # set and add rig (from camera)
89
+ rig = pycolmap.Rig()
90
+ rig.rig_id = camera.camera_id
91
+ rig.add_ref_sensor(camera.sensor_id)
92
+ reconstruction.add_rig(rig)
93
+
94
+ # set image
95
+ image = pycolmap.Image()
96
+ image.image_id = fidx + 1
97
+ image.camera_id = camera.camera_id
98
+
99
+ # set and add frame (from image)
100
+ frame = pycolmap.Frame()
101
+ frame.frame_id = image.image_id
102
+ frame.rig_id = camera.camera_id
103
+ frame.add_data_id(image.data_id)
104
+ frame.rig_from_world = cam_from_world
105
+ reconstruction.add_frame(frame)
106
+
107
+ # set point2d and update track
108
+ point2d_list = []
109
+ points_in_frame = points_xyf[:, 2].astype(np.int32) == fidx
110
+ for vidx in np.where(points_in_frame)[0]:
111
+ point2d = points_xyf[vidx][:2]
112
+ point2d[0] *= orig_w / w
113
+ point2d[1] *= orig_h / h
114
+ point3d_id = point3d_ids[vidx]
115
+ point2d_list.append(pycolmap.Point2D(point2d, point3d_id))
116
+ reconstruction.point3D(point3d_id).track.add_element(
117
+ image.image_id, len(point2d_list) - 1
118
+ )
119
+
120
+ # set and add image
121
+ image.frame_id = image.image_id
122
+ image.name = os.path.basename(image_paths[fidx])
123
+ image.points2D = pycolmap.Point2DList(point2d_list)
124
+ reconstruction.add_image(image)
125
+
126
+ # 3. Export
127
+ reconstruction.write(export_dir)
128
+
129
+
130
+ def _create_xyf(num_frames, height, width):
131
+ """
132
+ Creates a grid of pixel coordinates and frame indices (fidx) for all frames.
133
+ """
134
+ # Create coordinate grids for a single frame
135
+ y_grid, x_grid = np.indices((height, width), dtype=np.int32)
136
+ x_grid = x_grid[np.newaxis, :, :]
137
+ y_grid = y_grid[np.newaxis, :, :]
138
+
139
+ # Broadcast to all frames
140
+ x_coords = np.broadcast_to(x_grid, (num_frames, height, width))
141
+ y_coords = np.broadcast_to(y_grid, (num_frames, height, width))
142
+
143
+ # Create frame indices and broadcast
144
+ f_idx = np.arange(num_frames, dtype=np.int32)[:, np.newaxis, np.newaxis]
145
+ f_coords = np.broadcast_to(f_idx, (num_frames, height, width))
146
+
147
+ # Stack coordinates and frame indices
148
+ points_xyf = np.stack((x_coords, y_coords, f_coords), axis=-1)
149
+
150
+ return points_xyf
multivue/cameras/source/src/depth_anything_3/utils/export/depth_vis.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ import imageio
17
+ import numpy as np
18
+
19
+ from depth_anything_3.specs import Prediction
20
+ from depth_anything_3.utils.visualize import visualize_depth
21
+
22
+
23
+ def export_to_depth_vis(
24
+ prediction: Prediction,
25
+ export_dir: str,
26
+ ):
27
+ # Use prediction.processed_images, which is already processed image data
28
+ if prediction.processed_images is None:
29
+ raise ValueError("prediction.processed_images is required but not available")
30
+
31
+ images_u8 = prediction.processed_images # (N,H,W,3) uint8
32
+
33
+ os.makedirs(os.path.join(export_dir, "depth_vis"), exist_ok=True)
34
+ for idx in range(prediction.depth.shape[0]):
35
+ depth_vis = visualize_depth(prediction.depth[idx])
36
+ image_vis = images_u8[idx]
37
+ depth_vis = depth_vis.astype(np.uint8)
38
+ image_vis = image_vis.astype(np.uint8)
39
+ vis_image = np.concatenate([image_vis, depth_vis], axis=1)
40
+ save_path = os.path.join(export_dir, f"depth_vis/{idx:04d}.jpg")
41
+ imageio.imwrite(save_path, vis_image, quality=95)
multivue/cameras/source/src/depth_anything_3/utils/export/feat_vis.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ import subprocess
17
+ import cv2
18
+ import imageio
19
+ import numpy as np
20
+ from tqdm.auto import tqdm
21
+
22
+ from depth_anything_3.utils.logger import logger
23
+ from depth_anything_3.utils.parallel_utils import async_call
24
+ from depth_anything_3.utils.pca_utils import PCARGBVisualizer
25
+
26
+
27
+ @async_call
28
+ def export_to_feat_vis(
29
+ prediction,
30
+ export_dir,
31
+ fps=15,
32
+ ):
33
+ """Export feature visualization with PCA.
34
+
35
+ Args:
36
+ prediction: Model prediction containing feature maps
37
+ export_dir: Directory to export results
38
+ fps: Frame rate for output video (default: 15)
39
+ """
40
+ out_dir = os.path.join(export_dir, "feat_vis")
41
+ os.makedirs(out_dir, exist_ok=True)
42
+
43
+ images = prediction.processed_images
44
+ for k, v in prediction.aux.items():
45
+ if not k.startswith("feat_layer_"):
46
+ continue
47
+ os.makedirs(os.path.join(out_dir, k), exist_ok=True)
48
+ viz = PCARGBVisualizer(basis_mode="fixed", percentile_mode="global", clip_percent=10.0)
49
+ viz.fit_reference(v)
50
+ feats_vis = viz.transform_video(v)
51
+ for idx in tqdm(range(len(feats_vis))):
52
+ img = images[idx]
53
+ feat_vis = (feats_vis[idx] * 255).astype(np.uint8)
54
+ feat_vis = cv2.resize(
55
+ feat_vis, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_NEAREST
56
+ )
57
+ save_path = os.path.join(out_dir, f"{k}/{idx:06d}.jpg")
58
+ save = np.concatenate([img, feat_vis], axis=1)
59
+ imageio.imwrite(save_path, save, quality=95)
60
+ cmd = [
61
+ "ffmpeg",
62
+ "-loglevel", "error",
63
+ "-hide_banner",
64
+ "-y",
65
+ "-framerate", str(fps),
66
+ "-start_number", "0",
67
+ "-i", os.path.join(out_dir, k, "%06d.jpg"),
68
+ "-c:v", "libx264",
69
+ "-pix_fmt", "yuv420p",
70
+ os.path.join(out_dir, f"{k}.mp4"),
71
+ ]
72
+ try:
73
+ subprocess.run(cmd, shell=False, check=False)
74
+ except OSError as e:
75
+ logger.warn(f"Failed to run ffmpeg for feature visualization export: {e}")
multivue/cameras/source/src/depth_anything_3/utils/export/glb.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import numpy as np
20
+ import trimesh
21
+
22
+ from depth_anything_3.specs import Prediction
23
+ from depth_anything_3.utils.logger import logger
24
+
25
+ from .depth_vis import export_to_depth_vis
26
+
27
+
28
+ def set_sky_depth(prediction: Prediction, sky_mask: np.ndarray, sky_depth_def: float = 98.0):
29
+ non_sky_mask = ~sky_mask
30
+ valid_depth = prediction.depth[non_sky_mask]
31
+ if valid_depth.size > 0:
32
+ max_depth = np.percentile(valid_depth, sky_depth_def)
33
+ prediction.depth[sky_mask] = max_depth
34
+
35
+
36
+ def get_conf_thresh(
37
+ prediction: Prediction,
38
+ sky_mask: np.ndarray,
39
+ conf_thresh: float,
40
+ conf_thresh_percentile: float = 10.0,
41
+ ensure_thresh_percentile: float = 90.0,
42
+ ):
43
+ if sky_mask is not None and (~sky_mask).sum() > 10:
44
+ conf_pixels = prediction.conf[~sky_mask]
45
+ else:
46
+ conf_pixels = prediction.conf
47
+ lower = np.percentile(conf_pixels, conf_thresh_percentile)
48
+ upper = np.percentile(conf_pixels, ensure_thresh_percentile)
49
+ conf_thresh = min(max(conf_thresh, lower), upper)
50
+ return conf_thresh
51
+
52
+
53
+ def export_to_glb(
54
+ prediction: Prediction,
55
+ export_dir: str,
56
+ num_max_points: int = 1_000_000,
57
+ conf_thresh: float = 1.05,
58
+ filter_black_bg: bool = False,
59
+ filter_white_bg: bool = False,
60
+ conf_thresh_percentile: float = 40.0,
61
+ ensure_thresh_percentile: float = 90.0,
62
+ sky_depth_def: float = 98.0,
63
+ show_cameras: bool = True,
64
+ camera_size: float = 0.03,
65
+ export_depth_vis: bool = True,
66
+ ) -> str:
67
+ """Generate a 3D point cloud and camera wireframes and export them as a ``.glb`` file.
68
+
69
+ The function builds a point cloud from the predicted depth maps, aligns it to the
70
+ first camera in glTF coordinates (X-right, Y-up, Z-backward), optionally draws
71
+ camera wireframes, and writes the result to ``scene.glb``. Auxiliary assets such as
72
+ depth visualizations can also be generated alongside the main export.
73
+
74
+ Args:
75
+ prediction: Model prediction containing depth, confidence, intrinsics, extrinsics,
76
+ and pre-processed images.
77
+ export_dir: Output directory where the glTF assets will be written.
78
+ num_max_points: Maximum number of points retained after downsampling.
79
+ conf_thresh: Base confidence threshold used before percentile adjustments.
80
+ filter_black_bg: Mark near-black background pixels for removal during confidence filtering.
81
+ filter_white_bg: Mark near-white background pixels for removal during confidence filtering.
82
+ conf_thresh_percentile: Lower percentile used when adapting the confidence threshold.
83
+ ensure_thresh_percentile: Upper percentile clamp for the adaptive threshold.
84
+ sky_depth_def: Percentile used to fill sky pixels with plausible depth values.
85
+ show_cameras: Whether to render camera wireframes in the exported scene.
86
+ camera_size: Relative camera wireframe scale as a fraction of the scene diagonal.
87
+ export_depth_vis: Whether to export raster depth visualisations alongside the glTF.
88
+
89
+ Returns:
90
+ Path to the exported ``scene.glb`` file.
91
+ """
92
+ # 1) Use prediction.processed_images, which is already processed image data
93
+ assert (
94
+ prediction.processed_images is not None
95
+ ), "Export to GLB: prediction.processed_images is required but not available"
96
+ assert (
97
+ prediction.depth is not None
98
+ ), "Export to GLB: prediction.depth is required but not available"
99
+ assert (
100
+ prediction.intrinsics is not None
101
+ ), "Export to GLB: prediction.intrinsics is required but not available"
102
+ assert (
103
+ prediction.extrinsics is not None
104
+ ), "Export to GLB: prediction.extrinsics is required but not available"
105
+ assert (
106
+ prediction.conf is not None
107
+ ), "Export to GLB: prediction.conf is required but not available"
108
+ logger.info(f"conf_thresh_percentile: {conf_thresh_percentile}")
109
+ logger.info(f"num max points: {num_max_points}")
110
+ logger.info(f"Exporting to GLB with num_max_points: {num_max_points}")
111
+ if prediction.processed_images is None:
112
+ raise ValueError("prediction.processed_images is required but not available")
113
+
114
+ images_u8 = prediction.processed_images # (N,H,W,3) uint8
115
+
116
+ # 2) Sky processing (if sky_mask is provided)
117
+ if getattr(prediction, "sky_mask", None) is not None:
118
+ set_sky_depth(prediction, prediction.sky_mask, sky_depth_def)
119
+
120
+ # 3) Confidence threshold (if no conf, then no filtering)
121
+ if filter_black_bg:
122
+ prediction.conf[(prediction.processed_images < 16).all(axis=-1)] = 1.0
123
+ if filter_white_bg:
124
+ prediction.conf[(prediction.processed_images >= 240).all(axis=-1)] = 1.0
125
+ conf_thr = get_conf_thresh(
126
+ prediction,
127
+ getattr(prediction, "sky_mask", None),
128
+ conf_thresh,
129
+ conf_thresh_percentile,
130
+ ensure_thresh_percentile,
131
+ )
132
+
133
+ # 4) Back-project to world coordinates and get colors (world frame)
134
+ points, colors = _depths_to_world_points_with_colors(
135
+ prediction.depth,
136
+ prediction.intrinsics,
137
+ prediction.extrinsics, # w2c
138
+ images_u8,
139
+ prediction.conf,
140
+ conf_thr,
141
+ )
142
+
143
+ # 5) Based on first camera orientation + glTF axis system, center by point cloud,
144
+ # construct alignment transform, and apply to point cloud
145
+ A = _compute_alignment_transform_first_cam_glTF_center_by_points(
146
+ prediction.extrinsics[0], points
147
+ ) # (4,4)
148
+
149
+ if points.shape[0] > 0:
150
+ points = trimesh.transform_points(points, A)
151
+
152
+ # 6) Clean + downsample
153
+ points, colors = _filter_and_downsample(points, colors, num_max_points)
154
+
155
+ # 7) Assemble scene (add point cloud first)
156
+ scene = trimesh.Scene()
157
+ if scene.metadata is None:
158
+ scene.metadata = {}
159
+ scene.metadata["hf_alignment"] = A # For camera wireframes and external reuse
160
+
161
+ if points.shape[0] > 0:
162
+ pc = trimesh.points.PointCloud(vertices=points, colors=colors)
163
+ scene.add_geometry(pc)
164
+
165
+ # 8) Draw cameras (wireframe pyramids), using the same transform A
166
+ if show_cameras and prediction.intrinsics is not None and prediction.extrinsics is not None:
167
+ scene_scale = _estimate_scene_scale(points, fallback=1.0)
168
+ H, W = prediction.depth.shape[1:]
169
+ _add_cameras_to_scene(
170
+ scene=scene,
171
+ K=prediction.intrinsics,
172
+ ext_w2c=prediction.extrinsics,
173
+ image_sizes=[(H, W)] * prediction.depth.shape[0],
174
+ scale=scene_scale * camera_size,
175
+ )
176
+
177
+ # 9) Export
178
+ os.makedirs(export_dir, exist_ok=True)
179
+ out_path = os.path.join(export_dir, "scene.glb")
180
+ scene.export(out_path)
181
+
182
+ if export_depth_vis:
183
+ export_to_depth_vis(prediction, export_dir)
184
+ depth_vis_thumbnail = os.path.join(export_dir, "depth_vis", "0000.jpg")
185
+ if os.path.isfile(depth_vis_thumbnail):
186
+ shutil.copy2(depth_vis_thumbnail, os.path.join(export_dir, "scene.jpg"))
187
+ else:
188
+ logger.warn(f"Depth visualization thumbnail not found at {depth_vis_thumbnail}")
189
+ return out_path
190
+
191
+
192
+ # =========================
193
+ # utilities
194
+ # =========================
195
+
196
+
197
+ def _as_homogeneous44(ext: np.ndarray) -> np.ndarray:
198
+ """
199
+ Accept (4,4) or (3,4) extrinsic parameters, return (4,4) homogeneous matrix.
200
+ """
201
+ if ext.shape == (4, 4):
202
+ return ext
203
+ if ext.shape == (3, 4):
204
+ H = np.eye(4, dtype=ext.dtype)
205
+ H[:3, :4] = ext
206
+ return H
207
+ raise ValueError(f"extrinsic must be (4,4) or (3,4), got {ext.shape}")
208
+
209
+
210
+ def _depths_to_world_points_with_colors(
211
+ depth: np.ndarray,
212
+ K: np.ndarray,
213
+ ext_w2c: np.ndarray,
214
+ images_u8: np.ndarray,
215
+ conf: np.ndarray | None,
216
+ conf_thr: float,
217
+ ) -> tuple[np.ndarray, np.ndarray]:
218
+ """
219
+ For each frame, transform (u,v,1) through K^{-1} to get rays,
220
+ multiply by depth to camera frame, then use (w2c)^{-1} to transform to world frame.
221
+ Simultaneously extract colors.
222
+ """
223
+ N, H, W = depth.shape
224
+ us, vs = np.meshgrid(np.arange(W), np.arange(H))
225
+ ones = np.ones_like(us)
226
+ pix = np.stack([us, vs, ones], axis=-1).reshape(-1, 3) # (H*W,3)
227
+
228
+ pts_all, col_all = [], []
229
+
230
+ for i in range(N):
231
+ d = depth[i] # (H,W)
232
+ valid = np.isfinite(d) & (d > 0)
233
+ if conf is not None:
234
+ valid &= conf[i] >= conf_thr
235
+ if not np.any(valid):
236
+ continue
237
+
238
+ d_flat = d.reshape(-1)
239
+ vidx = np.flatnonzero(valid.reshape(-1))
240
+
241
+ K_inv = np.linalg.inv(K[i]) # (3,3)
242
+ c2w = np.linalg.inv(_as_homogeneous44(ext_w2c[i])) # (4,4)
243
+
244
+ rays = K_inv @ pix[vidx].T # (3,M)
245
+ Xc = rays * d_flat[vidx][None, :] # (3,M)
246
+ Xc_h = np.vstack([Xc, np.ones((1, Xc.shape[1]))])
247
+ Xw = (c2w @ Xc_h)[:3].T.astype(np.float32) # (M,3)
248
+
249
+ cols = images_u8[i].reshape(-1, 3)[vidx].astype(np.uint8) # (M,3)
250
+
251
+ pts_all.append(Xw)
252
+ col_all.append(cols)
253
+
254
+ if len(pts_all) == 0:
255
+ return np.zeros((0, 3), dtype=np.float32), np.zeros((0, 3), dtype=np.uint8)
256
+
257
+ return np.concatenate(pts_all, 0), np.concatenate(col_all, 0)
258
+
259
+
260
+ def _filter_and_downsample(points: np.ndarray, colors: np.ndarray, num_max: int):
261
+ if points.shape[0] == 0:
262
+ return points, colors
263
+ finite = np.isfinite(points).all(axis=1)
264
+ points, colors = points[finite], colors[finite]
265
+ if points.shape[0] > num_max:
266
+ idx = np.random.choice(points.shape[0], num_max, replace=False)
267
+ points, colors = points[idx], colors[idx]
268
+ return points, colors
269
+
270
+
271
+ def _estimate_scene_scale(points: np.ndarray, fallback: float = 1.0) -> float:
272
+ if points.shape[0] < 2:
273
+ return fallback
274
+ lo = np.percentile(points, 5, axis=0)
275
+ hi = np.percentile(points, 95, axis=0)
276
+ diag = np.linalg.norm(hi - lo)
277
+ return float(diag if np.isfinite(diag) and diag > 0 else fallback)
278
+
279
+
280
+ def _compute_alignment_transform_first_cam_glTF_center_by_points(
281
+ ext_w2c0: np.ndarray,
282
+ points_world: np.ndarray,
283
+ ) -> np.ndarray:
284
+ """Computes the transformation matrix to align the scene with glTF standards.
285
+
286
+ This function calculates a 4x4 homogeneous matrix that centers the scene's
287
+ point cloud and transforms its coordinate system from the computer vision (CV)
288
+ standard to the glTF standard.
289
+
290
+ The transformation process involves three main steps:
291
+ 1. **Initial Alignment**: Orients the world coordinate system to match the
292
+ first camera's view (x-right, y-down, z-forward).
293
+ 2. **Coordinate System Conversion**: Converts the CV camera frame to the
294
+ glTF frame (x-right, y-up, z-backward) by flipping the Y and Z axes.
295
+ 3. **Centering**: Translates the entire scene so that the median of the
296
+ point cloud becomes the new origin (0,0,0).
297
+
298
+ Returns:
299
+ A 4x4 homogeneous transformation matrix (torch.Tensor or np.ndarray)
300
+ that applies these transformations. A: X' = A @ [X;1]
301
+ """
302
+
303
+ w2c0 = _as_homogeneous44(ext_w2c0).astype(np.float64)
304
+
305
+ # CV -> glTF axis transformation
306
+ M = np.eye(4, dtype=np.float64)
307
+ M[1, 1] = -1.0 # flip Y
308
+ M[2, 2] = -1.0 # flip Z
309
+
310
+ # Don't center first
311
+ A_no_center = M @ w2c0
312
+
313
+ # Calculate point cloud center in new coordinate system (use median to resist outliers)
314
+ if points_world.shape[0] > 0:
315
+ pts_tmp = trimesh.transform_points(points_world, A_no_center)
316
+ center = np.median(pts_tmp, axis=0)
317
+ else:
318
+ center = np.zeros(3, dtype=np.float64)
319
+
320
+ T_center = np.eye(4, dtype=np.float64)
321
+ T_center[:3, 3] = -center
322
+
323
+ A = T_center @ A_no_center
324
+ return A
325
+
326
+
327
+ def _add_cameras_to_scene(
328
+ scene: trimesh.Scene,
329
+ K: np.ndarray,
330
+ ext_w2c: np.ndarray,
331
+ image_sizes: list[tuple[int, int]],
332
+ scale: float,
333
+ ) -> None:
334
+ """Draws camera frustums to visualize their position and orientation.
335
+
336
+ This function renders each camera as a wireframe pyramid, originating from
337
+ the camera's center and extending to the corners of its imaging plane.
338
+
339
+ It reads the 'hf_alignment' metadata from the scene to ensure the
340
+ wireframes are correctly aligned with the 3D point cloud.
341
+ """
342
+ N = K.shape[0]
343
+ if N == 0:
344
+ return
345
+
346
+ # Alignment matrix consistent with point cloud (use identity matrix if missing)
347
+ A = None
348
+ try:
349
+ A = scene.metadata.get("hf_alignment", None) if scene.metadata else None
350
+ except Exception:
351
+ A = None
352
+ if A is None:
353
+ A = np.eye(4, dtype=np.float64)
354
+
355
+ for i in range(N):
356
+ H, W = image_sizes[i]
357
+ segs = _camera_frustum_lines(K[i], ext_w2c[i], W, H, scale) # (8,2,3) world frame
358
+ # Apply unified transformation
359
+ segs = trimesh.transform_points(segs.reshape(-1, 3), A).reshape(-1, 2, 3)
360
+ path = trimesh.load_path(segs)
361
+ color = _index_color_rgb(i, N)
362
+ if hasattr(path, "colors"):
363
+ path.colors = np.tile(color, (len(path.entities), 1))
364
+ scene.add_geometry(path)
365
+
366
+
367
+ def _camera_frustum_lines(
368
+ K: np.ndarray, ext_w2c: np.ndarray, W: int, H: int, scale: float
369
+ ) -> np.ndarray:
370
+ corners = np.array(
371
+ [
372
+ [0, 0, 1.0],
373
+ [W - 1, 0, 1.0],
374
+ [W - 1, H - 1, 1.0],
375
+ [0, H - 1, 1.0],
376
+ ],
377
+ dtype=float,
378
+ ) # (4,3)
379
+
380
+ K_inv = np.linalg.inv(K)
381
+ c2w = np.linalg.inv(_as_homogeneous44(ext_w2c))
382
+
383
+ # camera center in world
384
+ Cw = (c2w @ np.array([0, 0, 0, 1.0]))[:3]
385
+
386
+ # rays -> z=1 plane points (camera frame)
387
+ rays = (K_inv @ corners.T).T
388
+ z = rays[:, 2:3]
389
+ z[z == 0] = 1.0
390
+ plane_cam = (rays / z) * scale # (4,3)
391
+
392
+ # to world
393
+ plane_w = []
394
+ for p in plane_cam:
395
+ pw = (c2w @ np.array([p[0], p[1], p[2], 1.0]))[:3]
396
+ plane_w.append(pw)
397
+ plane_w = np.stack(plane_w, 0) # (4,3)
398
+
399
+ segs = []
400
+ # center to corners
401
+ for k in range(4):
402
+ segs.append(np.stack([Cw, plane_w[k]], 0))
403
+ # rectangle edges
404
+ order = [0, 1, 2, 3, 0]
405
+ for a, b in zip(order[:-1], order[1:]):
406
+ segs.append(np.stack([plane_w[a], plane_w[b]], 0))
407
+
408
+ return np.stack(segs, 0) # (8,2,3)
409
+
410
+
411
+ def _index_color_rgb(i: int, n: int) -> np.ndarray:
412
+ h = (i + 0.5) / max(n, 1)
413
+ s, v = 0.85, 0.95
414
+ r, g, b = _hsv_to_rgb(h, s, v)
415
+ return (np.array([r, g, b]) * 255).astype(np.uint8)
416
+
417
+
418
+ def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]:
419
+ i = int(h * 6.0)
420
+ f = h * 6.0 - i
421
+ p = v * (1.0 - s)
422
+ q = v * (1.0 - f * s)
423
+ t = v * (1.0 - (1.0 - f) * s)
424
+ i = i % 6
425
+ if i == 0:
426
+ r, g, b = v, t, p
427
+ elif i == 1:
428
+ r, g, b = q, v, p
429
+ elif i == 2:
430
+ r, g, b = p, v, t
431
+ elif i == 3:
432
+ r, g, b = p, q, v
433
+ elif i == 4:
434
+ r, g, b = t, p, v
435
+ else:
436
+ r, g, b = v, p, q
437
+ return r, g, b
multivue/cameras/source/src/depth_anything_3/utils/export/gs.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ from typing import Literal, Optional
17
+ import moviepy.editor as mpy
18
+ import torch
19
+
20
+ from depth_anything_3.model.utils.gs_renderer import run_renderer_in_chunk_w_trj_mode
21
+ from depth_anything_3.specs import Prediction
22
+ from depth_anything_3.utils.gsply_helpers import save_gaussian_ply
23
+ from depth_anything_3.utils.layout_helpers import hcat, vcat
24
+ from depth_anything_3.utils.visualize import vis_depth_map_tensor
25
+
26
+ VIDEO_QUALITY_MAP = {
27
+ "low": {"crf": "28", "preset": "veryfast"},
28
+ "medium": {"crf": "23", "preset": "medium"},
29
+ "high": {"crf": "18", "preset": "slow"},
30
+ }
31
+
32
+
33
+ def export_to_gs_ply(
34
+ prediction: Prediction,
35
+ export_dir: str,
36
+ gs_views_interval: Optional[
37
+ int
38
+ ] = 1, # export GS every N views, useful for extremely dense inputs
39
+ ):
40
+ gs_world = prediction.gaussians
41
+ pred_depth = torch.from_numpy(prediction.depth).unsqueeze(-1).to(gs_world.means) # v h w 1
42
+ idx = 0
43
+ os.makedirs(os.path.join(export_dir, "gs_ply"), exist_ok=True)
44
+ save_path = os.path.join(export_dir, f"gs_ply/{idx:04d}.ply")
45
+ if gs_views_interval is None: # select around 12 views in total
46
+ gs_views_interval = max(pred_depth.shape[0] // 12, 1)
47
+ save_gaussian_ply(
48
+ gaussians=gs_world,
49
+ save_path=save_path,
50
+ ctx_depth=pred_depth,
51
+ shift_and_scale=False,
52
+ save_sh_dc_only=True,
53
+ gs_views_interval=gs_views_interval,
54
+ inv_opacity=True,
55
+ prune_by_depth_percent=0.9,
56
+ prune_border_gs=True,
57
+ match_3dgs_mcmc_dev=False,
58
+ )
59
+
60
+
61
+ def export_to_gs_video(
62
+ prediction: Prediction,
63
+ export_dir: str,
64
+ extrinsics: Optional[torch.Tensor] = None, # render views' world2cam, "b v 4 4"
65
+ intrinsics: Optional[torch.Tensor] = None, # render views' unnormed intrinsics, "b v 3 3"
66
+ out_image_hw: Optional[tuple[int, int]] = None, # render views' resolution, (h, w)
67
+ chunk_size: Optional[int] = 4,
68
+ trj_mode: Literal[
69
+ "original",
70
+ "smooth",
71
+ "interpolate",
72
+ "interpolate_smooth",
73
+ "wander",
74
+ "dolly_zoom",
75
+ "extend",
76
+ "wobble_inter",
77
+ ] = "extend",
78
+ color_mode: Literal["RGB+D", "RGB+ED"] = "RGB+ED",
79
+ vis_depth: Optional[Literal["hcat", "vcat"]] = "hcat",
80
+ enable_tqdm: Optional[bool] = True,
81
+ output_name: Optional[str] = None,
82
+ video_quality: Literal["low", "medium", "high"] = "high",
83
+ ) -> None:
84
+ gs_world = prediction.gaussians
85
+ # if target poses are not provided, render the (smooth/interpolate) input poses
86
+ if extrinsics is not None:
87
+ tgt_extrs = extrinsics
88
+ else:
89
+ tgt_extrs = torch.from_numpy(prediction.extrinsics).unsqueeze(0).to(gs_world.means)
90
+ if prediction.is_metric:
91
+ scale_factor = prediction.scale_factor
92
+ if scale_factor is not None:
93
+ tgt_extrs[:, :, :3, 3] /= scale_factor
94
+ tgt_intrs = (
95
+ intrinsics
96
+ if intrinsics is not None
97
+ else torch.from_numpy(prediction.intrinsics).unsqueeze(0).to(gs_world.means)
98
+ )
99
+ # if render resolution is not provided, render the input ones
100
+ if out_image_hw is not None:
101
+ H, W = out_image_hw
102
+ else:
103
+ H, W = prediction.depth.shape[-2:]
104
+ # if single views, render wander trj
105
+ if tgt_extrs.shape[1] <= 1:
106
+ trj_mode = "wander"
107
+ # trj_mode = "dolly_zoom"
108
+
109
+ color, depth = run_renderer_in_chunk_w_trj_mode(
110
+ gaussians=gs_world,
111
+ extrinsics=tgt_extrs,
112
+ intrinsics=tgt_intrs,
113
+ image_shape=(H, W),
114
+ chunk_size=chunk_size,
115
+ trj_mode=trj_mode,
116
+ use_sh=True,
117
+ color_mode=color_mode,
118
+ enable_tqdm=enable_tqdm,
119
+ )
120
+
121
+ # save as video
122
+ ffmpeg_params = [
123
+ "-crf",
124
+ VIDEO_QUALITY_MAP[video_quality]["crf"],
125
+ "-preset",
126
+ VIDEO_QUALITY_MAP[video_quality]["preset"],
127
+ "-pix_fmt",
128
+ "yuv420p",
129
+ ] # best compatibility
130
+
131
+ os.makedirs(os.path.join(export_dir, "gs_video"), exist_ok=True)
132
+ for idx in range(color.shape[0]):
133
+ video_i = color[idx]
134
+ if vis_depth is not None:
135
+ depth_i = vis_depth_map_tensor(depth[0])
136
+ cat_fn = hcat if vis_depth == "hcat" else vcat
137
+ video_i = torch.stack([cat_fn(c, d) for c, d in zip(video_i, depth_i)])
138
+ frames = list(
139
+ (video_i.clamp(0, 1) * 255).byte().permute(0, 2, 3, 1).cpu().numpy()
140
+ ) # T x H x W x C, uint8, numpy()
141
+
142
+ fps = 24
143
+ clip = mpy.ImageSequenceClip(frames, fps=fps)
144
+ output_name = f"{idx:04d}_{trj_mode}" if output_name is None else output_name
145
+ save_path = os.path.join(export_dir, f"gs_video/{output_name}.mp4")
146
+ # clip.write_videofile(save_path, codec="libx264", audio=False, bitrate="4000k")
147
+ clip.write_videofile(
148
+ save_path,
149
+ codec="libx264",
150
+ audio=False,
151
+ fps=fps,
152
+ ffmpeg_params=ffmpeg_params,
153
+ )
154
+ return
multivue/cameras/source/src/depth_anything_3/utils/export/npz.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ import numpy as np
17
+
18
+ from depth_anything_3.specs import Prediction
19
+ from depth_anything_3.utils.parallel_utils import async_call
20
+
21
+
22
+ @async_call
23
+ def export_to_npz(
24
+ prediction: Prediction,
25
+ export_dir: str,
26
+ ):
27
+ output_file = os.path.join(export_dir, "exports", "npz", "results.npz")
28
+ os.makedirs(os.path.dirname(output_file), exist_ok=True)
29
+
30
+ # Use prediction.processed_images, which is already processed image data
31
+ if prediction.processed_images is None:
32
+ raise ValueError("prediction.processed_images is required but not available")
33
+
34
+ image = prediction.processed_images # (N,H,W,3) uint8
35
+
36
+ # Build save dict with only non-None values
37
+ save_dict = {
38
+ "image": image,
39
+ "depth": np.round(prediction.depth, 6),
40
+ }
41
+
42
+ if prediction.conf is not None:
43
+ save_dict["conf"] = np.round(prediction.conf, 2)
44
+ if prediction.extrinsics is not None:
45
+ save_dict["extrinsics"] = prediction.extrinsics
46
+ if prediction.intrinsics is not None:
47
+ save_dict["intrinsics"] = prediction.intrinsics
48
+
49
+ # aux = {k: np.round(v, 4) for k, v in prediction.aux.items()}
50
+ np.savez_compressed(output_file, **save_dict)
51
+
52
+
53
+ @async_call
54
+ def export_to_mini_npz(
55
+ prediction: Prediction,
56
+ export_dir: str,
57
+ ):
58
+ output_file = os.path.join(export_dir, "exports", "mini_npz", "results.npz")
59
+ os.makedirs(os.path.dirname(output_file), exist_ok=True)
60
+
61
+ # Build save dict with only non-None values
62
+ save_dict = {
63
+ "depth": np.round(prediction.depth, 8),
64
+ }
65
+
66
+ if prediction.conf is not None:
67
+ save_dict["conf"] = np.round(prediction.conf, 2)
68
+ if prediction.extrinsics is not None:
69
+ save_dict["extrinsics"] = prediction.extrinsics
70
+ if prediction.intrinsics is not None:
71
+ save_dict["intrinsics"] = prediction.intrinsics
72
+
73
+ np.savez_compressed(output_file, **save_dict)
multivue/cameras/source/src/depth_anything_3/utils/export/utils.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import numpy as np
16
+ import torch
17
+
18
+
19
+ def _denorm_and_to_uint8(image_tensor: torch.Tensor) -> np.ndarray:
20
+ """Denormalize to [0,255] and output (N, H, W, 3) uint8."""
21
+ resnet_mean = torch.tensor(
22
+ [0.485, 0.456, 0.406], dtype=image_tensor.dtype, device=image_tensor.device
23
+ )
24
+ resnet_std = torch.tensor(
25
+ [0.229, 0.224, 0.225], dtype=image_tensor.dtype, device=image_tensor.device
26
+ )
27
+ img = image_tensor * resnet_std[None, :, None, None] + resnet_mean[None, :, None, None]
28
+ img = torch.clamp(img, 0.0, 1.0)
29
+ img = (img.permute(0, 2, 3, 1).cpu().numpy() * 255.0).round().astype(np.uint8) # (N,H,W,3)
30
+ return img
multivue/cameras/source/src/depth_anything_3/utils/geometry.py ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # flake8: noqa: F722
2
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ from types import SimpleNamespace
16
+ from typing import Optional
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from einops import einsum
21
+
22
+
23
+ def as_homogeneous(ext):
24
+ """
25
+ Accept (..., 3,4) or (..., 4,4) extrinsics, return (...,4,4) homogeneous matrix.
26
+ Supports torch.Tensor or np.ndarray.
27
+ """
28
+ if isinstance(ext, torch.Tensor):
29
+ # If already in homogeneous form
30
+ if ext.shape[-2:] == (4, 4):
31
+ return ext
32
+ elif ext.shape[-2:] == (3, 4):
33
+ # Create a new homogeneous matrix
34
+ ones = torch.zeros_like(ext[..., :1, :4])
35
+ ones[..., 0, 3] = 1.0
36
+ return torch.cat([ext, ones], dim=-2)
37
+ else:
38
+ raise ValueError(f"Invalid shape for torch.Tensor: {ext.shape}")
39
+
40
+ elif isinstance(ext, np.ndarray):
41
+ if ext.shape[-2:] == (4, 4):
42
+ return ext
43
+ elif ext.shape[-2:] == (3, 4):
44
+ ones = np.zeros_like(ext[..., :1, :4])
45
+ ones[..., 0, 3] = 1.0
46
+ return np.concatenate([ext, ones], axis=-2)
47
+ else:
48
+ raise ValueError(f"Invalid shape for np.ndarray: {ext.shape}")
49
+
50
+ else:
51
+ raise TypeError("Input must be a torch.Tensor or np.ndarray.")
52
+
53
+
54
+ @torch.jit.script
55
+ def affine_inverse(A: torch.Tensor):
56
+ R = A[..., :3, :3] # ..., 3, 3
57
+ T = A[..., :3, 3:] # ..., 3, 1
58
+ P = A[..., 3:, :] # ..., 1, 4
59
+ return torch.cat([torch.cat([R.mT, -R.mT @ T], dim=-1), P], dim=-2)
60
+
61
+
62
+ def transpose_last_two_axes(arr):
63
+ """
64
+ for np < 2
65
+ """
66
+ if arr.ndim < 2:
67
+ return arr
68
+ axes = list(range(arr.ndim))
69
+ # swap the last two
70
+ axes[-2], axes[-1] = axes[-1], axes[-2]
71
+ return arr.transpose(axes)
72
+
73
+
74
+ def affine_inverse_np(A: np.ndarray):
75
+ R = A[..., :3, :3]
76
+ T = A[..., :3, 3:]
77
+ P = A[..., 3:, :]
78
+ return np.concatenate(
79
+ [
80
+ np.concatenate([transpose_last_two_axes(R), -transpose_last_two_axes(R) @ T], axis=-1),
81
+ P,
82
+ ],
83
+ axis=-2,
84
+ )
85
+
86
+
87
+ def quat_to_mat(quaternions: torch.Tensor) -> torch.Tensor:
88
+ """
89
+ Quaternion Order: XYZW or say ijkr, scalar-last
90
+
91
+ Convert rotations given as quaternions to rotation matrices.
92
+ Args:
93
+ quaternions: quaternions with real part last,
94
+ as tensor of shape (..., 4).
95
+
96
+ Returns:
97
+ Rotation matrices as tensor of shape (..., 3, 3).
98
+ """
99
+ i, j, k, r = torch.unbind(quaternions, -1)
100
+ # pyre-fixme[58]: `/` is not supported for operand types `float` and `Tensor`.
101
+ two_s = 2.0 / (quaternions * quaternions).sum(-1)
102
+
103
+ o = torch.stack(
104
+ (
105
+ 1 - two_s * (j * j + k * k),
106
+ two_s * (i * j - k * r),
107
+ two_s * (i * k + j * r),
108
+ two_s * (i * j + k * r),
109
+ 1 - two_s * (i * i + k * k),
110
+ two_s * (j * k - i * r),
111
+ two_s * (i * k - j * r),
112
+ two_s * (j * k + i * r),
113
+ 1 - two_s * (i * i + j * j),
114
+ ),
115
+ -1,
116
+ )
117
+ return o.reshape(quaternions.shape[:-1] + (3, 3))
118
+
119
+
120
+ def mat_to_quat(matrix: torch.Tensor) -> torch.Tensor:
121
+ """
122
+ Convert rotations given as rotation matrices to quaternions.
123
+
124
+ Args:
125
+ matrix: Rotation matrices as tensor of shape (..., 3, 3).
126
+
127
+ Returns:
128
+ quaternions with real part last, as tensor of shape (..., 4).
129
+ Quaternion Order: XYZW or say ijkr, scalar-last
130
+ """
131
+ if matrix.size(-1) != 3 or matrix.size(-2) != 3:
132
+ raise ValueError(f"Invalid rotation matrix shape {matrix.shape}.")
133
+
134
+ batch_dim = matrix.shape[:-2]
135
+ m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
136
+ matrix.reshape(batch_dim + (9,)), dim=-1
137
+ )
138
+
139
+ q_abs = _sqrt_positive_part(
140
+ torch.stack(
141
+ [
142
+ 1.0 + m00 + m11 + m22,
143
+ 1.0 + m00 - m11 - m22,
144
+ 1.0 - m00 + m11 - m22,
145
+ 1.0 - m00 - m11 + m22,
146
+ ],
147
+ dim=-1,
148
+ )
149
+ )
150
+
151
+ # we produce the desired quaternion multiplied by each of r, i, j, k
152
+ quat_by_rijk = torch.stack(
153
+ [
154
+ # pyre-fixme[58]: `**` is not supported for operand types `Tensor` and
155
+ # `int`.
156
+ torch.stack([q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01], dim=-1),
157
+ # pyre-fixme[58]: `**` is not supported for operand types `Tensor` and
158
+ # `int`.
159
+ torch.stack([m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20], dim=-1),
160
+ # pyre-fixme[58]: `**` is not supported for operand types `Tensor` and
161
+ # `int`.
162
+ torch.stack([m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21], dim=-1),
163
+ # pyre-fixme[58]: `**` is not supported for operand types `Tensor` and
164
+ # `int`.
165
+ torch.stack([m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2], dim=-1),
166
+ ],
167
+ dim=-2,
168
+ )
169
+
170
+ # We floor here at 0.1 but the exact level is not important; if q_abs is small,
171
+ # the candidate won't be picked.
172
+ flr = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
173
+ quat_candidates = quat_by_rijk / (2.0 * q_abs[..., None].max(flr))
174
+
175
+ # if not for numerical problems, quat_candidates[i] should be same (up to a sign),
176
+ # forall i; we pick the best-conditioned one (with the largest denominator)
177
+ out = quat_candidates[F.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, :].reshape(
178
+ batch_dim + (4,)
179
+ )
180
+
181
+ # Convert from rijk to ijkr
182
+ out = out[..., [1, 2, 3, 0]]
183
+
184
+ out = standardize_quaternion(out)
185
+
186
+ return out
187
+
188
+
189
+ def _sqrt_positive_part(x: torch.Tensor) -> torch.Tensor:
190
+ """
191
+ Returns torch.sqrt(torch.max(0, x))
192
+ but with a zero subgradient where x is 0.
193
+ """
194
+ ret = torch.zeros_like(x)
195
+ positive_mask = x > 0
196
+ if torch.is_grad_enabled():
197
+ ret[positive_mask] = torch.sqrt(x[positive_mask])
198
+ else:
199
+ ret = torch.where(positive_mask, torch.sqrt(x), ret)
200
+ return ret
201
+
202
+
203
+ def standardize_quaternion(quaternions: torch.Tensor) -> torch.Tensor:
204
+ """
205
+ Convert a unit quaternion to a standard form: one in which the real
206
+ part is non negative.
207
+
208
+ Args:
209
+ quaternions: Quaternions with real part last,
210
+ as tensor of shape (..., 4).
211
+
212
+ Returns:
213
+ Standardized quaternions as tensor of shape (..., 4).
214
+ """
215
+ return torch.where(quaternions[..., 3:4] < 0, -quaternions, quaternions)
216
+
217
+
218
+ def sample_image_grid(
219
+ shape: tuple[int, ...],
220
+ device: torch.device = torch.device("cpu"),
221
+ ) -> tuple[
222
+ torch.Tensor, # float coordinates (xy indexing), "*shape dim"
223
+ torch.Tensor, # integer indices (ij indexing), "*shape dim"
224
+ ]:
225
+ """Get normalized (range 0 to 1) coordinates and integer indices for an image."""
226
+
227
+ # Each entry is a pixel-wise integer coordinate. In the 2D case, each entry is a
228
+ # (row, col) coordinate.
229
+ indices = [torch.arange(length, device=device) for length in shape]
230
+ stacked_indices = torch.stack(torch.meshgrid(*indices, indexing="ij"), dim=-1)
231
+
232
+ # Each entry is a floating-point coordinate in the range (0, 1). In the 2D case,
233
+ # each entry is an (x, y) coordinate.
234
+ coordinates = [(idx + 0.5) / length for idx, length in zip(indices, shape)]
235
+ coordinates = reversed(coordinates)
236
+ coordinates = torch.stack(torch.meshgrid(*coordinates, indexing="xy"), dim=-1)
237
+
238
+ return coordinates, stacked_indices
239
+
240
+
241
+ def homogenize_points(points: torch.Tensor) -> torch.Tensor: # "*batch dim" # "*batch dim+1"
242
+ """Convert batched points (xyz) to (xyz1)."""
243
+ return torch.cat([points, torch.ones_like(points[..., :1])], dim=-1)
244
+
245
+
246
+ def homogenize_vectors(vectors: torch.Tensor) -> torch.Tensor: # "*batch dim" # "*batch dim+1"
247
+ """Convert batched vectors (xyz) to (xyz0)."""
248
+ return torch.cat([vectors, torch.zeros_like(vectors[..., :1])], dim=-1)
249
+
250
+
251
+ def transform_rigid(
252
+ homogeneous_coordinates: torch.Tensor, # "*#batch dim"
253
+ transformation: torch.Tensor, # "*#batch dim dim"
254
+ ) -> torch.Tensor: # "*batch dim"
255
+ """Apply a rigid-body transformation to points or vectors."""
256
+ return einsum(
257
+ transformation,
258
+ homogeneous_coordinates.to(transformation.dtype),
259
+ "... i j, ... j -> ... i",
260
+ )
261
+
262
+
263
+ def transform_cam2world(
264
+ homogeneous_coordinates: torch.Tensor, # "*#batch dim"
265
+ extrinsics: torch.Tensor, # "*#batch dim dim"
266
+ ) -> torch.Tensor: # "*batch dim"
267
+ """Transform points from 3D camera coordinates to 3D world coordinates."""
268
+ return transform_rigid(homogeneous_coordinates, extrinsics)
269
+
270
+
271
+ def unproject(
272
+ coordinates: torch.Tensor, # "*#batch dim"
273
+ z: torch.Tensor, # "*#batch"
274
+ intrinsics: torch.Tensor, # "*#batch dim+1 dim+1"
275
+ ) -> torch.Tensor: # "*batch dim+1"
276
+ """Unproject 2D camera coordinates with the given Z values."""
277
+
278
+ # Apply the inverse intrinsics to the coordinates.
279
+ coordinates = homogenize_points(coordinates)
280
+ ray_directions = einsum(
281
+ intrinsics.float().inverse().to(intrinsics),
282
+ coordinates.to(intrinsics.dtype),
283
+ "... i j, ... j -> ... i",
284
+ )
285
+
286
+ # Apply the supplied depth values.
287
+ return ray_directions * z[..., None]
288
+
289
+
290
+ def get_world_rays(
291
+ coordinates: torch.Tensor, # "*#batch dim"
292
+ extrinsics: torch.Tensor, # "*#batch dim+2 dim+2"
293
+ intrinsics: torch.Tensor, # "*#batch dim+1 dim+1"
294
+ ) -> tuple[
295
+ torch.Tensor, # origins, "*batch dim+1"
296
+ torch.Tensor, # directions, "*batch dim+1"
297
+ ]:
298
+ # Get camera-space ray directions.
299
+ directions = unproject(
300
+ coordinates,
301
+ torch.ones_like(coordinates[..., 0]),
302
+ intrinsics,
303
+ )
304
+ directions = directions / directions.norm(dim=-1, keepdim=True)
305
+
306
+ # Transform ray directions to world coordinates.
307
+ directions = homogenize_vectors(directions)
308
+ directions = transform_cam2world(directions, extrinsics)[..., :-1]
309
+
310
+ # Tile the ray origins to have the same shape as the ray directions.
311
+ origins = extrinsics[..., :-1, -1].broadcast_to(directions.shape)
312
+
313
+ return origins, directions
314
+
315
+
316
+ def get_fov(intrinsics: torch.Tensor) -> torch.Tensor: # "batch 3 3" -> "batch 2"
317
+ intrinsics_inv = intrinsics.float().inverse().to(intrinsics)
318
+
319
+ def process_vector(vector):
320
+ vector = torch.tensor(vector, dtype=intrinsics.dtype, device=intrinsics.device)
321
+ vector = einsum(intrinsics_inv, vector, "b i j, j -> b i")
322
+ return vector / vector.norm(dim=-1, keepdim=True)
323
+
324
+ left = process_vector([0, 0.5, 1])
325
+ right = process_vector([1, 0.5, 1])
326
+ top = process_vector([0.5, 0, 1])
327
+ bottom = process_vector([0.5, 1, 1])
328
+ fov_x = (left * right).sum(dim=-1).acos()
329
+ fov_y = (top * bottom).sum(dim=-1).acos()
330
+ return torch.stack((fov_x, fov_y), dim=-1)
331
+
332
+
333
+ def map_pdf_to_opacity(
334
+ pdf: torch.Tensor, # " *batch"
335
+ global_step: int = 0,
336
+ opacity_mapping: Optional[dict] = None,
337
+ ) -> torch.Tensor: # " *batch"
338
+ # https://www.desmos.com/calculator/opvwti3ba9
339
+
340
+ # Figure out the exponent.
341
+ if opacity_mapping is not None:
342
+ cfg = SimpleNamespace(**opacity_mapping)
343
+ x = cfg.initial + min(global_step / cfg.warm_up, 1) * (cfg.final - cfg.initial)
344
+ else:
345
+ x = 0.0
346
+ exponent = 2**x
347
+
348
+ # Map the probability density to an opacity.
349
+ return 0.5 * (1 - (1 - pdf) ** exponent + pdf ** (1 / exponent))
350
+
351
+ def normalize_homogenous_points(points):
352
+ """Normalize the point vectors"""
353
+ return points / points[..., -1:]
354
+
355
+ def inverse_intrinsic_matrix(ixts):
356
+ """ """
357
+ return torch.inverse(ixts)
358
+
359
+ def pixel_space_to_camera_space(pixel_space_points, depth, intrinsics):
360
+ """
361
+ Convert pixel space points to camera space points.
362
+
363
+ Args:
364
+ pixel_space_points (torch.Tensor): Pixel space points with shape (h, w, 2)
365
+ depth (torch.Tensor): Depth map with shape (b, v, h, w, 1)
366
+ intrinsics (torch.Tensor): Camera intrinsics with shape (b, v, 3, 3)
367
+
368
+ Returns:
369
+ torch.Tensor: Camera space points with shape (b, v, h, w, 3).
370
+ """
371
+ pixel_space_points = homogenize_points(pixel_space_points)
372
+ # camera_space_points = torch.einsum(
373
+ # "b v i j , h w j -> b v h w i", intrinsics.inverse(), pixel_space_points
374
+ # )
375
+ camera_space_points = torch.einsum(
376
+ "b v i j , h w j -> b v h w i", inverse_intrinsic_matrix(intrinsics), pixel_space_points
377
+ )
378
+ camera_space_points = camera_space_points * depth
379
+ return camera_space_points
380
+
381
+
382
+ def camera_space_to_world_space(camera_space_points, c2w):
383
+ """
384
+ Convert camera space points to world space points.
385
+
386
+ Args:
387
+ camera_space_points (torch.Tensor): Camera space points with shape (b, v, h, w, 3)
388
+ c2w (torch.Tensor): Camera to world extrinsics matrix with shape (b, v, 4, 4)
389
+
390
+ Returns:
391
+ torch.Tensor: World space points with shape (b, v, h, w, 3).
392
+ """
393
+ camera_space_points = homogenize_points(camera_space_points)
394
+ world_space_points = torch.einsum("b v i j , b v h w j -> b v h w i", c2w, camera_space_points)
395
+ return world_space_points[..., :3]
396
+
397
+
398
+ def camera_space_to_pixel_space(camera_space_points, intrinsics):
399
+ """
400
+ Convert camera space points to pixel space points.
401
+
402
+ Args:
403
+ camera_space_points (torch.Tensor): Camera space points with shape (b, v1, v2, h, w, 3)
404
+ c2w (torch.Tensor): Camera to world extrinsics matrix with shape (b, v2, 3, 3)
405
+
406
+ Returns:
407
+ torch.Tensor: World space points with shape (b, v1, v2, h, w, 2).
408
+ """
409
+ camera_space_points = normalize_homogenous_points(camera_space_points)
410
+ pixel_space_points = torch.einsum(
411
+ "b u i j , b v u h w j -> b v u h w i", intrinsics, camera_space_points
412
+ )
413
+ return pixel_space_points[..., :2]
414
+
415
+
416
+ def world_space_to_camera_space(world_space_points, c2w):
417
+ """
418
+ Convert world space points to pixel space points.
419
+
420
+ Args:
421
+ world_space_points (torch.Tensor): World space points with shape (b, v1, h, w, 3)
422
+ c2w (torch.Tensor): Camera to world extrinsics matrix with shape (b, v2, 4, 4)
423
+
424
+ Returns:
425
+ torch.Tensor: Camera space points with shape (b, v1, v2, h, w, 3).
426
+ """
427
+ world_space_points = homogenize_points(world_space_points)
428
+ camera_space_points = torch.einsum(
429
+ "b u i j , b v h w j -> b v u h w i", c2w.inverse(), world_space_points
430
+ )
431
+ return camera_space_points[..., :3]
432
+
433
+
434
+ def unproject_depth(
435
+ depth, intrinsics, c2w=None, ixt_normalized=False, num_patches_x=None, num_patches_y=None
436
+ ):
437
+ """
438
+ Turn the depth map into a 3D point cloud in world space
439
+
440
+ Args:
441
+ depth: (b, v, h, w, 1)
442
+ intrinsics: (b, v, 3, 3)
443
+ c2w: (b, v, 4, 4)
444
+
445
+ Returns:
446
+ torch.Tensor: World space points with shape (b, v, h, w, 3).
447
+ """
448
+ if c2w is None:
449
+ c2w = torch.eye(4, device=depth.device, dtype=depth.dtype)
450
+ c2w = c2w[None, None].repeat(depth.shape[0], depth.shape[1], 1, 1)
451
+
452
+ if not ixt_normalized:
453
+ # Compute indices of pixels
454
+ h, w = depth.shape[-3], depth.shape[-2]
455
+ x_grid, y_grid = torch.meshgrid(
456
+ torch.arange(w, device=depth.device, dtype=depth.dtype),
457
+ torch.arange(h, device=depth.device, dtype=depth.dtype),
458
+ indexing="xy",
459
+ ) # (h, w), (h, w)
460
+ else:
461
+ # ixt_normalized: h=w=2.0. cx, cy, fx, fy are normalized according to h=w=2.0
462
+ assert num_patches_x is not None and num_patches_y is not None
463
+ dx = 1 / num_patches_x
464
+ dy = 1 / num_patches_y
465
+ max_y = 1 - dy
466
+ min_y = -max_y
467
+ max_x = 1 - dx
468
+ min_x = -max_x
469
+
470
+ grid_shift = 1.0
471
+ y_grid, x_grid = torch.meshgrid(
472
+ torch.linspace(
473
+ min_y + grid_shift,
474
+ max_y + grid_shift,
475
+ num_patches_y,
476
+ dtype=torch.float32,
477
+ device=depth.device,
478
+ ),
479
+ torch.linspace(
480
+ min_x + grid_shift,
481
+ max_x + grid_shift,
482
+ num_patches_x,
483
+ dtype=torch.float32,
484
+ device=depth.device,
485
+ ),
486
+ indexing="ij",
487
+ )
488
+
489
+ # Compute coordinates of pixels in camera space
490
+ pixel_space_points = torch.stack((x_grid, y_grid), dim=-1) # (..., h, w, 2)
491
+ camera_points = pixel_space_to_camera_space(
492
+ pixel_space_points, depth, intrinsics
493
+ ) # (..., h, w, 3)
494
+
495
+ # Convert points to world space
496
+ world_points = camera_space_to_world_space(camera_points, c2w) # (..., h, w, 3)
497
+
498
+ return world_points
multivue/cameras/source/src/depth_anything_3/utils/gsply_helpers.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from pathlib import Path
15
+ from typing import Optional
16
+ import numpy as np
17
+ import torch
18
+ from einops import rearrange, repeat
19
+ from plyfile import PlyData, PlyElement
20
+ from torch import Tensor
21
+
22
+ from depth_anything_3.specs import Gaussians
23
+
24
+
25
+ def construct_list_of_attributes(num_rest: int) -> list[str]:
26
+ attributes = ["x", "y", "z", "nx", "ny", "nz"]
27
+ for i in range(3):
28
+ attributes.append(f"f_dc_{i}")
29
+ for i in range(num_rest):
30
+ attributes.append(f"f_rest_{i}")
31
+ attributes.append("opacity")
32
+ for i in range(3):
33
+ attributes.append(f"scale_{i}")
34
+ for i in range(4):
35
+ attributes.append(f"rot_{i}")
36
+ return attributes
37
+
38
+
39
+ def export_ply(
40
+ means: Tensor, # "gaussian 3"
41
+ scales: Tensor, # "gaussian 3"
42
+ rotations: Tensor, # "gaussian 4"
43
+ harmonics: Tensor, # "gaussian 3 d_sh"
44
+ opacities: Tensor, # "gaussian"
45
+ path: Path,
46
+ shift_and_scale: bool = False,
47
+ save_sh_dc_only: bool = True,
48
+ match_3dgs_mcmc_dev: Optional[bool] = False,
49
+ ):
50
+ if shift_and_scale:
51
+ # Shift the scene so that the median Gaussian is at the origin.
52
+ means = means - means.median(dim=0).values
53
+
54
+ # Rescale the scene so that most Gaussians are within range [-1, 1].
55
+ scale_factor = means.abs().quantile(0.95, dim=0).max()
56
+ means = means / scale_factor
57
+ scales = scales / scale_factor
58
+
59
+ rotations = rotations.detach().cpu().numpy()
60
+
61
+ # Since current model use SH_degree = 4,
62
+ # which require large memory to store, we can only save the DC band to save memory.
63
+ f_dc = harmonics[..., 0]
64
+ f_rest = harmonics[..., 1:].flatten(start_dim=1)
65
+
66
+ if match_3dgs_mcmc_dev:
67
+ sh_degree = 3
68
+ n_rest = 3 * (sh_degree + 1) ** 2 - 3
69
+ f_rest = repeat(
70
+ torch.zeros_like(harmonics[..., :1]), "... i -> ... (n i)", n=(n_rest // 3)
71
+ ).flatten(start_dim=1)
72
+ dtype_full = [
73
+ (attribute, "f4")
74
+ for attribute in construct_list_of_attributes(num_rest=n_rest)
75
+ if attribute not in ("nx", "ny", "nz")
76
+ ]
77
+ else:
78
+ dtype_full = [
79
+ (attribute, "f4")
80
+ for attribute in construct_list_of_attributes(
81
+ 0 if save_sh_dc_only else f_rest.shape[1]
82
+ )
83
+ ]
84
+ elements = np.empty(means.shape[0], dtype=dtype_full)
85
+ attributes = [
86
+ means.detach().cpu().numpy(),
87
+ torch.zeros_like(means).detach().cpu().numpy(),
88
+ f_dc.detach().cpu().contiguous().numpy(),
89
+ f_rest.detach().cpu().contiguous().numpy(),
90
+ opacities[..., None].detach().cpu().numpy(),
91
+ scales.log().detach().cpu().numpy(),
92
+ rotations,
93
+ ]
94
+ if match_3dgs_mcmc_dev:
95
+ attributes.pop(1) # dummy normal is not needed
96
+ elif save_sh_dc_only:
97
+ attributes.pop(3) # remove f_rest from attributes
98
+
99
+ attributes = np.concatenate(attributes, axis=1)
100
+ elements[:] = list(map(tuple, attributes))
101
+ path.parent.mkdir(exist_ok=True, parents=True)
102
+ PlyData([PlyElement.describe(elements, "vertex")]).write(path)
103
+
104
+
105
+ def inverse_sigmoid(x):
106
+ return torch.log(x / (1 - x))
107
+
108
+
109
+ def save_gaussian_ply(
110
+ gaussians: Gaussians,
111
+ save_path: str,
112
+ ctx_depth: torch.Tensor, # depth of input views; for getting shape and filtering, "v h w 1"
113
+ shift_and_scale: bool = False,
114
+ save_sh_dc_only: bool = True,
115
+ gs_views_interval: int = 1,
116
+ inv_opacity: Optional[bool] = True,
117
+ prune_by_depth_percent: Optional[float] = 1.0,
118
+ prune_border_gs: Optional[bool] = True,
119
+ match_3dgs_mcmc_dev: Optional[bool] = False,
120
+ ):
121
+ b = gaussians.means.shape[0]
122
+ assert b == 1, "must set batch_size=1 when exporting 3D gaussians"
123
+ src_v, out_h, out_w, _ = ctx_depth.shape
124
+
125
+ # extract gs params
126
+ world_means = gaussians.means
127
+ world_shs = gaussians.harmonics
128
+ world_rotations = gaussians.rotations
129
+ gs_scales = gaussians.scales
130
+ gs_opacities = inverse_sigmoid(gaussians.opacities) if inv_opacity else gaussians.opacities
131
+
132
+ # Create a mask to filter the Gaussians.
133
+
134
+ # TODO: prune the sky region here
135
+
136
+ # throw away Gaussians at the borders, since they're generally of lower quality.
137
+ if prune_border_gs:
138
+ mask = torch.zeros_like(ctx_depth, dtype=torch.bool)
139
+ gstrim_h = int(8 / 256 * out_h)
140
+ gstrim_w = int(8 / 256 * out_w)
141
+ mask[:, gstrim_h:-gstrim_h, gstrim_w:-gstrim_w, :] = 1
142
+ else:
143
+ mask = torch.ones_like(ctx_depth, dtype=torch.bool)
144
+
145
+ # trim the far away point based on depth;
146
+ if prune_by_depth_percent is not None and prune_by_depth_percent < 1:
147
+ in_depths = ctx_depth
148
+ d_percentile = torch.quantile(
149
+ in_depths.view(in_depths.shape[0], -1), q=prune_by_depth_percent, dim=1
150
+ ).view(-1, 1, 1)
151
+ d_mask = (in_depths[..., 0] <= d_percentile).unsqueeze(-1)
152
+ mask = mask & d_mask
153
+ mask = mask.squeeze(-1) # v h w
154
+
155
+ # helper fn, must place after mask
156
+ def trim_select_reshape(element):
157
+ selected_element = rearrange(
158
+ element[0], "(v h w) ... -> v h w ...", v=src_v, h=out_h, w=out_w
159
+ )
160
+ selected_element = selected_element[::gs_views_interval][mask[::gs_views_interval]]
161
+ return selected_element
162
+
163
+ export_ply(
164
+ means=trim_select_reshape(world_means),
165
+ scales=trim_select_reshape(gs_scales),
166
+ rotations=trim_select_reshape(world_rotations),
167
+ harmonics=trim_select_reshape(world_shs),
168
+ opacities=trim_select_reshape(gs_opacities),
169
+ path=Path(save_path),
170
+ shift_and_scale=shift_and_scale,
171
+ save_sh_dc_only=save_sh_dc_only,
172
+ match_3dgs_mcmc_dev=match_3dgs_mcmc_dev,
173
+ )
multivue/cameras/source/src/depth_anything_3/utils/io/__pycache__/input_processor.cpython-312.pyc ADDED
Binary file (21.8 kB). View file
 
multivue/cameras/source/src/depth_anything_3/utils/io/input_processor.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Input processor for Depth Anything 3 (parallelized).
17
+
18
+ This version removes the square center-crop step for "*crop" methods (same as your note).
19
+ In addition, it parallelizes per-image preprocessing using the provided `parallel_execution`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Sequence
25
+ import cv2
26
+ import numpy as np
27
+ import torch
28
+ import torchvision.transforms as T
29
+ from PIL import Image
30
+
31
+ from depth_anything_3.utils.logger import logger
32
+ from depth_anything_3.utils.parallel_utils import parallel_execution
33
+
34
+
35
+ class InputProcessor:
36
+ """Prepares a batch of images for model inference.
37
+ This processor converts a list of image file paths into a single, model-ready
38
+ tensor. The processing pipeline is executed in parallel across multiple workers
39
+ for efficiency.
40
+
41
+ Pipeline:
42
+ 1) Load image and convert to RGB
43
+ 2) Boundary resize (upper/lower bound, preserving aspect ratio)
44
+ 3) Enforce divisibility by PATCH_SIZE:
45
+ - "*resize" methods: each dimension is rounded to nearest multiple
46
+ (may up/downscale a few px)
47
+ - "*crop" methods: each dimension is floored to nearest multiple via center crop
48
+ 4) Convert to tensor and apply ImageNet normalization
49
+ 5) Stack into (1, N, 3, H, W)
50
+
51
+ Parallelization:
52
+ - Each image is processed independently in a worker.
53
+ - Order of outputs matches the input order.
54
+ """
55
+
56
+ NORMALIZE = T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
57
+ PATCH_SIZE = 14
58
+
59
+ def __init__(self):
60
+ pass
61
+
62
+ # -----------------------------
63
+ # Public API
64
+ # -----------------------------
65
+ def __call__(
66
+ self,
67
+ image: list[np.ndarray | Image.Image | str],
68
+ extrinsics: np.ndarray | None = None,
69
+ intrinsics: np.ndarray | None = None,
70
+ process_res: int = 504,
71
+ process_res_method: str = "upper_bound_resize",
72
+ *,
73
+ num_workers: int = 8,
74
+ print_progress: bool = False,
75
+ sequential: bool | None = None,
76
+ desc: str | None = "Preprocess",
77
+ ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]:
78
+ """
79
+ Returns:
80
+ (tensor, extrinsics_list, intrinsics_list)
81
+ tensor shape: (1, N, 3, H, W)
82
+ """
83
+ sequential = self._resolve_sequential(sequential, num_workers)
84
+ exts_list, ixts_list = self._validate_and_pack_meta(image, extrinsics, intrinsics)
85
+
86
+ results = self._run_parallel(
87
+ image=image,
88
+ exts_list=exts_list,
89
+ ixts_list=ixts_list,
90
+ process_res=process_res,
91
+ process_res_method=process_res_method,
92
+ num_workers=num_workers,
93
+ print_progress=print_progress,
94
+ sequential=sequential,
95
+ desc=desc,
96
+ )
97
+
98
+ proc_imgs, out_sizes, out_ixts, out_exts = self._unpack_results(results)
99
+ proc_imgs, out_sizes, out_ixts = self._unify_batch_shapes(proc_imgs, out_sizes, out_ixts)
100
+
101
+ batch_tensor = self._stack_batch(proc_imgs)
102
+ out_exts = (
103
+ torch.from_numpy(np.asarray(out_exts)).float()
104
+ if out_exts is not None and out_exts[0] is not None
105
+ else None
106
+ )
107
+ out_ixts = (
108
+ torch.from_numpy(np.asarray(out_ixts)).float()
109
+ if out_ixts is not None and out_ixts[0] is not None
110
+ else None
111
+ )
112
+ return (batch_tensor, out_exts, out_ixts)
113
+
114
+ # -----------------------------
115
+ # __call__ helpers
116
+ # -----------------------------
117
+ def _resolve_sequential(self, sequential: bool | None, num_workers: int) -> bool:
118
+ return (num_workers <= 1) if sequential is None else sequential
119
+
120
+ def _validate_and_pack_meta(
121
+ self,
122
+ images: list[np.ndarray | Image.Image | str],
123
+ extrinsics: np.ndarray | None,
124
+ intrinsics: np.ndarray | None,
125
+ ) -> tuple[list[np.ndarray | None] | None, list[np.ndarray | None] | None]:
126
+ if extrinsics is not None and len(extrinsics) != len(images):
127
+ raise ValueError("Length of extrinsics must match images when provided.")
128
+ if intrinsics is not None and len(intrinsics) != len(images):
129
+ raise ValueError("Length of intrinsics must match images when provided.")
130
+ exts_list = [e for e in extrinsics] if extrinsics is not None else None
131
+ ixts_list = [k for k in intrinsics] if intrinsics is not None else None
132
+ return exts_list, ixts_list
133
+
134
+ def _run_parallel(
135
+ self,
136
+ *,
137
+ image: list[np.ndarray | Image.Image | str],
138
+ exts_list: list[np.ndarray | None] | None,
139
+ ixts_list: list[np.ndarray | None] | None,
140
+ process_res: int,
141
+ process_res_method: str,
142
+ num_workers: int,
143
+ print_progress: bool,
144
+ sequential: bool,
145
+ desc: str | None,
146
+ ):
147
+ results = parallel_execution(
148
+ image,
149
+ exts_list,
150
+ ixts_list,
151
+ action=self._process_one, # (img, extrinsic, intrinsic, ...)
152
+ num_processes=num_workers,
153
+ print_progress=print_progress,
154
+ sequential=sequential,
155
+ desc=desc,
156
+ process_res=process_res,
157
+ process_res_method=process_res_method,
158
+ )
159
+ if not results:
160
+ raise RuntimeError(
161
+ "No preprocessing results returned. Check inputs and parallel_execution."
162
+ )
163
+ return results
164
+
165
+ def _unpack_results(self, results):
166
+ """
167
+ results: List[Tuple[torch.Tensor, Tuple[H, W], Optional[np.ndarray], Optional[np.ndarray]]]
168
+ -> processed_images, out_sizes, out_intrinsics, out_extrinsics
169
+ """
170
+ try:
171
+ processed_images, out_sizes, out_intrinsics, out_extrinsics = zip(*results)
172
+ except Exception as e:
173
+ raise RuntimeError(
174
+ "Unexpected results structure from parallel_execution: "
175
+ f"{type(results)} / sample: {results[0]}"
176
+ ) from e
177
+
178
+ return list(processed_images), list(out_sizes), list(out_intrinsics), list(out_extrinsics)
179
+
180
+ def _unify_batch_shapes(
181
+ self,
182
+ processed_images: list[torch.Tensor],
183
+ out_sizes: list[tuple[int, int]],
184
+ out_intrinsics: list[np.ndarray | None],
185
+ ) -> tuple[list[torch.Tensor], list[tuple[int, int]], list[np.ndarray | None]]:
186
+ """Center-crop all tensors to the smallest H, W; adjust intrinsics' cx, cy accordingly."""
187
+ if len(set(out_sizes)) <= 1:
188
+ return processed_images, out_sizes, out_intrinsics
189
+
190
+ min_h = min(h for h, _ in out_sizes)
191
+ min_w = min(w for _, w in out_sizes)
192
+ logger.warn(
193
+ f"Images in batch have different sizes {out_sizes}; "
194
+ f"center-cropping all to smallest ({min_h},{min_w})"
195
+ )
196
+
197
+ center_crop = T.CenterCrop((min_h, min_w))
198
+ new_imgs, new_sizes, new_ixts = [], [], []
199
+ for img_t, (H, W), K in zip(processed_images, out_sizes, out_intrinsics):
200
+ crop_top = max(0, (H - min_h) // 2)
201
+ crop_left = max(0, (W - min_w) // 2)
202
+ new_imgs.append(center_crop(img_t))
203
+ new_sizes.append((min_h, min_w))
204
+ if K is None:
205
+ new_ixts.append(None)
206
+ else:
207
+ K_adj = K.copy()
208
+ K_adj[0, 2] -= crop_left
209
+ K_adj[1, 2] -= crop_top
210
+ new_ixts.append(K_adj)
211
+ return new_imgs, new_sizes, new_ixts
212
+
213
+ def _stack_batch(self, processed_images: list[torch.Tensor]) -> torch.Tensor:
214
+ return torch.stack(processed_images)
215
+
216
+ # -----------------------------
217
+ # Per-item worker
218
+ # -----------------------------
219
+ def _process_one(
220
+ self,
221
+ img: np.ndarray | Image.Image | str,
222
+ extrinsic: np.ndarray | None = None,
223
+ intrinsic: np.ndarray | None = None,
224
+ *,
225
+ process_res: int,
226
+ process_res_method: str,
227
+ ) -> tuple[torch.Tensor, tuple[int, int], np.ndarray | None, np.ndarray | None]:
228
+ # Load & remember original size
229
+ pil_img = self._load_image(img)
230
+ orig_w, orig_h = pil_img.size
231
+
232
+ # Boundary resize
233
+ pil_img = self._resize_image(pil_img, process_res, process_res_method)
234
+ w, h = pil_img.size
235
+ intrinsic = self._resize_ixt(intrinsic, orig_w, orig_h, w, h)
236
+
237
+ # Enforce divisibility by PATCH_SIZE
238
+ if process_res_method.endswith("resize"):
239
+ pil_img = self._make_divisible_by_resize(pil_img, self.PATCH_SIZE)
240
+ new_w, new_h = pil_img.size
241
+ intrinsic = self._resize_ixt(intrinsic, w, h, new_w, new_h)
242
+ w, h = new_w, new_h
243
+ elif process_res_method.endswith("crop"):
244
+ pil_img = self._make_divisible_by_crop(pil_img, self.PATCH_SIZE)
245
+ new_w, new_h = pil_img.size
246
+ intrinsic = self._crop_ixt(intrinsic, w, h, new_w, new_h)
247
+ w, h = new_w, new_h
248
+ else:
249
+ raise ValueError(f"Unsupported process_res_method: {process_res_method}")
250
+
251
+ # Convert to tensor & normalize
252
+ img_tensor = self._normalize_image(pil_img)
253
+ _, H, W = img_tensor.shape
254
+ assert (W, H) == (w, h), "Tensor size mismatch with PIL image size after processing."
255
+
256
+ # Return: (img_tensor, (H, W), intrinsic, extrinsic)
257
+ return img_tensor, (H, W), intrinsic, extrinsic
258
+
259
+ # -----------------------------
260
+ # Intrinsics transforms
261
+ # -----------------------------
262
+ def _resize_ixt(
263
+ self,
264
+ intrinsic: np.ndarray | None,
265
+ orig_w: int,
266
+ orig_h: int,
267
+ w: int,
268
+ h: int,
269
+ ) -> np.ndarray | None:
270
+ if intrinsic is None:
271
+ return None
272
+ K = intrinsic.copy()
273
+ # scale fx, cx by w ratio; fy, cy by h ratio
274
+ K[:1] *= w / float(orig_w)
275
+ K[1:2] *= h / float(orig_h)
276
+ return K
277
+
278
+ def _crop_ixt(
279
+ self,
280
+ intrinsic: np.ndarray | None,
281
+ orig_w: int,
282
+ orig_h: int,
283
+ w: int,
284
+ h: int,
285
+ ) -> np.ndarray | None:
286
+ if intrinsic is None:
287
+ return None
288
+ K = intrinsic.copy()
289
+ crop_h = (orig_h - h) // 2
290
+ crop_w = (orig_w - w) // 2
291
+ K[0, 2] -= crop_w
292
+ K[1, 2] -= crop_h
293
+ return K
294
+
295
+ # -----------------------------
296
+ # I/O & normalization
297
+ # -----------------------------
298
+ def _load_image(self, img: np.ndarray | Image.Image | str) -> Image.Image:
299
+ if isinstance(img, str):
300
+ return Image.open(img).convert("RGB")
301
+ elif isinstance(img, np.ndarray):
302
+ # Assume HxWxC uint8/RGB
303
+ return Image.fromarray(img).convert("RGB")
304
+ elif isinstance(img, Image.Image):
305
+ return img.convert("RGB")
306
+ else:
307
+ raise ValueError(f"Unsupported image type: {type(img)}")
308
+
309
+ def _normalize_image(self, img: Image.Image) -> torch.Tensor:
310
+ img_tensor = T.ToTensor()(img)
311
+ return self.NORMALIZE(img_tensor)
312
+
313
+ # -----------------------------
314
+ # Boundary resizing
315
+ # -----------------------------
316
+ def _resize_image(self, img: Image.Image, target_size: int, method: str) -> Image.Image:
317
+ if method in ("upper_bound_resize", "upper_bound_crop"):
318
+ return self._resize_longest_side(img, target_size)
319
+ elif method in ("lower_bound_resize", "lower_bound_crop"):
320
+ return self._resize_shortest_side(img, target_size)
321
+ else:
322
+ raise ValueError(f"Unsupported resize method: {method}")
323
+
324
+ def _resize_longest_side(self, img: Image.Image, target_size: int) -> Image.Image:
325
+ w, h = img.size
326
+ longest = max(w, h)
327
+ if longest == target_size:
328
+ return img
329
+ scale = target_size / float(longest)
330
+ new_w = max(1, int(round(w * scale)))
331
+ new_h = max(1, int(round(h * scale)))
332
+ interpolation = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA
333
+ arr = cv2.resize(np.asarray(img), (new_w, new_h), interpolation=interpolation)
334
+ return Image.fromarray(arr)
335
+
336
+ def _resize_shortest_side(self, img: Image.Image, target_size: int) -> Image.Image:
337
+ w, h = img.size
338
+ shortest = min(w, h)
339
+ if shortest == target_size:
340
+ return img
341
+ scale = target_size / float(shortest)
342
+ new_w = max(1, int(round(w * scale)))
343
+ new_h = max(1, int(round(h * scale)))
344
+ interpolation = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA
345
+ arr = cv2.resize(np.asarray(img), (new_w, new_h), interpolation=interpolation)
346
+ return Image.fromarray(arr)
347
+
348
+ # -----------------------------
349
+ # Make divisible by PATCH_SIZE
350
+ # -----------------------------
351
+ def _make_divisible_by_crop(self, img: Image.Image, patch: int) -> Image.Image:
352
+ """
353
+ Floor each dimension to the nearest multiple of PATCH_SIZE via center crop.
354
+ Example: 504x377 -> 504x364
355
+ """
356
+ w, h = img.size
357
+ new_w = (w // patch) * patch
358
+ new_h = (h // patch) * patch
359
+ if new_w == w and new_h == h:
360
+ return img
361
+ left = (w - new_w) // 2
362
+ top = (h - new_h) // 2
363
+ return img.crop((left, top, left + new_w, top + new_h))
364
+
365
+ def _make_divisible_by_resize(self, img: Image.Image, patch: int) -> Image.Image:
366
+ """
367
+ Round each dimension to nearest multiple of PATCH_SIZE via small resize.
368
+ """
369
+ w, h = img.size
370
+
371
+ def nearest_multiple(x: int, p: int) -> int:
372
+ down = (x // p) * p
373
+ up = down + p
374
+ return up if abs(up - x) <= abs(x - down) else down
375
+
376
+ new_w = max(1, nearest_multiple(w, patch))
377
+ new_h = max(1, nearest_multiple(h, patch))
378
+ if new_w == w and new_h == h:
379
+ return img
380
+ upscale = (new_w > w) or (new_h > h)
381
+ interpolation = cv2.INTER_CUBIC if upscale else cv2.INTER_AREA
382
+ arr = cv2.resize(np.asarray(img), (new_w, new_h), interpolation=interpolation)
383
+ return Image.fromarray(arr)
384
+
385
+
386
+ # Backward compatibility alias
387
+ InputAdapter = InputProcessor
388
+
389
+
390
+ # ===========================
391
+ # Minimal test runner (parallel execution)
392
+ # ===========================
393
+ if __name__ == "__main__":
394
+ """
395
+ Minimal test suite:
396
+ - Creates pairs of images so batch shapes match.
397
+ - Tests all four process_res_methods.
398
+ - Prints fx fy cx cy IN->OUT per image.
399
+ - Includes cases with K/E provided and with None.
400
+ """
401
+
402
+ def fmt_k_line(K: np.ndarray | None) -> str:
403
+ if K is None:
404
+ return "None"
405
+ fx, fy, cx, cy = float(K[0, 0]), float(K[1, 1]), float(K[0, 2]), float(K[1, 2])
406
+ return f"fx={fx:.3f} fy={fy:.3f} cx={cx:.3f} cy={cy:.3f}"
407
+
408
+ def show_result(
409
+ tag: str,
410
+ tensor: torch.Tensor,
411
+ Ks_in: Sequence[np.ndarray | None] | None = None,
412
+ Ks_out: Sequence[np.ndarray | None] | None = None,
413
+ ):
414
+ B, N, C, H, W = tensor.shape
415
+ print(f"[{tag}] shape={tuple(tensor.shape)} HxW=({H},{W}) div14=({H%14==0},{W%14==0})")
416
+ assert H % 14 == 0 and W % 14 == 0, f"{tag}: output size not divisible by 14!"
417
+ if Ks_in is not None or Ks_out is not None:
418
+ Ks_in = Ks_in or [None] * N
419
+ Ks_out = Ks_out or [None] * N
420
+ for i in range(N):
421
+ print(f" K[{i}]: {fmt_k_line(Ks_in[i])} -> {fmt_k_line(Ks_out[i])}")
422
+
423
+ proc = InputProcessor()
424
+ process_res = 504
425
+ methods = ["upper_bound_resize", "upper_bound_crop", "lower_bound_resize", "lower_bound_crop"]
426
+
427
+ # Example sizes (two orientations)
428
+ small_sizes = [(680, 1208), (1208, 680)]
429
+ large_sizes = [(1208, 680), (680, 1208)]
430
+
431
+ def make_K(w, h, fx=1200.0, fy=1100.0):
432
+ cx, cy = w / 2.0, h / 2.0
433
+ K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float32)
434
+ return K
435
+
436
+ def run_suite(suite_name: str, sizes: list[tuple[int, int]]):
437
+ print(f"\n===== {suite_name} =====")
438
+ for w, h in sizes:
439
+ img = Image.new("RGB", (w, h), color=(123, 222, 100))
440
+ batch_imgs = [img, img]
441
+
442
+ # intrinsics / extrinsics examples
443
+ Ks_in = [make_K(w, h), make_K(w, h)]
444
+ Es_in = [np.eye(4, dtype=np.float32), np.eye(4, dtype=np.float32)]
445
+
446
+ for m in methods:
447
+ tensor, Es_out, Ks_out = proc(
448
+ image=batch_imgs,
449
+ process_res=process_res,
450
+ process_res_method=m,
451
+ num_workers=8,
452
+ print_progress=False,
453
+ intrinsics=Ks_in, # test with non-None
454
+ extrinsics=Es_in,
455
+ )
456
+ show_result(f"{suite_name} size=({w},{h}) | {m}", tensor, Ks_in, Ks_out)
457
+
458
+ # Also test None path
459
+ tensor2, Es_out2, Ks_out2 = proc(
460
+ image=batch_imgs,
461
+ process_res=process_res,
462
+ process_res_method="upper_bound_resize",
463
+ num_workers=8,
464
+ intrinsics=None,
465
+ extrinsics=None,
466
+ )
467
+ show_result(
468
+ f"{suite_name} size=({w},{h}) | upper_bound_resize | no K/E",
469
+ tensor2,
470
+ None,
471
+ Ks_out2,
472
+ )
473
+
474
+ run_suite("SMALL", small_sizes)
475
+ run_suite("LARGE", large_sizes)
476
+
477
+ # Extra sanity for 504x376
478
+ print("\n===== EXTRA sanity for 504x376 =====")
479
+ img_example = Image.new("RGB", (504, 376), color=(10, 20, 30))
480
+ Ks_in_extra = [make_K(504, 376, fx=900.0, fy=900.0), make_K(504, 376, fx=900.0, fy=900.0)]
481
+
482
+ out_r, _, Ks_out_r = proc(
483
+ image=[img_example, img_example],
484
+ process_res=504,
485
+ process_res_method="upper_bound_resize",
486
+ num_workers=8,
487
+ intrinsics=Ks_in_extra,
488
+ )
489
+ out_c, _, Ks_out_c = proc(
490
+ image=[img_example, img_example],
491
+ process_res=504,
492
+ process_res_method="upper_bound_crop",
493
+ num_workers=8,
494
+ intrinsics=Ks_in_extra,
495
+ )
496
+ _, _, _, Hr, Wr = out_r.shape
497
+ _, _, _, Hc, Wc = out_c.shape
498
+ print(f"upper_bound_resize -> ({Hr},{Wr}) (rounded to nearest multiple of 14)")
499
+ show_result("Ks after upper_bound_resize", out_r, Ks_in_extra, Ks_out_r)
500
+ print(f"upper_bound_crop -> ({Hc},{Wc}) (floored to multiple of 14)")
501
+ show_result("Ks after upper_bound_crop", out_c, Ks_in_extra, Ks_out_c)
multivue/cameras/source/src/depth_anything_3/utils/io/output_processor.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Output processor for Depth Anything 3.
17
+
18
+ This module handles model output processing, including tensor-to-numpy conversion,
19
+ batch dimension removal, and Prediction object creation.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+ import torch
26
+ from addict import Dict as AddictDict
27
+
28
+ from depth_anything_3.specs import Prediction
29
+
30
+
31
+ class OutputProcessor:
32
+ """
33
+ Output processor for converting model outputs to Prediction objects.
34
+
35
+ Handles tensor-to-numpy conversion, batch dimension removal,
36
+ and creates structured Prediction objects with proper data types.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ """Initialize the output processor."""
41
+
42
+ def __call__(self, model_output: dict[str, torch.Tensor]) -> Prediction:
43
+ """
44
+ Convert model output to Prediction object.
45
+
46
+ Args:
47
+ model_output: Model output dictionary containing depth, conf, extrinsics, intrinsics
48
+ Expected shapes: depth (B, N, 1, H, W), conf (B, N, 1, H, W),
49
+ extrinsics (B, N, 4, 4), intrinsics (B, N, 3, 3)
50
+
51
+ Returns:
52
+ Prediction: Object containing depth estimation results with shapes:
53
+ depth (N, H, W), conf (N, H, W), extrinsics (N, 4, 4), intrinsics (N, 3, 3)
54
+ """
55
+ # Extract data from batch dimension (B=1, N=number of images)
56
+ depth = self._extract_depth(model_output)
57
+ conf = self._extract_conf(model_output)
58
+ extrinsics = self._extract_extrinsics(model_output)
59
+ intrinsics = self._extract_intrinsics(model_output)
60
+ sky = self._extract_sky(model_output)
61
+ aux = self._extract_aux(model_output)
62
+ gaussians = model_output.get("gaussians", None)
63
+ scale_factor = model_output.get("scale_factor", None)
64
+
65
+ return Prediction(
66
+ depth=depth,
67
+ sky=sky,
68
+ conf=conf,
69
+ extrinsics=extrinsics,
70
+ intrinsics=intrinsics,
71
+ is_metric=getattr(model_output, "is_metric", 0),
72
+ gaussians=gaussians,
73
+ aux=aux,
74
+ scale_factor=scale_factor,
75
+ )
76
+
77
+ def _extract_depth(self, model_output: dict[str, torch.Tensor]) -> np.ndarray:
78
+ """
79
+ Extract depth tensor from model output and convert to numpy.
80
+
81
+ Args:
82
+ model_output: Model output dictionary
83
+
84
+ Returns:
85
+ Depth array with shape (N, H, W)
86
+ """
87
+ depth = model_output["depth"].squeeze(0).squeeze(-1).cpu().numpy() # (N, H, W)
88
+ return depth
89
+
90
+ def _extract_conf(self, model_output: dict[str, torch.Tensor]) -> np.ndarray | None:
91
+ """
92
+ Extract confidence tensor from model output and convert to numpy.
93
+
94
+ Args:
95
+ model_output: Model output dictionary
96
+
97
+ Returns:
98
+ Confidence array with shape (N, H, W) or None
99
+ """
100
+ conf = model_output.get("depth_conf", None)
101
+ if conf is not None:
102
+ conf = conf.squeeze(0).cpu().numpy() # (N, H, W)
103
+ return conf
104
+
105
+ def _extract_extrinsics(self, model_output: dict[str, torch.Tensor]) -> np.ndarray | None:
106
+ """
107
+ Extract extrinsics tensor from model output and convert to numpy.
108
+
109
+ Args:
110
+ model_output: Model output dictionary
111
+
112
+ Returns:
113
+ Extrinsics array with shape (N, 4, 4) or None
114
+ """
115
+ extrinsics = model_output.get("extrinsics", None)
116
+ if extrinsics is not None:
117
+ extrinsics = extrinsics.squeeze(0).cpu().numpy() # (N, 4, 4)
118
+ return extrinsics
119
+
120
+ def _extract_intrinsics(self, model_output: dict[str, torch.Tensor]) -> np.ndarray | None:
121
+ """
122
+ Extract intrinsics tensor from model output and convert to numpy.
123
+
124
+ Args:
125
+ model_output: Model output dictionary
126
+
127
+ Returns:
128
+ Intrinsics array with shape (N, 3, 3) or None
129
+ """
130
+ intrinsics = model_output.get("intrinsics", None)
131
+ if intrinsics is not None:
132
+ intrinsics = intrinsics.squeeze(0).cpu().numpy() # (N, 3, 3)
133
+ return intrinsics
134
+
135
+ def _extract_sky(self, model_output: dict[str, torch.Tensor]) -> np.ndarray | None:
136
+ """
137
+ Extract sky tensor from model output and convert to numpy.
138
+
139
+ Args:
140
+ model_output: Model output dictionary
141
+
142
+ Returns:
143
+ Sky mask array with shape (N, H, W) or None
144
+ """
145
+ sky = model_output.get("sky", None)
146
+ if sky is not None:
147
+ sky = sky.squeeze(0).cpu().numpy() >= 0.5 # (N, H, W)
148
+ return sky
149
+
150
+ def _extract_aux(self, model_output: dict[str, torch.Tensor]) -> AddictDict:
151
+ """
152
+ Extract auxiliary data from model output and convert to numpy.
153
+
154
+ Args:
155
+ model_output: Model output dictionary
156
+
157
+ Returns:
158
+ Dictionary containing auxiliary data
159
+ """
160
+ aux = model_output.get("aux", None)
161
+ ret = AddictDict()
162
+ if aux is not None:
163
+ for k in aux.keys():
164
+ if isinstance(aux[k], torch.Tensor):
165
+ ret[k] = aux[k].squeeze(0).cpu().numpy()
166
+ else:
167
+ ret[k] = aux[k]
168
+ return ret
169
+
170
+
171
+ # Backward compatibility alias
172
+ OutputAdapter = OutputProcessor
multivue/cameras/source/src/depth_anything_3/utils/layout_helpers.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """This file contains useful layout utilities for images. They are:
16
+
17
+ - add_border: Add a border to an image.
18
+ - cat/hcat/vcat: Join images by arranging them in a line. If the images have different
19
+ sizes, they are aligned as specified (start, end, center). Allows you to specify a gap
20
+ between images.
21
+
22
+ Images are assumed to be float32 tensors with shape (channel, height, width).
23
+ """
24
+
25
+ from typing import Any, Generator, Iterable, Literal, Union
26
+ import torch
27
+ from torch import Tensor
28
+
29
+ Alignment = Literal["start", "center", "end"]
30
+ Axis = Literal["horizontal", "vertical"]
31
+ Color = Union[
32
+ int,
33
+ float,
34
+ Iterable[int],
35
+ Iterable[float],
36
+ Tensor,
37
+ Tensor,
38
+ ]
39
+
40
+
41
+ def _sanitize_color(color: Color) -> Tensor: # "#channel"
42
+ # Convert tensor to list (or individual item).
43
+ if isinstance(color, torch.Tensor):
44
+ color = color.tolist()
45
+
46
+ # Turn iterators and individual items into lists.
47
+ if isinstance(color, Iterable):
48
+ color = list(color)
49
+ else:
50
+ color = [color]
51
+
52
+ return torch.tensor(color, dtype=torch.float32)
53
+
54
+
55
+ def _intersperse(iterable: Iterable, delimiter: Any) -> Generator[Any, None, None]:
56
+ it = iter(iterable)
57
+ yield next(it)
58
+ for item in it:
59
+ yield delimiter
60
+ yield item
61
+
62
+
63
+ def _get_main_dim(main_axis: Axis) -> int:
64
+ return {
65
+ "horizontal": 2,
66
+ "vertical": 1,
67
+ }[main_axis]
68
+
69
+
70
+ def _get_cross_dim(main_axis: Axis) -> int:
71
+ return {
72
+ "horizontal": 1,
73
+ "vertical": 2,
74
+ }[main_axis]
75
+
76
+
77
+ def _compute_offset(base: int, overlay: int, align: Alignment) -> slice:
78
+ assert base >= overlay
79
+ offset = {
80
+ "start": 0,
81
+ "center": (base - overlay) // 2,
82
+ "end": base - overlay,
83
+ }[align]
84
+ return slice(offset, offset + overlay)
85
+
86
+
87
+ def overlay(
88
+ base: Tensor, # "channel base_height base_width"
89
+ overlay: Tensor, # "channel overlay_height overlay_width"
90
+ main_axis: Axis,
91
+ main_axis_alignment: Alignment,
92
+ cross_axis_alignment: Alignment,
93
+ ) -> Tensor: # "channel base_height base_width"
94
+ # The overlay must be smaller than the base.
95
+ _, base_height, base_width = base.shape
96
+ _, overlay_height, overlay_width = overlay.shape
97
+ assert base_height >= overlay_height and base_width >= overlay_width
98
+
99
+ # Compute spacing on the main dimension.
100
+ main_dim = _get_main_dim(main_axis)
101
+ main_slice = _compute_offset(
102
+ base.shape[main_dim], overlay.shape[main_dim], main_axis_alignment
103
+ )
104
+
105
+ # Compute spacing on the cross dimension.
106
+ cross_dim = _get_cross_dim(main_axis)
107
+ cross_slice = _compute_offset(
108
+ base.shape[cross_dim], overlay.shape[cross_dim], cross_axis_alignment
109
+ )
110
+
111
+ # Combine the slices and paste the overlay onto the base accordingly.
112
+ selector = [..., None, None]
113
+ selector[main_dim] = main_slice
114
+ selector[cross_dim] = cross_slice
115
+ result = base.clone()
116
+ result[selector] = overlay
117
+ return result
118
+
119
+
120
+ def cat(
121
+ main_axis: Axis,
122
+ *images: Iterable[Tensor], # "channel _ _"
123
+ align: Alignment = "center",
124
+ gap: int = 8,
125
+ gap_color: Color = 1,
126
+ ) -> Tensor: # "channel height width"
127
+ """Arrange images in a line. The interface resembles a CSS div with flexbox."""
128
+ device = images[0].device
129
+ gap_color = _sanitize_color(gap_color).to(device)
130
+
131
+ # Find the maximum image side length in the cross axis dimension.
132
+ cross_dim = _get_cross_dim(main_axis)
133
+ cross_axis_length = max(image.shape[cross_dim] for image in images)
134
+
135
+ # Pad the images.
136
+ padded_images = []
137
+ for image in images:
138
+ # Create an empty image with the correct size.
139
+ padded_shape = list(image.shape)
140
+ padded_shape[cross_dim] = cross_axis_length
141
+ base = torch.ones(padded_shape, dtype=torch.float32, device=device)
142
+ base = base * gap_color[:, None, None]
143
+ padded_images.append(overlay(base, image, main_axis, "start", align))
144
+
145
+ # Intersperse separators if necessary.
146
+ if gap > 0:
147
+ # Generate a separator.
148
+ c, _, _ = images[0].shape
149
+ separator_size = [gap, gap]
150
+ separator_size[cross_dim - 1] = cross_axis_length
151
+ separator = torch.ones((c, *separator_size), dtype=torch.float32, device=device)
152
+ separator = separator * gap_color[:, None, None]
153
+
154
+ # Intersperse the separator between the images.
155
+ padded_images = list(_intersperse(padded_images, separator))
156
+
157
+ return torch.cat(padded_images, dim=_get_main_dim(main_axis))
158
+
159
+
160
+ def hcat(
161
+ *images: Iterable[Tensor], # "channel _ _"
162
+ align: Literal["start", "center", "end", "top", "bottom"] = "start",
163
+ gap: int = 8,
164
+ gap_color: Color = 1,
165
+ ):
166
+ """Shorthand for a horizontal linear concatenation."""
167
+ return cat(
168
+ "horizontal",
169
+ *images,
170
+ align={
171
+ "start": "start",
172
+ "center": "center",
173
+ "end": "end",
174
+ "top": "start",
175
+ "bottom": "end",
176
+ }[align],
177
+ gap=gap,
178
+ gap_color=gap_color,
179
+ )
180
+
181
+
182
+ def vcat(
183
+ *images: Iterable[Tensor], # "channel _ _"
184
+ align: Literal["start", "center", "end", "left", "right"] = "start",
185
+ gap: int = 8,
186
+ gap_color: Color = 1,
187
+ ):
188
+ """Shorthand for a horizontal linear concatenation."""
189
+ return cat(
190
+ "vertical",
191
+ *images,
192
+ align={
193
+ "start": "start",
194
+ "center": "center",
195
+ "end": "end",
196
+ "left": "start",
197
+ "right": "end",
198
+ }[align],
199
+ gap=gap,
200
+ gap_color=gap_color,
201
+ )
202
+
203
+
204
+ def add_border(
205
+ image: Tensor, # "channel height width"
206
+ border: int = 8,
207
+ color: Color = 1,
208
+ ) -> Tensor: # "channel new_height new_width"
209
+ color = _sanitize_color(color).to(image)
210
+ c, h, w = image.shape
211
+ result = torch.empty(
212
+ (c, h + 2 * border, w + 2 * border), dtype=torch.float32, device=image.device
213
+ )
214
+ result[:] = color[:, None, None]
215
+ result[:, border : h + border, border : w + border] = image
216
+ return result
multivue/cameras/source/src/depth_anything_3/utils/logger.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ import sys
17
+
18
+
19
+ class Color:
20
+ RED = "\033[91m"
21
+ YELLOW = "\033[93m"
22
+ WHITE = "\033[97m"
23
+ GREEN = "\033[92m"
24
+ RESET = "\033[0m"
25
+
26
+
27
+ LOG_LEVELS = {"ERROR": 0, "WARN": 1, "INFO": 2, "DEBUG": 3}
28
+
29
+ COLOR_MAP = {"ERROR": Color.RED, "WARN": Color.YELLOW, "INFO": Color.WHITE, "DEBUG": Color.GREEN}
30
+
31
+
32
+ def get_env_log_level():
33
+ level = os.environ.get("DA3_LOG_LEVEL", "INFO").upper()
34
+ return LOG_LEVELS.get(level, LOG_LEVELS["INFO"])
35
+
36
+
37
+ class Logger:
38
+ def __init__(self):
39
+ self.level = get_env_log_level()
40
+
41
+ def log(self, level_str, *args, **kwargs):
42
+ level_key = level_str.split(":")[0].strip()
43
+ level_val = LOG_LEVELS.get(level_key)
44
+ if level_val is None:
45
+ raise ValueError(f"Unknown log level: {level_str}")
46
+ if self.level >= level_val:
47
+ color = COLOR_MAP[level_key]
48
+ msg = " ".join(str(arg) for arg in args)
49
+
50
+ # Align log level output in square brackets
51
+ # ERROR and DEBUG are 5 characters, INFO and WARN have an extra space for alignment
52
+ tag = level_key
53
+ if tag in ("INFO", "WARN"):
54
+ tag += " "
55
+ print(
56
+ f"{color}[{tag}] {msg}{Color.RESET}",
57
+ file=sys.stderr if level_key == "ERROR" else sys.stdout,
58
+ **kwargs,
59
+ )
60
+
61
+ def error(self, *args, **kwargs):
62
+ self.log("ERROR:", *args, **kwargs)
63
+
64
+ def warn(self, *args, **kwargs):
65
+ self.log("WARN:", *args, **kwargs)
66
+
67
+ def info(self, *args, **kwargs):
68
+ self.log("INFO:", *args, **kwargs)
69
+
70
+ def debug(self, *args, **kwargs):
71
+ self.log("DEBUG:", *args, **kwargs)
72
+
73
+
74
+ logger = Logger()
75
+
76
+ __all__ = ["logger"]
77
+
78
+ if __name__ == "__main__":
79
+ logger.info("This is an info message")
80
+ logger.warn("This is a warning message")
81
+ logger.error("This is an error message")
82
+ logger.debug("This is a debug message")
multivue/cameras/source/src/depth_anything_3/utils/memory.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPU memory utility helpers.
3
+
4
+ Shared cleanup and memory checking logic used by both the backend API and
5
+ the Gradio UI to keep memory-management behavior consistent.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import gc
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+ import torch
14
+
15
+
16
+ def get_gpu_memory_info() -> Optional[Dict[str, Any]]:
17
+ """Return a snapshot of current GPU memory usage or None if CUDA not available.
18
+
19
+ Keys in returned dict: total_gb, allocated_gb, reserved_gb, free_gb, utilization
20
+ """
21
+ if not torch.cuda.is_available():
22
+ return None
23
+
24
+ try:
25
+ device = torch.cuda.current_device()
26
+ total_memory = torch.cuda.get_device_properties(device).total_memory
27
+ allocated_memory = torch.cuda.memory_allocated(device)
28
+ reserved_memory = torch.cuda.memory_reserved(device)
29
+ free_memory = total_memory - reserved_memory
30
+
31
+ return {
32
+ "total_gb": total_memory / 1024 ** 3,
33
+ "allocated_gb": allocated_memory / 1024 ** 3,
34
+ "reserved_gb": reserved_memory / 1024 ** 3,
35
+ "free_gb": free_memory / 1024 ** 3,
36
+ "utilization": (reserved_memory / total_memory) * 100,
37
+ }
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ def cleanup_cuda_memory() -> None:
43
+ """Perform a robust GPU cleanup sequence.
44
+
45
+ This includes synchronizing, emptying caches, collecting IPC handles and
46
+ running the Python garbage collector. Use this instead of a raw
47
+ ``torch.cuda.empty_cache()`` where you need reliable freeing of GPU memory
48
+ between model loads or in error handling paths.
49
+ """
50
+ try:
51
+ if torch.cuda.is_available():
52
+ mem_before = get_gpu_memory_info()
53
+
54
+ torch.cuda.synchronize()
55
+ torch.cuda.empty_cache()
56
+ # Collect cross-process cuda resources
57
+ try:
58
+ torch.cuda.ipc_collect()
59
+ except Exception:
60
+ # Older PyTorch versions or non-cuda devices may not support
61
+ # ipc_collect (no-op if not available)
62
+ pass
63
+ gc.collect()
64
+
65
+ mem_after = get_gpu_memory_info()
66
+ if mem_before and mem_after:
67
+ freed = mem_before["reserved_gb"] - mem_after["reserved_gb"]
68
+ print(
69
+ f"CUDA cleanup: freed {freed:.2f}GB, "
70
+ f"available: {mem_after['free_gb']:.2f}GB/{mem_after['total_gb']:.2f}GB"
71
+ )
72
+ else:
73
+ print("CUDA memory cleanup completed")
74
+ except Exception as e:
75
+ print(f"Warning: CUDA cleanup failed: {e}")
76
+
77
+
78
+ def check_memory_availability(required_gb: float = 2.0) -> tuple[bool, str]:
79
+ """Return whether at least ``required_gb`` seems available on the current GPU.
80
+
81
+ The returned tuple is (is_available, message) with a human-friendly message.
82
+ """
83
+ try:
84
+ if not torch.cuda.is_available():
85
+ return False, "CUDA is not available"
86
+
87
+ mem_info = get_gpu_memory_info()
88
+ if mem_info is None:
89
+ return True, "Cannot check memory, proceeding anyway"
90
+
91
+ if mem_info["free_gb"] < required_gb:
92
+ return (
93
+ False,
94
+ (
95
+ f"Insufficient GPU memory: {mem_info['free_gb']:.2f}GB available, "
96
+ f"{required_gb:.2f}GB required. Total: {mem_info['total_gb']:.2f}GB, "
97
+ f"Used: {mem_info['reserved_gb']:.2f}GB ({mem_info['utilization']:.1f}%)"
98
+ ),
99
+ )
100
+
101
+ return (
102
+ True,
103
+ (
104
+ f"Memory check passed: {mem_info['free_gb']:.2f}GB available, "
105
+ f"{required_gb:.2f}GB required"
106
+ ),
107
+ )
108
+ except Exception as e:
109
+ return True, f"Memory check failed: {e}, proceeding anyway"
110
+ def estimate_memory_requirement(num_images: int, process_res: int) -> float:
111
+ """Heuristic estimate for memory usage (GB) based on image count and resolution.
112
+
113
+ This mirrors the simple policy used by the backend service so other code
114
+ (e.g., Gradio UI) can make consistent decisions when checking available
115
+ memory before loading a model or running inference.
116
+
117
+ Args:
118
+ num_images: Number of images to process.
119
+ process_res: Processing resolution.
120
+
121
+ Returns:
122
+ Estimated memory requirement in GB.
123
+ """
124
+ base_memory = 2.0
125
+ per_image_memory = (process_res / 504) ** 2 * 0.5
126
+ total_memory = base_memory + (num_images * per_image_memory * 0.1)
127
+ return total_memory
multivue/cameras/source/src/depth_anything_3/utils/model_loading.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Model loading and state dict conversion utilities.
17
+ """
18
+
19
+ from typing import Dict, Tuple
20
+ import torch
21
+
22
+ from depth_anything_3.utils.logger import logger
23
+
24
+
25
+ def convert_general_state_dict(state_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
26
+ """
27
+ Convert general model state dict to match current model architecture.
28
+
29
+ Args:
30
+ state_dict: Original state dictionary
31
+
32
+ Returns:
33
+ Converted state dictionary
34
+ """
35
+ # Replace module prefixes
36
+ state_dict = {k.replace("module.", "model."): v for k, v in state_dict.items()}
37
+ state_dict = {k.replace(".net.", ".backbone."): v for k, v in state_dict.items()}
38
+
39
+ # Remove camera token if present
40
+ if "model.backbone.pretrained.camera_token" in state_dict:
41
+ del state_dict["model.backbone.pretrained.camera_token"]
42
+
43
+ # Replace camera token naming
44
+ state_dict = {
45
+ k.replace(".camera_token_extra", ".camera_token"): v for k, v in state_dict.items()
46
+ }
47
+
48
+ # Replace head naming
49
+ state_dict = {
50
+ k.replace("model.all_heads.camera_cond_head", "model.cam_enc"): v
51
+ for k, v in state_dict.items()
52
+ }
53
+ state_dict = {
54
+ k.replace("model.all_heads.camera_head", "model.cam_dec"): v for k, v in state_dict.items()
55
+ }
56
+ state_dict = {k.replace(".more_mlps.", ".backbone."): v for k, v in state_dict.items()}
57
+ state_dict = {k.replace(".fc_rot.", ".fc_qvec."): v for k, v in state_dict.items()}
58
+ state_dict = {
59
+ k.replace("model.all_heads.head", "model.head"): v for k, v in state_dict.items()
60
+ }
61
+
62
+ # Replace output naming
63
+ state_dict = {
64
+ k.replace("output_conv2_additional.sky_mask", "sky_output_conv2"): v
65
+ for k, v in state_dict.items()
66
+ }
67
+ state_dict = {k.replace("_ray.", "_aux."): v for k, v in state_dict.items()}
68
+
69
+ # Update GS-DPT head naming and value
70
+ state_dict = {k.replace("gaussian_param_head.", "gs_head."): v for k, v in state_dict.items()}
71
+
72
+ return state_dict
73
+
74
+
75
+ def convert_metric_state_dict(state_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
76
+ """
77
+ Convert metric model state dict to match current model architecture.
78
+
79
+ Args:
80
+ state_dict: Original metric state dictionary
81
+
82
+ Returns:
83
+ Converted state dictionary
84
+ """
85
+ # Add module prefix for metric models
86
+ state_dict = {"module." + k: v for k, v in state_dict.items()}
87
+ return convert_general_state_dict(state_dict)
88
+
89
+
90
+ def load_pretrained_weights(model, model_path: str, is_metric: bool = False) -> Tuple[list, list]:
91
+ """
92
+ Load pretrained weights for a single model.
93
+
94
+ Args:
95
+ model: Model instance to load weights into
96
+ model_path: Path to the pretrained weights
97
+ is_metric: Whether this is a metric model
98
+
99
+ Returns:
100
+ Tuple of (missed_keys, unexpected_keys)
101
+ """
102
+ state_dict = torch.load(model_path, map_location="cpu")
103
+
104
+ if is_metric:
105
+ state_dict = convert_metric_state_dict(state_dict)
106
+ else:
107
+ state_dict = convert_general_state_dict(state_dict)
108
+
109
+ missed, unexpected = model.load_state_dict(state_dict, strict=False)
110
+ logger.info("Missed keys:", missed)
111
+ logger.info("Unexpected keys:", unexpected)
112
+
113
+ return missed, unexpected
114
+
115
+
116
+ def load_pretrained_nested_weights(
117
+ model, main_model_path: str, metric_model_path: str
118
+ ) -> Tuple[list, list]:
119
+ """
120
+ Load pretrained weights for a nested model with both main and metric branches.
121
+
122
+ Args:
123
+ model: Nested model instance
124
+ main_model_path: Path to main model weights
125
+ metric_model_path: Path to metric model weights
126
+
127
+ Returns:
128
+ Tuple of (missed_keys, unexpected_keys)
129
+ """
130
+ # Load main model weights
131
+ state_dict0 = torch.load(main_model_path, map_location="cpu")
132
+ state_dict0 = convert_general_state_dict(state_dict0)
133
+ state_dict0 = {k.replace("model.", "model.da3."): v for k, v in state_dict0.items()}
134
+
135
+ # Load metric model weights
136
+ state_dict1 = torch.load(metric_model_path, map_location="cpu")
137
+ state_dict1 = convert_metric_state_dict(state_dict1)
138
+ state_dict1 = {k.replace("model.", "model.da3_metric."): v for k, v in state_dict1.items()}
139
+
140
+ # Combine state dictionaries
141
+ combined_state_dict = state_dict0.copy()
142
+ combined_state_dict.update(state_dict1)
143
+
144
+ missed, unexpected = model.load_state_dict(combined_state_dict, strict=False)
145
+
146
+ print("Missed keys:", missed)
147
+ print("Unexpected keys:", unexpected)
148
+
149
+ return missed, unexpected
multivue/cameras/source/src/depth_anything_3/utils/parallel_utils.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ import os
17
+ from functools import wraps
18
+ from multiprocessing.pool import ThreadPool
19
+ from threading import Thread
20
+ from typing import Callable, Dict, List
21
+ import imageio
22
+ from tqdm import tqdm
23
+
24
+
25
+ def async_call_func(func):
26
+ @wraps(func)
27
+ async def wrapper(*args, **kwargs):
28
+ loop = asyncio.get_event_loop()
29
+ # Use run_in_executor to run the blocking function in a separate thread
30
+ return await loop.run_in_executor(None, func, *args, **kwargs)
31
+
32
+ return wrapper
33
+
34
+
35
+ slice_func = lambda chunk_index, chunk_dim, chunk_size: [slice(None)] * chunk_dim + [
36
+ slice(chunk_index, chunk_index + chunk_size)
37
+ ]
38
+
39
+
40
+ def async_call(fn):
41
+ def wrapper(*args, **kwargs):
42
+ Thread(target=fn, args=args, kwargs=kwargs).start()
43
+
44
+ return wrapper
45
+
46
+
47
+ def _save_image_impl(save_img, save_path):
48
+ """Common implementation for saving images synchronously or asynchronously"""
49
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
50
+ imageio.imwrite(save_path, save_img)
51
+
52
+
53
+ @async_call
54
+ def save_image_async(save_img, save_path):
55
+ """Save image asynchronously"""
56
+ _save_image_impl(save_img, save_path)
57
+
58
+
59
+ def save_image(save_img, save_path):
60
+ """Save image synchronously"""
61
+ _save_image_impl(save_img, save_path)
62
+
63
+
64
+ def parallel_execution(
65
+ *args,
66
+ action: Callable,
67
+ num_processes=32,
68
+ print_progress=False,
69
+ sequential=False,
70
+ async_return=False,
71
+ desc=None,
72
+ **kwargs,
73
+ ):
74
+ # Partially copy from EasyVolumetricVideo (parallel_execution)
75
+ # NOTE: we expect first arg / or kwargs to be distributed
76
+ # NOTE: print_progress arg is reserved.
77
+ # `*args` packs all positional arguments passed to the function into a tuple
78
+ args = list(args)
79
+
80
+ def get_length(args: List, kwargs: Dict):
81
+ for a in args:
82
+ if isinstance(a, list):
83
+ return len(a)
84
+ for v in kwargs.values():
85
+ if isinstance(v, list):
86
+ return len(v)
87
+ raise NotImplementedError
88
+
89
+ def get_action_args(length: int, args: List, kwargs: Dict, i: int):
90
+ action_args = [
91
+ (arg[i] if isinstance(arg, list) and len(arg) == length else arg) for arg in args
92
+ ]
93
+ # TODO: Support all types of iterable
94
+ action_kwargs = {
95
+ key: (
96
+ kwargs[key][i]
97
+ if isinstance(kwargs[key], list) and len(kwargs[key]) == length
98
+ else kwargs[key]
99
+ )
100
+ for key in kwargs
101
+ }
102
+ return action_args, action_kwargs
103
+
104
+ if not sequential:
105
+ # Create ThreadPool
106
+ pool = ThreadPool(processes=num_processes)
107
+
108
+ # Spawn threads
109
+ results = []
110
+ asyncs = []
111
+ length = get_length(args, kwargs)
112
+ for i in range(length):
113
+ action_args, action_kwargs = get_action_args(length, args, kwargs, i)
114
+ async_result = pool.apply_async(action, action_args, action_kwargs)
115
+ asyncs.append(async_result)
116
+
117
+ # Join threads and get return values
118
+ if not async_return:
119
+ for async_result in tqdm(asyncs, desc=desc, disable=not print_progress):
120
+ results.append(async_result.get()) # will sync the corresponding thread
121
+ pool.close()
122
+ pool.join()
123
+ return results
124
+ else:
125
+ return pool
126
+ else:
127
+ results = []
128
+ length = get_length(args, kwargs)
129
+ for i in tqdm(range(length), desc=desc, disable=not print_progress):
130
+ action_args, action_kwargs = get_action_args(length, args, kwargs, i)
131
+ async_result = action(*action_args, **action_kwargs)
132
+ results.append(async_result)
133
+ return results
multivue/cameras/source/src/depth_anything_3/utils/pca_utils.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ PCA utilities for feature visualization and dimensionality reduction (video-friendly).
17
+ - Support frame-by-frame: transform_frame / transform_video
18
+ - Support one-time global PCA fitting and reuse (mean, V3) for stable colors
19
+ - Support Procrustes alignment (solving principal component order/sign/rotation jumps)
20
+ - Support global fixed or temporal EMA for percentiles (time dimension only, no spatial)
21
+ """
22
+
23
+ import numpy as np
24
+ import torch
25
+
26
+
27
+ def pca_to_rgb_4d_bf16_percentile(
28
+ x_np: np.ndarray,
29
+ device=None,
30
+ q_oversample: int = 6,
31
+ clip_percent: float = 10.0, # Percentage to clip from top and bottom (0~49.9)
32
+ return_uint8: bool = False,
33
+ enable_autocast_bf16: bool = True,
34
+ ):
35
+ """
36
+ Reduce numpy array of shape (49, 27, 36, 3072) to 3D via PCA and visualize as (49, 27, 36, 3).
37
+ - PCA uses torch.pca_lowrank (randomized SVD), defaults to GPU.
38
+ - Uses CUDA bf16 autocast in computation (if available),
39
+ then per-channel percentile clipping and normalization.
40
+ - Default removes 5% outliers from top and bottom (adjustable via clip_percent) to
41
+ improve visualization contrast.
42
+
43
+ Parameters
44
+ ----------
45
+ x_np : np.ndarray
46
+ Shape must be (49, 27, 36, 3072). dtype recommended float32/float64.
47
+ device : str | None
48
+ Specify 'cuda' or 'cpu'. Auto-select if None (prefer cuda).
49
+ q_oversample : int
50
+ Oversampling q for pca_lowrank, must be >= 3.
51
+ Slightly larger than target dim (3) is more stable, default 6.
52
+ clip_percent : float
53
+ Percentage to clip from top and bottom (0~49.9),
54
+ e.g. 5.0 means clip lowest 5% and highest 5% per channel.
55
+ return_uint8 : bool
56
+ True returns uint8(0~255), otherwise returns float32(0~1).
57
+ enable_autocast_bf16 : bool
58
+ Enable bf16 autocast on CUDA.
59
+
60
+ Returns
61
+ -------
62
+ np.ndarray
63
+ Array of shape (49, 27, 36, 3), float32[0,1] or uint8[0,255].
64
+ """
65
+ assert (
66
+ x_np.ndim == 4
67
+ ) # and x_np.shape[-1] == 3072, f"expect (49,27,36,3072), got {x_np.shape}"
68
+ B1, B2, B3, D = x_np.shape
69
+ N = B1 * B2 * B3
70
+
71
+ # Device selection
72
+ if device is None:
73
+ device = "cuda" if torch.cuda.is_available() else "cpu"
74
+
75
+ # Convert input to torch, unified float32
76
+ X = torch.from_numpy(x_np.reshape(N, D)).to(device=device, dtype=torch.float32)
77
+
78
+ # Parameter and safety checks
79
+ k = 3
80
+ q = max(int(q_oversample), k)
81
+ clip_percent = float(clip_percent)
82
+ if not (0.0 <= clip_percent < 50.0):
83
+ raise ValueError(
84
+ "clip_percent must be in [0, 50), e.g. 5.0 means clip 5% from top and bottom"
85
+ )
86
+ low = clip_percent / 100.0
87
+ high = 1.0 - low
88
+
89
+ with torch.no_grad():
90
+ # Zero mean
91
+ mean = X.mean(dim=0, keepdim=True)
92
+ Xc = X - mean
93
+
94
+ # Main computation: PCA + projection, try to use bf16
95
+ # (auto-fallback if operator not supported)
96
+ device.startswith("cuda") and enable_autocast_bf16
97
+ U, S, V = torch.pca_lowrank(Xc, q=q, center=False) # V: (D, q)
98
+ V3 = V[:, :k] # (3072, 3)
99
+ PCs = Xc @ V3 # (N, 3)
100
+
101
+ # === Per-channel percentile clipping and normalization to [0,1] ===
102
+ # Vectorized one-time calculation of low/high percentiles for each channel
103
+ qs = torch.tensor([low, high], device=PCs.device, dtype=PCs.dtype)
104
+ qvals = torch.quantile(PCs, q=qs, dim=0) # Shape (2, 3)
105
+ lo = qvals[0] # (3,)
106
+ hi = qvals[1] # (3,)
107
+
108
+ # Avoid degenerate case where hi==lo
109
+ denom = torch.clamp(hi - lo, min=1e-8)
110
+
111
+ # Broadcast clipping + normalization
112
+ PCs = torch.clamp(PCs, lo, hi)
113
+ PCs = (PCs - lo) / denom # (N, 3) in [0,1]
114
+
115
+ # Restore 4D
116
+ PCs = PCs.reshape(B1, B2, B3, k)
117
+
118
+ # Output
119
+ if return_uint8:
120
+ out = (PCs * 255.0).round().clamp(0, 255).to(torch.uint8).cpu().numpy()
121
+ else:
122
+ out = PCs.clamp(0, 1).to(torch.float32).cpu().numpy()
123
+
124
+ return out
125
+
126
+
127
+ class PCARGBVisualizer:
128
+ """
129
+ Stable PCA→RGB for video features shaped (T, H, W, D) or a single frame (H, W, D).
130
+ - Global mean/V3 reference for stable colors
131
+ - Per-frame PCA with Procrustes alignment to V3_ref (basis_mode='procrustes')
132
+ - Percentile normalization with global or EMA stats (time-only, no spatial smoothing)
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ device=None,
138
+ q_oversample: int = 16,
139
+ clip_percent: float = 10.0,
140
+ return_uint8: bool = False,
141
+ enable_autocast_bf16: bool = True,
142
+ basis_mode: str = "procrustes", # 'fixed' | 'procrustes'
143
+ percentile_mode: str = "ema", # 'global' | 'ema'
144
+ ema_alpha: float = 0.1,
145
+ denom_eps: float = 1e-4,
146
+ ):
147
+ assert 0.0 <= clip_percent < 50.0
148
+ assert basis_mode in ("fixed", "procrustes")
149
+ assert percentile_mode in ("global", "ema")
150
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
151
+ self.q = max(int(q_oversample), 6)
152
+ self.clip_percent = float(clip_percent)
153
+ self.return_uint8 = return_uint8
154
+ self.enable_autocast_bf16 = enable_autocast_bf16
155
+ self.basis_mode = basis_mode
156
+ self.percentile_mode = percentile_mode
157
+ self.ema_alpha = float(ema_alpha)
158
+ self.denom_eps = float(denom_eps)
159
+
160
+ # reference state
161
+ self.mean_ref = None # (1, D)
162
+ self.V3_ref = None # (D, 3)
163
+ self.lo_ref = None # (3,)
164
+ self.hi_ref = None # (3,)
165
+
166
+ @torch.no_grad()
167
+ def fit_reference(self, frames):
168
+ """
169
+ Fit global mean/V3 and initialize percentiles from a reference set.
170
+ frames: ndarray (T,H,W,D) or list of (H,W,D)
171
+ """
172
+ if isinstance(frames, np.ndarray):
173
+ if frames.ndim != 4:
174
+ raise ValueError("fit_reference expects (T,H,W,D) ndarray.")
175
+ T, H, W, D = frames.shape
176
+ X = torch.from_numpy(frames.reshape(T * H * W, D))
177
+ else: # list of (H,W,D)
178
+ xs = [torch.from_numpy(x.reshape(-1, x.shape[-1])) for x in frames]
179
+ D = xs[0].shape[-1]
180
+ X = torch.cat(xs, dim=0)
181
+
182
+ X = X.to(self.device, dtype=torch.float32)
183
+ X = torch.nan_to_num(X, nan=0.0, posinf=1e6, neginf=-1e6)
184
+
185
+ mean = X.mean(0, keepdim=True)
186
+ Xc = X - mean
187
+
188
+ U, S, V = torch.pca_lowrank(Xc, q=max(self.q, 8), center=False)
189
+ V3 = V[:, :3] # (D,3)
190
+
191
+ PCs = Xc @ V3
192
+ low = self.clip_percent / 100.0
193
+ high = 1.0 - low
194
+ qs = torch.tensor([low, high], device=PCs.device, dtype=PCs.dtype)
195
+ qvals = torch.quantile(PCs, q=qs, dim=0)
196
+ lo, hi = qvals[0], qvals[1]
197
+
198
+ self.mean_ref = mean
199
+ self.V3_ref = V3
200
+ if self.percentile_mode == "global":
201
+ self.lo_ref, self.hi_ref = lo, hi
202
+ else:
203
+ self.lo_ref = lo.clone()
204
+ self.hi_ref = hi.clone()
205
+
206
+ @torch.no_grad()
207
+ def _project_with_stable_colors(self, X: torch.Tensor) -> torch.Tensor:
208
+ """
209
+ X: (N,D) where N = H*W
210
+ Returns PCs_raw: (N,3) using stable basis (fixed or Procrustes-aligned)
211
+ """
212
+ assert self.mean_ref is not None and self.V3_ref is not None, "Call fit_reference() first."
213
+ X = torch.nan_to_num(X, nan=0.0, posinf=1e6, neginf=-1e6)
214
+ Xc = X - self.mean_ref
215
+
216
+ if self.basis_mode == "fixed":
217
+ V3_used = self.V3_ref
218
+ else:
219
+ U, S, V = torch.pca_lowrank(Xc, q=max(self.q, 6), center=False)
220
+ V3 = V[:, :3] # (D,3)
221
+ M = V3.T @ self.V3_ref
222
+ Uo, So, Vh = torch.linalg.svd(M)
223
+ R = Uo @ Vh
224
+ V3_used = V3 @ R
225
+ # Optional polarity fix via anchor
226
+ a = self.V3_ref.mean(0, keepdim=True)
227
+ sign = torch.sign((V3_used * a).sum(0, keepdim=True)).clamp(min=-1)
228
+ V3_used = V3_used * sign
229
+
230
+ return Xc @ V3_used
231
+
232
+ @torch.no_grad()
233
+ def _normalize_rgb(self, PCs_raw: torch.Tensor) -> torch.Tensor:
234
+ assert self.lo_ref is not None and self.hi_ref is not None
235
+ if self.percentile_mode == "global":
236
+ lo, hi = self.lo_ref, self.hi_ref
237
+ else:
238
+ low = self.clip_percent / 100.0
239
+ high = 1.0 - low
240
+ qs = torch.tensor([low, high], device=PCs_raw.device, dtype=PCs_raw.dtype)
241
+ qvals = torch.quantile(PCs_raw, q=qs, dim=0)
242
+ lo_now, hi_now = qvals[0], qvals[1]
243
+ a = self.ema_alpha
244
+ self.lo_ref = (1 - a) * self.lo_ref + a * lo_now
245
+ self.hi_ref = (1 - a) * self.hi_ref + a * hi_now
246
+ lo, hi = self.lo_ref, self.hi_ref
247
+
248
+ denom = torch.clamp(hi - lo, min=self.denom_eps)
249
+ PCs = torch.clamp(PCs_raw, lo, hi)
250
+ PCs = (PCs - lo) / denom
251
+ return PCs.clamp_(0, 1)
252
+
253
+ @torch.no_grad()
254
+ def transform_frame(self, frame: np.ndarray) -> np.ndarray:
255
+ """
256
+ frame: (H,W,D) -> (H,W,3)
257
+ """
258
+ if frame.ndim != 3:
259
+ raise ValueError("transform_frame expects (H,W,D).")
260
+ H, W, D = frame.shape
261
+ X = torch.from_numpy(frame.reshape(H * W, D)).to(self.device, dtype=torch.float32)
262
+ PCs_raw = self._project_with_stable_colors(X)
263
+ PCs = self._normalize_rgb(PCs_raw).reshape(H, W, 3)
264
+ if self.return_uint8:
265
+ return (PCs * 255.0).round().clamp(0, 255).to(torch.uint8).cpu().numpy()
266
+ return PCs.to(torch.float32).cpu().numpy()
267
+
268
+ @torch.no_grad()
269
+ def transform_video(self, frames) -> np.ndarray:
270
+ """
271
+ frames: (T,H,W,D) or list of (H,W,D)
272
+ returns: (T,H,W,3)
273
+ """
274
+ outs = []
275
+ if isinstance(frames, np.ndarray):
276
+ if frames.ndim != 4:
277
+ raise ValueError("transform_video expects (T,H,W,D).")
278
+ T, H, W, D = frames.shape
279
+ for t in range(T):
280
+ outs.append(self.transform_frame(frames[t]))
281
+ else:
282
+ for f in frames:
283
+ outs.append(self.transform_frame(f))
284
+ return np.stack(outs, axis=0)
multivue/cameras/source/src/depth_anything_3/utils/pose_align.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import List
16
+ import numpy as np
17
+ import torch
18
+ from evo.core.trajectory import PosePath3D
19
+
20
+ from depth_anything_3.utils.geometry import affine_inverse, affine_inverse_np
21
+
22
+
23
+ def batch_apply_alignment_to_enc(
24
+ rots: torch.Tensor, trans: torch.Tensor, scales: torch.Tensor, enc_list: List[torch.Tensor]
25
+ ):
26
+ pass
27
+
28
+
29
+ def batch_apply_alignment_to_ext(
30
+ rots: torch.Tensor, trans: torch.Tensor, scales: torch.Tensor, ext: torch.Tensor
31
+ ):
32
+ device, _ = ext.device, ext.dtype
33
+ if ext.shape[-2:] == (3, 4):
34
+ pad = torch.zeros((*ext.shape[:-2], 4, 4), dtype=ext.dtype, device=device)
35
+ pad[..., :3, :4] = ext
36
+ pad[..., 3, 3] = 1.0
37
+ ext = pad
38
+ pose_est = affine_inverse(ext)
39
+ pose_new_align_rot = rots[:, None] @ pose_est[..., :3, :3]
40
+ pose_new_align_trans = (
41
+ scales[:, None, None] * (rots[:, None] @ pose_est[..., :3, 3:])[..., 0] + trans[:, None]
42
+ )
43
+ pose_new_align = torch.zeros_like(ext)
44
+ pose_new_align[..., :3, :3] = pose_new_align_rot
45
+ pose_new_align[..., :3, 3] = pose_new_align_trans
46
+ pose_new_align[..., 3, 3] = 1.0
47
+ return affine_inverse(pose_new_align)[:, :3]
48
+
49
+
50
+ def batch_align_poses_umeyama(ext_ref: torch.Tensor, ext_est: torch.Tensor):
51
+ device, dtype = ext_ref.device, ext_ref.dtype
52
+ assert ext_ref.dtype in [torch.float32, torch.float64]
53
+ assert ext_est.dtype in [torch.float32, torch.float64]
54
+ assert ext_ref.requires_grad is False
55
+ assert ext_est.requires_grad is False
56
+ rots, trans, scales = [], [], []
57
+ for b in range(ext_ref.shape[0]):
58
+ r, t, s = align_poses_umeyama(ext_ref[b].cpu().numpy(), ext_est[b].cpu().numpy())
59
+ rots.append(torch.from_numpy(r).to(device=device, dtype=dtype))
60
+ trans.append(torch.from_numpy(t).to(device=device, dtype=dtype))
61
+ scales.append(torch.tensor(s, device=device, dtype=dtype))
62
+ return torch.stack(rots), torch.stack(trans), torch.stack(scales)
63
+
64
+
65
+ # Dependencies: affine_inverse_np, PosePath3D (maintain consistency with your existing project)
66
+
67
+
68
+ def _to44(ext):
69
+ if ext.shape[1] == 3:
70
+ out = np.eye(4)[None].repeat(len(ext), 0)
71
+ out[:, :3, :4] = ext
72
+ return out
73
+ return ext
74
+
75
+
76
+ def _poses_from_ext(ext_ref, ext_est):
77
+ ext_ref = _to44(ext_ref)
78
+ ext_est = _to44(ext_est)
79
+ pose_ref = affine_inverse_np(ext_ref)
80
+ pose_est = affine_inverse_np(ext_est)
81
+ return pose_ref, pose_est
82
+
83
+
84
+ def _umeyama_sim3_from_paths(pose_ref, pose_est):
85
+ path_ref = PosePath3D(poses_se3=pose_ref.copy())
86
+ path_est = PosePath3D(poses_se3=pose_est.copy())
87
+ r, t, s = path_est.align(path_ref, correct_scale=True)
88
+ pose_est_aligned = np.stack(path_est.poses_se3)
89
+ return r, t, s, pose_est_aligned
90
+
91
+
92
+ def _apply_sim3_to_poses(poses, r, t, s):
93
+ out = poses.copy()
94
+ Ri = poses[:, :3, :3]
95
+ ti = poses[:, :3, 3]
96
+ out[:, :3, :3] = r @ Ri
97
+ out[:, :3, 3] = (r @ (s * ti.T)).T + t
98
+ return out
99
+
100
+
101
+ def _median_nn_thresh(pose_ref, pose_est_aligned):
102
+ P_ref = pose_ref[:, :3, 3]
103
+ P_est = pose_est_aligned[:, :3, 3]
104
+ dists = []
105
+ for p in P_est:
106
+ dd = np.linalg.norm(P_ref - p[None, :], axis=1)
107
+ dists.append(dd.min())
108
+ return float(np.median(dists)) if dists else 0.0
109
+
110
+
111
+ def _ransac_align_sim3(
112
+ pose_ref, pose_est, sub_n=None, inlier_thresh=None, max_iters=10, random_state=None
113
+ ):
114
+ rng = np.random.default_rng(random_state)
115
+ N = pose_ref.shape[0]
116
+ idx_all = np.arange(N)
117
+ if sub_n is None:
118
+ sub_n = max(3, (N + 1) // 2)
119
+ else:
120
+ sub_n = max(3, min(sub_n, N))
121
+
122
+ # Pre-alignment + default threshold
123
+ r0, t0, s0, pose_est0 = _umeyama_sim3_from_paths(pose_ref, pose_est)
124
+ if inlier_thresh is None:
125
+ inlier_thresh = _median_nn_thresh(pose_ref, pose_est0)
126
+
127
+ P_ref_all = pose_ref[:, :3, 3]
128
+
129
+ best_model = (r0, t0, s0)
130
+ best_inliers = None
131
+ best_score = (-1, np.inf) # (num_inliers, mean_err)
132
+
133
+ for _ in range(max_iters):
134
+ sample = rng.choice(idx_all, size=sub_n, replace=False)
135
+ try:
136
+ r, t, s, _ = _umeyama_sim3_from_paths(pose_ref[sample], pose_est[sample])
137
+ except Exception:
138
+ continue
139
+ pose_h = _apply_sim3_to_poses(pose_est, r, t, s)
140
+ P_h = pose_h[:, :3, 3]
141
+ errs = np.linalg.norm(P_h - P_ref_all, axis=1) # Match by same index
142
+ inliers = errs <= inlier_thresh
143
+ k = int(inliers.sum())
144
+ mean_err = float(errs[inliers].mean()) if k > 0 else np.inf
145
+ if (k > best_score[0]) or (k == best_score[0] and mean_err < best_score[1]):
146
+ best_score = (k, mean_err)
147
+ best_model = (r, t, s)
148
+ best_inliers = inliers
149
+
150
+ # Fit again with best inliers
151
+ if best_inliers is not None and best_inliers.sum() >= 3:
152
+ r, t, s, _ = _umeyama_sim3_from_paths(pose_ref[best_inliers], pose_est[best_inliers])
153
+ else:
154
+ r, t, s = best_model
155
+ return r, t, s
156
+
157
+
158
+ def align_poses_umeyama(
159
+ ext_ref: np.ndarray,
160
+ ext_est: np.ndarray,
161
+ return_aligned=False,
162
+ ransac=False,
163
+ sub_n=None,
164
+ inlier_thresh=None,
165
+ ransac_max_iters=10,
166
+ random_state=None,
167
+ ):
168
+ """
169
+ Align estimated trajectory to reference using Umeyama Sim(3).
170
+ Default no RANSAC; if ransac=True, use RANSAC (max iterations default 10).
171
+ - sub_n defaults to half the number of frames (rounded up, at least 3)
172
+ - inlier_thresh defaults to median of "distance from each estimated pose to
173
+ nearest reference pose after pre-alignment"
174
+ Returns rotation (3x3), translation (3,), scale; optionally returns aligned extrinsics (4x4).
175
+ """
176
+ pose_ref, pose_est = _poses_from_ext(ext_ref, ext_est)
177
+
178
+ if not ransac:
179
+ r, t, s, pose_est_aligned = _umeyama_sim3_from_paths(pose_ref, pose_est)
180
+ else:
181
+ r, t, s = _ransac_align_sim3(
182
+ pose_ref,
183
+ pose_est,
184
+ sub_n=sub_n,
185
+ inlier_thresh=inlier_thresh,
186
+ max_iters=ransac_max_iters,
187
+ random_state=random_state,
188
+ )
189
+ pose_est_aligned = _apply_sim3_to_poses(pose_est, r, t, s)
190
+
191
+ if return_aligned:
192
+ ext_est_aligned = affine_inverse_np(pose_est_aligned)
193
+ return r, t, s, ext_est_aligned
194
+ return r, t, s
195
+
196
+
197
+ # def align_poses_umeyama(ext_ref: np.ndarray, ext_est: np.ndarray, return_aligned=False):
198
+ # """
199
+ # Align estimated trajectory to reference trajectory using Umeyama Sim(3)
200
+ # alignment (via evo PosePath3D). # noqa
201
+ # Returns rotation, translation, and scale.
202
+ # """
203
+ # # If input extrinsics are 3x4, convert to 4x4 by padding
204
+ # if ext_ref.shape[1] == 3:
205
+ # ext_ref_ = np.eye(4)[None].repeat(len(ext_ref), 0)
206
+ # ext_ref_[:, :3] = ext_ref
207
+ # ext_ref = ext_ref_
208
+ # if ext_est.shape[1] == 3:
209
+ # ext_est_ = np.eye(4)[None].repeat(len(ext_est), 0)
210
+ # ext_est_[:, :3] = ext_est
211
+ # ext_est = ext_est_
212
+
213
+ # # Convert to camera poses (inverse extrinsics)
214
+ # pose_ref = affine_inverse_np(ext_ref)
215
+ # pose_est = affine_inverse_np(ext_est)
216
+
217
+ # # Create evo PosePath3D objects
218
+ # path_ref = PosePath3D(poses_se3=pose_ref)
219
+ # path_est = PosePath3D(poses_se3=pose_est)
220
+ # r, t, s = path_est.align(path_ref, correct_scale=True)
221
+ # if return_aligned:
222
+ # return r, t, s, affine_inverse_np(np.stack(path_est.poses_se3))
223
+ # else:
224
+ # return r, t, s
225
+
226
+
227
+ def apply_umeyama_alignment_to_ext(
228
+ rot: np.ndarray, # (3,3)
229
+ trans: np.ndarray, # (3,) or (1,3)
230
+ scale: float,
231
+ ext_est: np.ndarray, # (...,4,4) or (...,3,4)
232
+ ) -> np.ndarray:
233
+ """
234
+ Apply Sim(3) (R, t, s) to a batch of world-to-camera extrinsics ext_est.
235
+ Returns the aligned extrinsics, with the same shape as input.
236
+ """
237
+
238
+ # Allow 3x4 extrinsics: pad to 4x4
239
+ if ext_est.shape[-2:] == (3, 4):
240
+ pad = np.zeros((*ext_est.shape[:-2], 4, 4), dtype=ext_est.dtype)
241
+ pad[..., :3, :4] = ext_est
242
+ pad[..., 3, 3] = 1.0
243
+ ext_est = pad
244
+
245
+ # Convert world-to-camera to camera-to-world
246
+ pose_est = affine_inverse_np(ext_est) # (...,4,4)
247
+ R_e = pose_est[..., :3, :3] # (...,3,3)
248
+ t_e = pose_est[..., :3, 3] # (...,3)
249
+
250
+ # Apply Sim(3) transformation
251
+ R_a = np.einsum("ij,...jk->...ik", rot, R_e) # (...,3,3)
252
+ t_a = scale * np.einsum("ij,...j->...i", rot, t_e) + trans # (...,3)
253
+
254
+ # Assemble the transformed pose
255
+ pose_a = np.zeros_like(pose_est)
256
+ pose_a[..., :3, :3] = R_a
257
+ pose_a[..., :3, 3] = t_a
258
+ pose_a[..., 3, 3] = 1.0
259
+
260
+ # Convert back to world-to-camera
261
+ return affine_inverse_np(pose_a)
262
+
263
+
264
+ def transform_points_sim3(points, rot, trans, scale, inverse=False):
265
+ """
266
+ Sim(3) transform point cloud
267
+ points: (N, 3)
268
+ rot: (3, 3)
269
+ trans: (3,) or (1, 3)
270
+ scale: float
271
+ inverse: Whether to do inverse transform (ref->est)
272
+ Returns: (N, 3)
273
+ """
274
+ if not inverse:
275
+ # Forward: est -> ref
276
+ return scale * (points @ rot.T) + trans
277
+ else:
278
+ # Inverse: ref -> est
279
+ return ((points - trans) @ rot) / scale
280
+
281
+
282
+ def _rand_rot():
283
+ u1, u2, u3 = np.random.rand(3)
284
+ q = np.array(
285
+ [
286
+ np.sqrt(1 - u1) * np.sin(2 * np.math.pi * u2),
287
+ np.sqrt(1 - u1) * np.cos(2 * np.math.pi * u2),
288
+ np.sqrt(u1) * np.sin(2 * np.math.pi * u3),
289
+ np.sqrt(u1) * np.cos(2 * np.math.pi * u3),
290
+ ]
291
+ )
292
+ w, x, y, z = q
293
+ return np.array(
294
+ [
295
+ [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
296
+ [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
297
+ [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
298
+ ]
299
+ )
300
+
301
+
302
+ def _rand_pose():
303
+ R, t = _rand_rot(), np.random.randn(3)
304
+ P = np.eye(4)
305
+ P[:3, :3] = R
306
+ P[:3, 3] = t
307
+ return P
308
+
309
+
310
+ if __name__ == "__main__":
311
+ np.random.seed(42)
312
+ # 1. Randomly generate reference trajectory and Sim(3)
313
+ N = 8
314
+ pose_ref = np.stack([_rand_pose() for _ in range(N)]) # (N,4,4) cam→world
315
+ rot_gt = _rand_rot()
316
+ scale_gt = 2.3
317
+ trans_gt = np.random.randn(3)
318
+ # 2. Generate estimated trajectory (apply Sim(3))
319
+ pose_est = np.zeros_like(pose_ref)
320
+ for i in range(N):
321
+ R = pose_ref[i][:3, :3]
322
+ t = pose_ref[i][:3, 3]
323
+ pose_est[i][:3, :3] = rot_gt @ R
324
+ pose_est[i][:3, 3] = scale_gt * (rot_gt @ t) + trans_gt
325
+ pose_est[i][3, 3] = 1.0
326
+ # 3. Get extrinsics (world->cam)
327
+ ext_ref = affine_inverse_np(pose_ref)
328
+ ext_est = affine_inverse_np(pose_est)
329
+ # 4. Use umeyama alignment, estimate Sim(3)
330
+ r_est, t_est, s_est = align_poses_umeyama(ext_ref, ext_est)
331
+ print("GT scale:", scale_gt, "Estimated:", s_est)
332
+ print("GT trans:", trans_gt, "Estimated:", t_est)
333
+ print("GT rot:\n", rot_gt, "\nEstimated:\n", r_est)
334
+ # 5. Random point cloud, in ref frame
335
+ num_points = 100
336
+ points_ref = np.random.randn(num_points, 3)
337
+ # 6. Use GT Sim(3) inverse transform to est frame
338
+ points_est = transform_points_sim3(points_ref, rot_gt, trans_gt, scale_gt, inverse=True)
339
+ # 7. Use estimated Sim(3) forward transform back to ref frame
340
+ points_ref_recovered = transform_points_sim3(points_est, r_est, t_est, s_est, inverse=False)
341
+ # 8. Check error
342
+ err = np.abs(points_ref_recovered - points_ref)
343
+ print("Point cloud sim3 transform error (mean abs):", err.mean())
344
+ print("Point cloud sim3 transform error (max abs):", err.max())
345
+ assert err.mean() < 1e-6, "Mean sim3 transform error too large!"
346
+ assert err.max() < 1e-5, "Max sim3 transform error too large!"
347
+ print("Sim(3) point cloud transform & alignment test passed!")