ffgs 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. ffgs/__init__.py +39 -0
  2. ffgs/dynamic.py +239 -0
  3. ffgs/geometry.py +418 -0
  4. ffgs/image.py +204 -0
  5. ffgs/models/__init__.py +2 -0
  6. ffgs/models/anysplat/LICENSE +1120 -0
  7. ffgs/models/anysplat/__init__.py +49 -0
  8. ffgs/models/anysplat/encoder.py +372 -0
  9. ffgs/models/anysplat/evaluation.py +98 -0
  10. ffgs/models/anysplat/gaussian_adapter.py +96 -0
  11. ffgs/models/anysplat/gs_head.py +166 -0
  12. ffgs/models/anysplat/modeling.py +95 -0
  13. ffgs/models/anysplat/processing.py +249 -0
  14. ffgs/models/anysplat/vggt/__init__.py +0 -0
  15. ffgs/models/anysplat/vggt/heads/__init__.py +0 -0
  16. ffgs/models/anysplat/vggt/heads/camera_head.py +181 -0
  17. ffgs/models/anysplat/vggt/heads/dpt_head.py +612 -0
  18. ffgs/models/anysplat/vggt/heads/head_act.py +134 -0
  19. ffgs/models/anysplat/vggt/heads/utils.py +126 -0
  20. ffgs/models/anysplat/vggt/layers/__init__.py +28 -0
  21. ffgs/models/anysplat/vggt/layers/attention.py +92 -0
  22. ffgs/models/anysplat/vggt/layers/block.py +157 -0
  23. ffgs/models/anysplat/vggt/layers/drop_path.py +42 -0
  24. ffgs/models/anysplat/vggt/layers/layer_scale.py +32 -0
  25. ffgs/models/anysplat/vggt/layers/mlp.py +45 -0
  26. ffgs/models/anysplat/vggt/layers/patch_embed.py +106 -0
  27. ffgs/models/anysplat/vggt/layers/rope.py +211 -0
  28. ffgs/models/anysplat/vggt/layers/swiglu_ffn.py +77 -0
  29. ffgs/models/anysplat/vggt/layers/vision_transformer.py +463 -0
  30. ffgs/models/anysplat/vggt/models/__init__.py +0 -0
  31. ffgs/models/anysplat/vggt/models/aggregator.py +398 -0
  32. ffgs/models/anysplat/vggt/utils/__init__.py +0 -0
  33. ffgs/models/anysplat/vggt/utils/geometry.py +230 -0
  34. ffgs/models/anysplat/vggt/utils/pose_enc.py +144 -0
  35. ffgs/models/anysplat/vggt/utils/rotation.py +148 -0
  36. ffgs/models/tokengs/__init__.py +43 -0
  37. ffgs/models/tokengs/activations.py +135 -0
  38. ffgs/models/tokengs/attention.py +189 -0
  39. ffgs/models/tokengs/enc_dec.py +323 -0
  40. ffgs/models/tokengs/modeling.py +173 -0
  41. ffgs/models/tokengs/processing.py +339 -0
  42. ffgs/pipeline.py +341 -0
  43. ffgs/processor.py +236 -0
  44. ffgs/registry.py +132 -0
  45. ffgs/render.py +111 -0
  46. ffgs/types.py +291 -0
  47. ffgs/zoo/__init__.py +234 -0
  48. ffgs/zoo/anysplat/default.json +34 -0
  49. ffgs/zoo/tokengs/dl3dv-2v.json +29 -0
  50. ffgs/zoo/tokengs/dl3dv-4v.json +29 -0
  51. ffgs/zoo/tokengs/dl3dv-6v.json +29 -0
  52. ffgs/zoo/tokengs/dl3dv-base.json +29 -0
  53. ffgs/zoo/tokengs/dl3dv-latent-2v-lpips.json +48 -0
  54. ffgs/zoo/tokengs/dl3dv-latent-2v-ssim.json +48 -0
  55. ffgs/zoo/tokengs/dl3dv-latent-4v-lpips.json +48 -0
  56. ffgs/zoo/tokengs/dl3dv-latent-4v-ssim.json +48 -0
  57. ffgs/zoo/tokengs/dl3dv-latent-6v-lpips.json +48 -0
  58. ffgs/zoo/tokengs/dl3dv-latent-6v-ssim.json +48 -0
  59. ffgs-0.1.0.dev0.dist-info/METADATA +320 -0
  60. ffgs-0.1.0.dev0.dist-info/RECORD +63 -0
  61. ffgs-0.1.0.dev0.dist-info/WHEEL +4 -0
  62. ffgs-0.1.0.dev0.dist-info/licenses/LICENSE +237 -0
  63. ffgs-0.1.0.dev0.dist-info/licenses/src/ffgs/models/anysplat/LICENSE +1120 -0
@@ -0,0 +1,49 @@
1
+ """AnySplat (Jiang, Mao, Xu, Lin, Xu, Gong, Dai, Wang, Lin, Zhou, Lin; ACM TOG /
2
+ SIGGRAPH Asia 2025), vendored from the official code at
3
+ https://github.com/InternRobotics/AnySplat (5f5e208).
4
+
5
+ Non-commercial: this directory holds MIT, CC BY-NC 4.0 (VGGT, as vendored by
6
+ AnySplat), CC BY-NC-SA 4.0 (Naver) and Apache-2.0 code, and the released weights
7
+ build on VGGT-1B; see LICENSE in this directory.
8
+
9
+ `model_type` "anysplat". Imported on first use: `import ffgs` does not import it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from ...registry import ModelSpec, register_model
17
+ from .evaluation import predict_target_cameras, split_llffhold, target_cameras
18
+ from .modeling import AnySplat
19
+ from .processing import AnySplatProcessor, AnySplatProcessorConfig
20
+
21
+ MODEL_TYPE = "anysplat"
22
+
23
+
24
+ def convert_state_dict(checkpoint: dict[str, Any]) -> dict[str, Any]:
25
+ """The official checkpoint (`lhjiang/anysplat` model.safetensors) -> `AnySplat`:
26
+ the parameter names are the upstream ones, so it is used as it is."""
27
+ return dict(checkpoint)
28
+
29
+
30
+ SPEC = register_model(
31
+ ModelSpec(
32
+ model_type=MODEL_TYPE,
33
+ model_cls=AnySplat,
34
+ processor_cls=AnySplatProcessor,
35
+ convert_state_dict=convert_state_dict,
36
+ )
37
+ )
38
+
39
+ __all__ = [
40
+ "MODEL_TYPE",
41
+ "SPEC",
42
+ "AnySplat",
43
+ "AnySplatProcessor",
44
+ "AnySplatProcessorConfig",
45
+ "convert_state_dict",
46
+ "predict_target_cameras",
47
+ "split_llffhold",
48
+ "target_cameras",
49
+ ]
@@ -0,0 +1,372 @@
1
+ # Copyright (c) 2025 Lihan Jiang and Yucheng Mao
2
+ # SPDX-License-Identifier: MIT (see the AnySplat section of LICENSE, this directory)
3
+ #
4
+ # Modified by the libgs contributors, 2026, from src/model/encoder/anysplat.py and
5
+ # src/model/encoder/encoder.py of https://github.com/InternRobotics/AnySplat
6
+ # (5f5e208): inference-only subset of `EncoderAnySplat` (pred_head_type="depth",
7
+ # pose_free=True, the released configuration).
8
+ # - The VGGT modules are built directly from the architecture arguments instead of
9
+ # `VGGT.from_pretrained("facebook/VGGT-1B")` (whose weights the AnySplat checkpoint
10
+ # replaces anyway); the point / track heads it also built are not. The Gaussian
11
+ # head's `dim_in` is `2 * embed_dim` (upstream: the literal 2048, VGGT-1B's).
12
+ # - torch_scatter's `scatter_max` / `scatter_add` replaced by the same reductions in
13
+ # torch (`scatter_reduce_("amax")`; `scatter_add_` on a broadcast index, which is
14
+ # what torch_scatter's `scatter_add` runs).
15
+ # - The aggregated tokens are made float32 before the float32 heads: a no-op on
16
+ # CUDA, where the bfloat16 autocast already returns them in float32, and what lets
17
+ # the model run on other devices, where that autocast is inactive.
18
+ # - `infos["num_gaussians"]` records each sample's Gaussian count: for batches,
19
+ # upstream pads the samples to the largest count (`pad_tensor_list`).
20
+ # - Removed: distillation, parameter freezing, `normalize_pts3d` /
21
+ # `align_pts_all_with_pts3d`, the point-head branch, visualization dumps, the data
22
+ # shim, the `print`s and the unconditional `torch.cuda.empty_cache()`; einops /
23
+ # jaxtyping no longer needed. `EncoderOutput` keeps the fields used here.
24
+
25
+ """AnySplat's encoder: VGGT aggregator, camera and depth heads, a DPT head for the
26
+ Gaussian parameters, and voxel fusion of the per-pixel Gaussians."""
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ import torch
34
+ from torch import Tensor, nn
35
+
36
+ from .gaussian_adapter import GaussianAdapterCfg, Gaussians, UnifiedGaussianAdapter
37
+ from .gs_head import VGGT_DPT_GS_Head
38
+ from .vggt.heads.camera_head import CameraHead
39
+ from .vggt.heads.dpt_head import DPTHead
40
+ from .vggt.models.aggregator import Aggregator
41
+ from .vggt.utils.geometry import batchify_unproject_depth_map_to_point_map
42
+ from .vggt.utils.pose_enc import pose_encoding_to_extri_intri
43
+
44
+ inf = float("inf")
45
+
46
+
47
+ @dataclass
48
+ class OpacityMappingCfg:
49
+ initial: float
50
+ final: float
51
+ warm_up: int
52
+
53
+
54
+ @dataclass
55
+ class GSHeadParams:
56
+ dec_depth: int = 23
57
+ patch_size: tuple[int, int] = (14, 14)
58
+ enc_embed_dim: int = 2048
59
+ dec_embed_dim: int = 2048
60
+ feature_dim: int = 256
61
+ depth_mode = ("exp", -inf, inf)
62
+ conf_mode = True
63
+
64
+
65
+ @dataclass
66
+ class EncoderAnySplatCfg:
67
+ """The inference fields of upstream's `EncoderAnySplatCfg` (same names and
68
+ defaults), and the VGGT architecture (ffgs: upstream fixes it to VGGT-1B)."""
69
+
70
+ voxel_size: float
71
+ gaussian_adapter: GaussianAdapterCfg
72
+ opacity_mapping: OpacityMappingCfg
73
+ gs_prune: bool = False
74
+ opacity_threshold: float = 0.001
75
+ gs_keep_ratio: float = 1.0
76
+ render_conf: bool = False
77
+ opacity_conf: bool = False
78
+ conf_threshold: float = 0.1
79
+ intermediate_layer_idx: list[int] | None = None
80
+ voxelize: bool = False
81
+ # VGGT-1B: `VGGT(img_size=518, patch_size=14, embed_dim=1024)`; extra keyword
82
+ # arguments of each module (all empty for the released model).
83
+ img_size: int = 518
84
+ patch_size: int = 14
85
+ embed_dim: int = 1024
86
+ aggregator: dict[str, Any] = field(default_factory=dict)
87
+ camera_head: dict[str, Any] = field(default_factory=dict)
88
+ depth_head: dict[str, Any] = field(default_factory=dict)
89
+ gs_head: dict[str, Any] = field(default_factory=dict)
90
+
91
+
92
+ @dataclass
93
+ class EncoderOutput:
94
+ gaussians: Gaussians
95
+ pred_pose_enc_list: list[Tensor] | None
96
+ pred_context_pose: dict | None
97
+ depth_dict: dict | None
98
+ infos: dict | None
99
+
100
+
101
+ class EncoderAnySplat(nn.Module):
102
+ def __init__(self, cfg: EncoderAnySplatCfg) -> None:
103
+ super().__init__()
104
+ self.cfg = cfg
105
+ dim_in = 2 * cfg.embed_dim
106
+ self.aggregator = Aggregator(
107
+ img_size=cfg.img_size,
108
+ patch_size=cfg.patch_size,
109
+ embed_dim=cfg.embed_dim,
110
+ **cfg.aggregator,
111
+ ).to(torch.bfloat16)
112
+ self.camera_head = CameraHead(dim_in=dim_in, **cfg.camera_head)
113
+ self.depth_head = DPTHead(
114
+ dim_in=dim_in,
115
+ output_dim=2,
116
+ activation="exp",
117
+ conf_activation="expp1",
118
+ **cfg.depth_head,
119
+ )
120
+
121
+ self.gaussian_adapter = UnifiedGaussianAdapter(cfg.gaussian_adapter)
122
+
123
+ self.raw_gs_dim = 1 + self.gaussian_adapter.d_in # 1 for opacity
124
+ self.voxel_size = cfg.voxel_size
125
+ # fake backbone for head parameters
126
+ head_params = GSHeadParams()
127
+ self.gaussian_param_head = VGGT_DPT_GS_Head(
128
+ dim_in=dim_in,
129
+ patch_size=head_params.patch_size,
130
+ output_dim=self.raw_gs_dim + 1,
131
+ activation="norm_exp",
132
+ conf_activation="expp1",
133
+ features=head_params.feature_dim,
134
+ **cfg.gs_head,
135
+ )
136
+
137
+ def map_pdf_to_opacity(
138
+ self,
139
+ pdf: Tensor,
140
+ global_step: int,
141
+ ) -> Tensor:
142
+ # https://www.desmos.com/calculator/opvwti3ba9
143
+
144
+ # Figure out the exponent.
145
+ cfg = self.cfg.opacity_mapping
146
+ x = cfg.initial + min(global_step / cfg.warm_up, 1) * (cfg.final - cfg.initial)
147
+ exponent = 2**x
148
+
149
+ # Map the probability density to an opacity.
150
+ return 0.5 * (1 - (1 - pdf) ** exponent + pdf ** (1 / exponent))
151
+
152
+ def pad_tensor_list(self, tensor_list, pad_shape, value=0.0):
153
+ padded = []
154
+ for t in tensor_list:
155
+ pad_len = pad_shape[0] - t.shape[0]
156
+ if pad_len > 0:
157
+ padding = torch.full(
158
+ (pad_len, *t.shape[1:]), value, device=t.device, dtype=t.dtype
159
+ )
160
+ t = torch.cat([t, padding], dim=0)
161
+ padded.append(t)
162
+ return torch.stack(padded)
163
+
164
+ def voxelizaton_with_fusion(self, img_feat, pts3d, voxel_size, conf=None):
165
+ # img_feat: B*V, C, H, W
166
+ # pts3d: B*V, 3, H, W
167
+ V, C, H, W = img_feat.shape
168
+ pts3d_flatten = pts3d.permute(0, 2, 3, 1).flatten(0, 2)
169
+
170
+ voxel_indices = (pts3d_flatten / voxel_size).round().int() # [B*V*N, 3]
171
+ unique_voxels, inverse_indices, counts = torch.unique(
172
+ voxel_indices, dim=0, return_inverse=True, return_counts=True
173
+ )
174
+
175
+ # Flatten confidence scores and features
176
+ conf_flat = conf.flatten() # [B*V*N]
177
+ anchor_feats_flat = img_feat.permute(0, 2, 3, 1).flatten(0, 2) # [B*V*N, ...]
178
+
179
+ # Compute softmax weights per voxel
180
+ conf_voxel_max = _scatter_max(conf_flat, inverse_indices, len(unique_voxels))
181
+ conf_exp = torch.exp(conf_flat - conf_voxel_max[inverse_indices])
182
+ voxel_weights = _scatter_add(
183
+ conf_exp, inverse_indices, len(unique_voxels)
184
+ ) # [num_unique_voxels]
185
+ weights = (conf_exp / (voxel_weights[inverse_indices] + 1e-6)).unsqueeze(
186
+ -1
187
+ ) # [B*V*N, 1]
188
+
189
+ # Compute weighted average of positions and features
190
+ weighted_pts = pts3d_flatten * weights
191
+ weighted_feats = anchor_feats_flat.squeeze(1) * weights
192
+
193
+ # Aggregate per voxel
194
+ voxel_pts = _scatter_add(
195
+ weighted_pts, inverse_indices, len(unique_voxels)
196
+ ) # [num_unique_voxels, 3]
197
+ voxel_feats = _scatter_add(
198
+ weighted_feats, inverse_indices, len(unique_voxels)
199
+ ) # [num_unique_voxels, feat_dim]
200
+
201
+ return voxel_pts, voxel_feats
202
+
203
+ def forward(
204
+ self,
205
+ image: torch.Tensor,
206
+ global_step: int = 0,
207
+ ) -> EncoderOutput:
208
+ device = image.device
209
+ b, v, _, h, w = image.shape
210
+
211
+ with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16):
212
+ aggregated_tokens_list, patch_start_idx = self.aggregator(
213
+ image.to(torch.bfloat16),
214
+ intermediate_layer_idx=self.cfg.intermediate_layer_idx,
215
+ )
216
+ aggregated_tokens_list = [t.float() for t in aggregated_tokens_list]
217
+
218
+ with torch.amp.autocast("cuda", enabled=False):
219
+ pred_pose_enc_list = self.camera_head(aggregated_tokens_list)
220
+ last_pred_pose_enc = pred_pose_enc_list[-1]
221
+ extrinsic, intrinsic = pose_encoding_to_extri_intri(
222
+ last_pred_pose_enc, image.shape[-2:]
223
+ ) # only for debug
224
+
225
+ depth_map, depth_conf = self.depth_head(
226
+ aggregated_tokens_list,
227
+ images=image,
228
+ patch_start_idx=patch_start_idx,
229
+ )
230
+ pts_all = batchify_unproject_depth_map_to_point_map(
231
+ depth_map, extrinsic, intrinsic
232
+ )
233
+
234
+ if self.cfg.render_conf:
235
+ conf_valid = torch.quantile(
236
+ depth_conf.flatten(0, 1), self.cfg.conf_threshold
237
+ )
238
+ conf_valid_mask = depth_conf > conf_valid
239
+ else:
240
+ conf_valid_mask = torch.ones_like(depth_conf, dtype=torch.bool)
241
+
242
+ # dpt style gs_head input format
243
+ out = self.gaussian_param_head(
244
+ aggregated_tokens_list,
245
+ pts_all.flatten(0, 1).permute(0, 3, 1, 2),
246
+ image,
247
+ patch_start_idx=patch_start_idx,
248
+ image_size=(h, w),
249
+ )
250
+
251
+ del aggregated_tokens_list, patch_start_idx
252
+
253
+ pts_flat = pts_all.flatten(2, 3)
254
+ scene_scale = pts_flat.norm(dim=-1).mean().clip(min=1e-8)
255
+
256
+ anchor_feats, conf = out[:, :, : self.raw_gs_dim], out[:, :, self.raw_gs_dim]
257
+
258
+ neural_feats_list, neural_pts_list = [], []
259
+ if self.cfg.voxelize:
260
+ for b_i in range(b):
261
+ neural_pts, neural_feats = self.voxelizaton_with_fusion(
262
+ anchor_feats[b_i],
263
+ pts_all[b_i].permute(0, 3, 1, 2).contiguous(),
264
+ self.voxel_size,
265
+ conf=conf[b_i],
266
+ )
267
+ neural_feats_list.append(neural_feats)
268
+ neural_pts_list.append(neural_pts)
269
+ else:
270
+ for b_i in range(b):
271
+ neural_feats_list.append(
272
+ anchor_feats[b_i].permute(0, 2, 3, 1)[conf_valid_mask[b_i]]
273
+ )
274
+ neural_pts_list.append(pts_all[b_i][conf_valid_mask[b_i]])
275
+
276
+ # ffgs: the per-sample Gaussian counts, before padding to the batch maximum.
277
+ num_gaussians = [f.shape[0] for f in neural_feats_list]
278
+ max_voxels = max(f.shape[0] for f in neural_feats_list)
279
+ neural_feats = self.pad_tensor_list(
280
+ neural_feats_list, (max_voxels,), value=-1e10
281
+ )
282
+
283
+ neural_pts = self.pad_tensor_list(
284
+ neural_pts_list, (max_voxels,), -1e4
285
+ ) # -1 == invalid voxel
286
+
287
+ depths = neural_pts[..., -1].unsqueeze(-1)
288
+ densities = neural_feats[..., 0].sigmoid()
289
+
290
+ assert len(densities.shape) == 2, "the shape of densities should be (B, N)"
291
+ assert neural_pts.shape[1] > 1, "the number of voxels should be greater than 1"
292
+
293
+ opacity = self.map_pdf_to_opacity(densities, global_step).squeeze(-1)
294
+ if self.cfg.opacity_conf:
295
+ shift = torch.quantile(depth_conf, self.cfg.conf_threshold)
296
+ opacity = opacity * torch.sigmoid(depth_conf - shift)[
297
+ conf_valid_mask
298
+ ].unsqueeze(
299
+ 0
300
+ ) # little bit hacky
301
+
302
+ # GS Prune, but only works when bs = 1
303
+ # if want to support bs > 1, need to random prune gaussians based on the rank of opacity like LongLRM
304
+ # Note: we not prune gaussians here, but we will try it in the future
305
+ if self.cfg.gs_prune and b == 1:
306
+ opacity_threshold = self.cfg.opacity_threshold
307
+ gaussian_usage = opacity > opacity_threshold # (B, N)
308
+
309
+ if (gaussian_usage.sum() / gaussian_usage.numel()) > self.cfg.gs_keep_ratio:
310
+ # rank by opacity
311
+ num_keep = int(gaussian_usage.shape[1] * self.cfg.gs_keep_ratio)
312
+ idx_sort = opacity.argsort(dim=1, descending=True)
313
+ keep_idx = idx_sort[:, :num_keep]
314
+ gaussian_usage = torch.zeros_like(gaussian_usage, dtype=torch.bool)
315
+ gaussian_usage.scatter_(1, keep_idx, True)
316
+
317
+ neural_pts = neural_pts[gaussian_usage].view(b, -1, 3).contiguous()
318
+ depths = depths[gaussian_usage].view(b, -1, 1).contiguous()
319
+ neural_feats = (
320
+ neural_feats[gaussian_usage].view(b, -1, self.raw_gs_dim).contiguous()
321
+ )
322
+ opacity = opacity[gaussian_usage].view(b, -1).contiguous()
323
+
324
+ gaussians = self.gaussian_adapter.forward(
325
+ neural_pts,
326
+ depths,
327
+ opacity,
328
+ neural_feats[..., 1:].squeeze(2),
329
+ )
330
+
331
+ infos = {}
332
+ infos["scene_scale"] = scene_scale
333
+ infos["voxelize_ratio"] = densities.shape[1] / (h * w * v)
334
+ if self.cfg.gs_prune and b == 1:
335
+ num_gaussians = [neural_pts.shape[1]]
336
+ infos["num_gaussians"] = torch.tensor(num_gaussians, device=device)
337
+
338
+ extrinsic_padding = (
339
+ torch.tensor([0, 0, 0, 1], device=device, dtype=extrinsic.dtype)
340
+ .view(1, 1, 1, 4)
341
+ .repeat(b, v, 1, 1)
342
+ )
343
+ intrinsic = intrinsic.clone() # Create a new tensor
344
+ intrinsic = torch.stack(
345
+ [intrinsic[:, :, 0] / w, intrinsic[:, :, 1] / h, intrinsic[:, :, 2]], dim=2
346
+ )
347
+
348
+ return EncoderOutput(
349
+ gaussians=gaussians,
350
+ pred_pose_enc_list=pred_pose_enc_list,
351
+ pred_context_pose=dict(
352
+ extrinsic=torch.cat([extrinsic, extrinsic_padding], dim=2).inverse(),
353
+ intrinsic=intrinsic,
354
+ ),
355
+ depth_dict=dict(depth=depth_map, conf_valid_mask=conf_valid_mask),
356
+ infos=infos,
357
+ )
358
+
359
+
360
+ def _scatter_max(src: Tensor, index: Tensor, size: int) -> Tensor:
361
+ """torch_scatter's `scatter_max(src, index, dim=0)[0]` for 1-D `src` whose
362
+ `index` hits every one of `size` slots (the maximum is exact in any order)."""
363
+ out = src.new_empty(size)
364
+ return out.scatter_reduce_(0, index, src, "amax", include_self=False)
365
+
366
+
367
+ def _scatter_add(src: Tensor, index: Tensor, size: int) -> Tensor:
368
+ """torch_scatter's `scatter_add(src, index, dim=0)`: its implementation, a
369
+ `scatter_add_` into zeros along an index broadcast to `src`."""
370
+ index = index.view(-1, *([1] * (src.dim() - 1))).expand_as(src)
371
+ out = src.new_zeros(size, *src.shape[1:])
372
+ return out.scatter_add_(0, index, src)
@@ -0,0 +1,98 @@
1
+ # Copyright (c) 2025 Lihan Jiang and Yucheng Mao
2
+ # SPDX-License-Identifier: MIT (see the AnySplat section of LICENSE, this directory)
3
+ #
4
+ # Modified by the libgs contributors, 2026, from src/eval_nvs.py of
5
+ # https://github.com/InternRobotics/AnySplat (5f5e208): the target-camera prediction
6
+ # of its `evaluate` as functions; the scale factor is taken per sample (upstream
7
+ # evaluates one scene at a time, where the two agree).
8
+
9
+ """The official novel-view-synthesis protocol of AnySplat (`src/eval_nvs.py`).
10
+
11
+ AnySplat predicts its cameras; the target views of an evaluation are not posed in
12
+ its frame. Upstream places them there by running the aggregator and camera head
13
+ once more, on the context and target images together, and rescaling the predicted
14
+ target translations by the ratio of the mean context translations of the two
15
+ predictions:
16
+
17
+ >>> out = pipe(context) # context views without c2w: out.frame is None
18
+ >>> cameras = target_cameras(pipe, out, context, targets)
19
+ >>> images = pipe.render(out, cameras)["images"]
20
+
21
+ Upstream holds out every `llffhold`-th image (`idx % 8 == 0`) as a target and uses
22
+ the others as context (`split_llffhold`).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import torch
28
+
29
+ from ...types import Cameras, Views
30
+ from .modeling import AnySplat
31
+ from .vggt.utils.pose_enc import pose_encoding_to_extri_intri
32
+
33
+
34
+ def split_llffhold(num_views: int, llffhold: int = 8) -> tuple[list[int], list[int]]:
35
+ """(context, target) view indices: targets are the indices divisible by
36
+ `llffhold`, as `eval_nvs.py` splits a scene."""
37
+ context = [i for i in range(num_views) if i % llffhold != 0]
38
+ target = [i for i in range(num_views) if i % llffhold == 0]
39
+ return context, target
40
+
41
+
42
+ def target_cameras(pipe, output, context: Views, targets: Views) -> Cameras:
43
+ """Cameras of `targets` in the model frame of `output` (= `pipe(context)`, run
44
+ without poses), by the official protocol. Render them with `pipe.render`."""
45
+ if output.frame is not None or output.space != "model":
46
+ raise ValueError(
47
+ "the official protocol places targets in the model's own frame: run "
48
+ "the pipeline on context views without c2w"
49
+ )
50
+ processor, device = pipe.processor, pipe.device
51
+ context_images = processor.preprocess(context, device).model_input
52
+ target_images = processor.preprocess(targets, device).model_input
53
+ return predict_target_cameras(
54
+ pipe.model, context_images, target_images, output.cameras.c2w
55
+ )
56
+
57
+
58
+ @torch.no_grad()
59
+ def predict_target_cameras(
60
+ model: AnySplat,
61
+ context_images: torch.Tensor,
62
+ target_images: torch.Tensor,
63
+ context_c2w: torch.Tensor,
64
+ ) -> Cameras:
65
+ """`eval_nvs.py`'s target cameras: model inputs [B, V, 3, H, W] of the context
66
+ and target views, and the context c2w [B, V, 4, 4] the encoder predicted for
67
+ the context views alone (`pred_context_pose["extrinsic"]`)."""
68
+ encoder = model.encoder
69
+ b, num_context = context_images.shape[:2]
70
+ images = torch.cat((context_images, target_images), dim=1).to(torch.bfloat16)
71
+ h, w = images.shape[-2:]
72
+ with torch.amp.autocast("cuda", enabled=False):
73
+ tokens, _ = encoder.aggregator(
74
+ images, intermediate_layer_idx=encoder.cfg.intermediate_layer_idx
75
+ )
76
+ tokens = [token.float() for token in tokens]
77
+ pose_enc = encoder.camera_head(tokens)[-1]
78
+ extrinsic, intrinsic = pose_encoding_to_extri_intri(pose_enc, (h, w))
79
+
80
+ padding = torch.tensor([0, 0, 0, 1], device=extrinsic.device, dtype=extrinsic.dtype)
81
+ padding = padding.view(1, 1, 1, 4).repeat(b, images.shape[1], 1, 1)
82
+ c2w = torch.cat([extrinsic, padding], dim=2).inverse()
83
+ intrinsic = intrinsic.clone()
84
+ intrinsic[:, :, 0] = intrinsic[:, :, 0] / w
85
+ intrinsic[:, :, 1] = intrinsic[:, :, 1] / h
86
+
87
+ # Upstream: the mean over all context translation components (signed).
88
+ scale = context_c2w[:, :, :3, 3].flatten(1).mean(1) / c2w[
89
+ :, :num_context, :3, 3
90
+ ].flatten(1).mean(1)
91
+ target_c2w = c2w[:, num_context:].clone()
92
+ target_c2w[..., :3, 3] = target_c2w[..., :3, 3] * scale[:, None, None]
93
+ return Cameras(
94
+ target_c2w.float(),
95
+ intrinsic[:, num_context:].float(),
96
+ (h, w),
97
+ normalized_intrinsics=True,
98
+ )
@@ -0,0 +1,96 @@
1
+ # Copyright (c) 2025 Lihan Jiang and Yucheng Mao
2
+ # SPDX-License-Identifier: MIT (see the AnySplat section of LICENSE, this directory)
3
+ #
4
+ # Modified by the libgs contributors, 2026, from
5
+ # src/model/encoder/common/gaussian_adapter.py and src/model/types.py of
6
+ # https://github.com/InternRobotics/AnySplat (5f5e208): inference-only subset. Only
7
+ # `UnifiedGaussianAdapter` (the adapter of `pose_free=True`) and what it uses from
8
+ # its base class are kept; the covariances (`build_covariance`) are not built, as
9
+ # ffgs renders from scales and quaternions; the unused `intrinsics` / `coordinates`
10
+ # arguments of `forward` dropped; einops' rearrange replaced by the equivalent
11
+ # `unflatten`, jaxtyping annotations by plain `Tensor`.
12
+
13
+ """Raw Gaussian features -> Gaussian parameters (AnySplat's pose-free adapter)."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from torch import Tensor, nn
22
+
23
+
24
+ @dataclass
25
+ class GaussianAdapterCfg:
26
+ gaussian_scale_min: float
27
+ gaussian_scale_max: float
28
+ sh_degree: int
29
+
30
+
31
+ @dataclass
32
+ class Gaussians:
33
+ """Upstream's Gaussians, without covariances. Quaternions are xyzw (scipy
34
+ order); harmonics are [batch, gaussian, 3, d_sh]."""
35
+
36
+ means: Tensor # [batch, gaussian, 3]
37
+ harmonics: Tensor # [batch, gaussian, 3, d_sh]
38
+ opacities: Tensor # [batch, gaussian]
39
+ scales: Tensor # [batch, gaussian, 3]
40
+ rotations: Tensor # [batch, gaussian, 4], xyzw
41
+
42
+
43
+ class GaussianAdapter(nn.Module):
44
+ cfg: GaussianAdapterCfg
45
+
46
+ def __init__(self, cfg: GaussianAdapterCfg):
47
+ super().__init__()
48
+ self.cfg = cfg
49
+
50
+ # Create a mask for the spherical harmonics coefficients. This ensures that at
51
+ # initialization, the coefficients are biased towards having a large DC
52
+ # component and small view-dependent components.
53
+ self.register_buffer(
54
+ "sh_mask",
55
+ torch.ones((self.d_sh,), dtype=torch.float32),
56
+ persistent=False,
57
+ )
58
+ for degree in range(1, self.cfg.sh_degree + 1):
59
+ self.sh_mask[degree**2 : (degree + 1) ** 2] = 0.1 * 0.25**degree
60
+
61
+ @property
62
+ def d_sh(self) -> int:
63
+ return (self.cfg.sh_degree + 1) ** 2
64
+
65
+ @property
66
+ def d_in(self) -> int:
67
+ return 7 + 3 * self.d_sh
68
+
69
+
70
+ class UnifiedGaussianAdapter(GaussianAdapter):
71
+ def forward(
72
+ self,
73
+ means: Tensor,
74
+ depths: Tensor,
75
+ opacities: Tensor,
76
+ raw_gaussians: Tensor,
77
+ eps: float = 1e-8,
78
+ ) -> Gaussians:
79
+ scales, rotations, sh = raw_gaussians.split((3, 4, 3 * self.d_sh), dim=-1)
80
+
81
+ scales = 0.001 * F.softplus(scales)
82
+ scales = scales.clamp_max(0.3)
83
+
84
+ # Normalize the quaternion features to yield a valid quaternion.
85
+ rotations = rotations / (rotations.norm(dim=-1, keepdim=True) + eps)
86
+
87
+ sh = sh.unflatten(-1, (3, self.d_sh))
88
+ sh = sh.broadcast_to((*opacities.shape, 3, self.d_sh)) * self.sh_mask
89
+
90
+ return Gaussians(
91
+ means=means.float(),
92
+ harmonics=sh.float(),
93
+ opacities=opacities.float(),
94
+ scales=scales.float(),
95
+ rotations=rotations.float(),
96
+ )