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,339 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
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
+ # Modified by the libgs contributors, 2026: the evaluation-time data path of
17
+ # https://github.com/nv-tlabs/TokenGS (b16269c) as an ffgs `Processor` --
18
+ # `ImageTransform` and `ray_condition` from tokengs/utils/data.py, camera
19
+ # normalisation and input normalisation from tokengs/data/provider.py (`Provider.
20
+ # _preprocess`), render settings from tokengs/rendering/gs.py and tokengs/options.py.
21
+ # Training-only branches (flips, margins, timestamps, depth-based scaling) removed.
22
+
23
+ """TokenGS pre / post-processing: the author's evaluation data path.
24
+
25
+ Per sample, as `tokengs/data/provider.py` does at evaluation:
26
+
27
+ 1. crop the largest centred region with the aspect ratio of `image_shape`, then
28
+ resize it to `image_shape` (torchvision bilinear, antialiased). The intrinsics
29
+ follow the upstream arithmetic exactly, including its half-pixel offset on odd
30
+ crop margins (the pixels are cropped at the rounded offset, K is shifted by the
31
+ exact one);
32
+ 2. cameras relative to the first input view (`"first_cam"`) or to the mean input
33
+ camera (`"mean_cam"`), translations times `scene_scale`;
34
+ 3. model input: ImageNet-normalised images and per-pixel Plücker rays.
35
+
36
+ The Gaussians come out in that frame (`GSOutput.to_world()` maps them to the
37
+ caller's world); colours are RGB (no SH). Rendering uses the upstream background and near / far
38
+ planes. `crop_mode="pad"` / `"none"` replace step 1 with the ffgs rules.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import warnings
44
+ from dataclasses import dataclass, field
45
+
46
+ import torch
47
+ import torch.nn.functional as F
48
+ import torchvision.transforms.functional as TF
49
+
50
+ from ...geometry import ModelFrame, anchored_frame
51
+ from ...image import fit_images, fit_intrinsics, rescale
52
+ from ...processor import ImageFitConfig, Prepared, Processor
53
+ from ...types import Cameras, Gaussians, Views
54
+
55
+ IMAGENET_MEAN = (0.485, 0.456, 0.406)
56
+ IMAGENET_STD = (0.229, 0.224, 0.225)
57
+ CAMERA_NORMALIZATIONS = ("first_cam", "mean_cam")
58
+ _AUTOCAST = {"bfloat16": torch.bfloat16, "float16": torch.float16}
59
+
60
+
61
+ @dataclass
62
+ class TokenGSProcessorConfig(ImageFitConfig):
63
+ image_shape: list[int] = field(default_factory=lambda: [256, 448])
64
+ resize_mode: str = "bilinear"
65
+ # "first_cam": poses relative to the first input view; "mean_cam": to the mean
66
+ # input camera (the 2026.6 latent-bottleneck models).
67
+ camera_normalization: str = "first_cam"
68
+ scene_scale: float = 0.15
69
+ # The input view count the checkpoint was trained for. Other counts run (the
70
+ # number of Gaussians does not depend on it) but warn. None: any.
71
+ num_input_views: int | None = None
72
+ image_mean: list[float] = field(default_factory=lambda: list(IMAGENET_MEAN))
73
+ image_std: list[float] = field(default_factory=lambda: list(IMAGENET_STD))
74
+ # Render settings of the upstream renderer, in model-frame units.
75
+ znear: float = 0.025
76
+ zfar: float = 125.0
77
+ background: list[float] = field(default_factory=lambda: [0.5, 0.5, 0.5])
78
+ # Autocast dtype of the model on CUDA ("bfloat16", as upstream evaluates, or
79
+ # "float16"); None runs in fp32.
80
+ autocast: str | None = "bfloat16"
81
+
82
+ def __post_init__(self) -> None:
83
+ super().__post_init__()
84
+ if self.camera_normalization not in CAMERA_NORMALIZATIONS:
85
+ raise ValueError(
86
+ f"camera_normalization must be one of {CAMERA_NORMALIZATIONS}"
87
+ )
88
+ if self.autocast is not None and self.autocast not in _AUTOCAST:
89
+ raise ValueError(f"autocast must be None or one of {sorted(_AUTOCAST)}")
90
+
91
+
92
+ class TokenGSProcessor(Processor):
93
+ config_cls = TokenGSProcessorConfig
94
+
95
+ def preprocess(
96
+ self, views: Views, device: torch.device, scene_scale: float | None = None
97
+ ) -> Prepared:
98
+ # TokenGS is conditioned on the input cameras (Plücker rays).
99
+ views.require("c2w", "intrinsics", by="TokenGS")
100
+ cfg = self.config
101
+ num_views = views.images.shape[1]
102
+ if cfg.num_input_views is not None and num_views != cfg.num_input_views:
103
+ warnings.warn(
104
+ f"this TokenGS checkpoint was trained for {cfg.num_input_views} "
105
+ f"input views, got {num_views}",
106
+ stacklevel=3,
107
+ )
108
+ scale = cfg.scene_scale if scene_scale is None else scene_scale
109
+ frame = self.frame(views.c2w, scale)
110
+ images, k = self.fit_pixel_views(views)
111
+ c2w = frame.c2w_to_model(views.c2w.float())
112
+ h, w = images.shape[-2:]
113
+ plucker = torch.stack(
114
+ [
115
+ ray_condition(k[i : i + 1], c2w[i : i + 1], h, w)[0]
116
+ for i in range(len(k))
117
+ ]
118
+ )
119
+ normalized = torch.stack(
120
+ [TF.normalize(x, cfg.image_mean, cfg.image_std) for x in images]
121
+ )
122
+ model_input = {"images": normalized.to(device), "plucker": plucker.to(device)}
123
+ return Prepared(model_input, frame)
124
+
125
+ def frame(self, c2w: torch.Tensor, scale: float) -> ModelFrame:
126
+ """The model frame of input views c2w [B, V, 4, 4]."""
127
+ if self.config.camera_normalization == "first_cam":
128
+ return anchored_frame(c2w, scale)
129
+ anchor = torch.stack([mean_camera(x.float()) for x in c2w])
130
+ return ModelFrame(anchor_c2w=anchor, scale=float(scale))
131
+
132
+ def postprocess(self, model_output: torch.Tensor) -> Gaussians:
133
+ g = model_output.float()
134
+ return Gaussians(
135
+ means=g[..., 0:3],
136
+ opacities=g[..., 3],
137
+ scales=g[..., 4:7],
138
+ quats=g[..., 7:11],
139
+ colors=g[..., 11:14],
140
+ sh_degree=None,
141
+ )
142
+
143
+ def render_planes(self, frame: ModelFrame) -> tuple[float, float]:
144
+ return self.config.znear / frame.scale, self.config.zfar / frame.scale
145
+
146
+ @property
147
+ def background(self) -> tuple[float, float, float]:
148
+ return tuple(self.config.background)
149
+
150
+ @property
151
+ def autocast_dtype(self) -> torch.dtype | None:
152
+ return None if self.config.autocast is None else _AUTOCAST[self.config.autocast]
153
+
154
+ # --- fitting inputs ---------------------------------------------------------
155
+
156
+ def fit_pixel_views(
157
+ self, views: Views, image_shape: list[int] | None = None
158
+ ) -> tuple[torch.Tensor, torch.Tensor]:
159
+ """Images [B, V, 3, h, w] and pixel intrinsics [B, V, 4] (fx, fy, cx, cy)
160
+ at `image_shape` (default: the model's), by the config's `crop_mode`."""
161
+ views.require("intrinsics", by="TokenGS")
162
+ cfg = self.config
163
+ shape = tuple(image_shape or cfg.image_shape)
164
+ src_h, src_w = views.image_shape
165
+ if views.normalized_intrinsics:
166
+ k = _pixel_k(views.intrinsics.float(), (src_h, src_w))
167
+ else:
168
+ k = _flat_k(views.intrinsics.float())
169
+ images, ks = [], []
170
+ for img, ki in zip(views.images, k):
171
+ if cfg.crop_mode == "crop":
172
+ img, ki = crop_and_resize(img, ki, shape, self._resize)
173
+ else:
174
+ img, kn = fit_images(
175
+ img,
176
+ _normalized_k(ki, (src_h, src_w)),
177
+ shape,
178
+ resize_mode=cfg.resize_mode,
179
+ crop_mode=cfg.crop_mode,
180
+ resize=self.resize,
181
+ pad_value=cfg.pad_value,
182
+ )
183
+ ki = _pixel_k(kn, shape)
184
+ images.append(img)
185
+ ks.append(ki)
186
+ return torch.stack(images), torch.stack(ks)
187
+
188
+ def fit_views(self, views: Views, image_shape: list[int] | None = None) -> Views:
189
+ images, k = self.fit_pixel_views(views, image_shape)
190
+ return Views(
191
+ images,
192
+ _normalized_k(k, tuple(images.shape[-2:])),
193
+ views.c2w,
194
+ normalized_intrinsics=True,
195
+ )
196
+
197
+ def fit_cameras(
198
+ self, cameras: Cameras, image_shape: list[int] | None = None
199
+ ) -> Cameras:
200
+ cfg = self.config
201
+ shape = tuple(image_shape or cfg.image_shape)
202
+ if cfg.crop_mode != "crop":
203
+ k = fit_intrinsics(
204
+ cameras.normalized_k, cameras.image_shape, shape, cfg.crop_mode
205
+ )
206
+ return Cameras(cameras.c2w, k, shape, normalized_intrinsics=True)
207
+ if cameras.normalized_intrinsics:
208
+ k = _pixel_k(cameras.intrinsics.float(), cameras.image_shape)
209
+ else:
210
+ k = _flat_k(cameras.intrinsics.float())
211
+ k = crop_and_resize_intrinsics(k, cameras.image_shape, shape)
212
+ return Cameras(
213
+ cameras.c2w, _normalized_k(k, shape), shape, normalized_intrinsics=True
214
+ )
215
+
216
+ def _resize(self, images: torch.Tensor, shape: tuple[int, int]) -> torch.Tensor:
217
+ if self.resize is not None:
218
+ return self.resize(images, shape)
219
+ return rescale(images, shape, self.config.resize_mode)
220
+
221
+
222
+ def crop_region(
223
+ image_shape: tuple[int, int], shape: tuple[int, int]
224
+ ) -> tuple[int, int]:
225
+ """(h, w) of the largest centred crop of `image_shape` with the aspect ratio
226
+ of `shape` (upstream `ImageTransform` with `max_crop=True`)."""
227
+ ori_h, ori_w = image_shape
228
+ crop_ratio = min(ori_h / shape[0], ori_w / shape[1])
229
+ return int(shape[0] * crop_ratio), int(shape[1] * crop_ratio)
230
+
231
+
232
+ def crop_and_resize_intrinsics(
233
+ intrinsics: torch.Tensor, image_shape: tuple[int, int], shape: tuple[int, int]
234
+ ) -> torch.Tensor:
235
+ """Pixel intrinsics [..., 4] (fx, fy, cx, cy) through `crop_and_resize`."""
236
+ ori_h, ori_w = image_shape
237
+ new_h, new_w = crop_region(image_shape, shape)
238
+ # u,v convention: shift relative to original (un-cropped) raster
239
+ shift = ((new_w - ori_w) / 2, (new_h - ori_h) / 2)
240
+ scale = (shape[1] / new_w, shape[0] / new_h)
241
+ return torch.stack(
242
+ [
243
+ intrinsics[..., 0] * scale[0],
244
+ intrinsics[..., 1] * scale[1],
245
+ (intrinsics[..., 2] + shift[0]) * scale[0],
246
+ (intrinsics[..., 3] + shift[1]) * scale[1],
247
+ ],
248
+ dim=-1,
249
+ )
250
+
251
+
252
+ def crop_and_resize(
253
+ images: torch.Tensor,
254
+ intrinsics: torch.Tensor,
255
+ shape: tuple[int, int],
256
+ resize=None,
257
+ ) -> tuple[torch.Tensor, torch.Tensor]:
258
+ """Centre-crop [V, 3, H, W] images to the aspect ratio of `shape`, resize to
259
+ `shape` (default: torchvision bilinear, antialiased), and map the pixel
260
+ intrinsics [V, 4] alike."""
261
+ image_shape = tuple(images.shape[-2:])
262
+ images = TF.center_crop(images, list(crop_region(image_shape, shape)))
263
+ if resize is None:
264
+ images = TF.resize(
265
+ images, list(shape), TF.InterpolationMode.BILINEAR, antialias=True
266
+ )
267
+ else:
268
+ images = resize(images, tuple(shape))
269
+ return images, crop_and_resize_intrinsics(intrinsics, image_shape, shape)
270
+
271
+
272
+ def mean_camera(c2w: torch.Tensor) -> torch.Tensor:
273
+ """The mean camera [4, 4] of input views c2w [V, 4, 4]: mean position, mean
274
+ forward / down axes orthonormalised (upstream `_normalize_camera_mean_cam`)."""
275
+ position_avg = c2w[:, :3, 3].mean(0) # (3,)
276
+ forward_avg = c2w[:, :3, 2].mean(0) # (3,)
277
+ down_avg = c2w[:, :3, 1].mean(0) # (3,)
278
+ # gram-schmidt process
279
+ forward_avg = F.normalize(forward_avg, dim=0)
280
+ down_avg = F.normalize(down_avg - down_avg.dot(forward_avg) * forward_avg, dim=0)
281
+ right_avg = torch.linalg.cross(down_avg, forward_avg)
282
+ pos_avg = torch.stack([right_avg, down_avg, forward_avg, position_avg], dim=1)
283
+ bottom = torch.tensor([[0, 0, 0, 1]], device=pos_avg.device).float()
284
+ return torch.cat([pos_avg, bottom], dim=0) # (4, 4)
285
+
286
+
287
+ def ray_condition(K: torch.Tensor, c2w: torch.Tensor, H: int, W: int) -> torch.Tensor:
288
+ """Plücker rays (o x d, d) [V, 6, H, W] through pixel centres, for pixel
289
+ intrinsics K [1, V, 4] and model-frame c2w [1, V, 4, 4]."""
290
+ B, V = K.shape[:2]
291
+ j, i = torch.meshgrid(
292
+ torch.linspace(0, H - 1, H, device=K.device, dtype=K.dtype),
293
+ torch.linspace(0, W - 1, W, device=K.device, dtype=K.dtype),
294
+ indexing="ij",
295
+ )
296
+ i = i.reshape([1, 1, H * W]).expand([B, V, H * W]) + 0.5
297
+ j = j.reshape([1, 1, H * W]).expand([B, V, H * W]) + 0.5
298
+
299
+ fx, fy, cx, cy = K.chunk(4, dim=-1)
300
+ zs = torch.ones_like(i)
301
+ xs = (i - cx) / fx * zs
302
+ ys = (j - cy) / fy * zs
303
+ zs = zs.expand_as(ys)
304
+
305
+ directions = torch.stack((xs, ys, zs), dim=-1)
306
+ directions = directions / directions.norm(dim=-1, keepdim=True)
307
+
308
+ rays_d = directions @ c2w[..., :3, :3].transpose(-1, -2)
309
+ rays_o = c2w[..., :3, 3]
310
+ rays_o = rays_o[..., None, :].expand_as(rays_d)
311
+
312
+ rays_dxo = torch.cross(rays_o, rays_d, dim=-1)
313
+ plucker = torch.cat([rays_dxo, rays_d], dim=-1)
314
+ return plucker.reshape(B, V, H, W, 6).permute(0, 1, 4, 2, 3).contiguous()
315
+
316
+
317
+ def _flat_k(k: torch.Tensor) -> torch.Tensor:
318
+ """3x3 K [..., 3, 3] -> [..., 4] (fx, fy, cx, cy)."""
319
+ return torch.stack((k[..., 0, 0], k[..., 1, 1], k[..., 0, 2], k[..., 1, 2]), -1)
320
+
321
+
322
+ def _pixel_k(k: torch.Tensor, image_shape: tuple[int, int]) -> torch.Tensor:
323
+ """Normalised 3x3 K -> pixel [..., 4] (fx, fy, cx, cy)."""
324
+ h, w = image_shape
325
+ return torch.stack(
326
+ (k[..., 0, 0] * w, k[..., 1, 1] * h, k[..., 0, 2] * w, k[..., 1, 2] * h), -1
327
+ )
328
+
329
+
330
+ def _normalized_k(k: torch.Tensor, image_shape: tuple[int, int]) -> torch.Tensor:
331
+ """Pixel [..., 4] (fx, fy, cx, cy) -> normalised 3x3 K."""
332
+ h, w = image_shape
333
+ out = torch.zeros(*k.shape[:-1], 3, 3, dtype=k.dtype, device=k.device)
334
+ out[..., 0, 0] = k[..., 0] / w
335
+ out[..., 1, 1] = k[..., 1] / h
336
+ out[..., 0, 2] = k[..., 2] / w
337
+ out[..., 1, 2] = k[..., 3] / h
338
+ out[..., 2, 2] = 1.0
339
+ return out
ffgs/pipeline.py ADDED
@@ -0,0 +1,341 @@
1
+ """`GSPipeline`: posed images in, Gaussians out, for any registered model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from contextlib import nullcontext
7
+ from dataclasses import dataclass, replace
8
+ from itertools import chain
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import torch
13
+ from torch import nn
14
+
15
+ from . import dynamic, zoo
16
+ from . import render as render_mod
17
+ from .geometry import ModelFrame
18
+ from .processor import PROCESSOR_CONFIG_NAME, Processor, read_json
19
+ from .registry import ModelSpec, get_model_spec, spec_for_model
20
+ from .types import Cameras, Gaussians, Views
21
+
22
+ CONFIG_NAME = "config.json"
23
+ # Options of `huggingface_hub` downloads that `from_pretrained` passes through.
24
+ HUB_KWARGS = frozenset(
25
+ {
26
+ "revision",
27
+ "subfolder",
28
+ "cache_dir",
29
+ "token",
30
+ "force_download",
31
+ "local_files_only",
32
+ }
33
+ )
34
+ # What `subfolder=` fetches from a Hub repo: a saved pipeline and its code.
35
+ _PIPELINE_FILES = (
36
+ CONFIG_NAME,
37
+ "model.safetensors",
38
+ "pytorch_model.bin",
39
+ PROCESSOR_CONFIG_NAME,
40
+ "*.py",
41
+ )
42
+
43
+
44
+ SPACES = ("model", "world")
45
+
46
+
47
+ @dataclass
48
+ class GSOutput:
49
+ """Predicted Gaussians, the model frame they were predicted in, and the input
50
+ cameras when the model predicts them.
51
+
52
+ `gaussians` (and `cameras`) are in `space`: `"model"`, the model's own frame,
53
+ as `pipe(views)` returns them (what the model's official code produces); or
54
+ `"world"`, the frame of the input c2w, after `to_world()`. `frame` is the
55
+ model frame either way; it is None when the model predicts its cameras and the
56
+ views had no poses, and then there is no world frame to go to.
57
+ """
58
+
59
+ gaussians: Gaussians
60
+ frame: ModelFrame | None
61
+ space: str = "model"
62
+ # Predicted input cameras (normalised K for the model's input images).
63
+ cameras: Cameras | None = None
64
+
65
+ def __post_init__(self) -> None:
66
+ if self.space not in SPACES:
67
+ raise ValueError(f"space must be one of {SPACES}, got {self.space!r}")
68
+ if self.space == "world" and self.frame is None:
69
+ raise ValueError("world-frame output needs the frame it came from")
70
+
71
+ def to_world(self) -> GSOutput:
72
+ """The same output with fp32 Gaussians (and cameras) in the world frame of
73
+ the input c2w (unchanged if already there). Rejects input c2w whose rotation
74
+ is not proper (non-orthonormal or a reflection), and outputs without a
75
+ frame (a model that predicts its cameras, run without poses)."""
76
+ if self.space == "world":
77
+ return self
78
+ if self.frame is None:
79
+ raise ValueError(
80
+ "this output has no world frame: the model predicts its own "
81
+ "cameras and the views had no poses (c2w). Its Gaussians and "
82
+ "`cameras` are in the model's frame; give Views c2w to place them "
83
+ "in yours"
84
+ )
85
+ to_world = self.frame.model_to_world()
86
+ gaussians = to_world.apply(self.gaussians.to(dtype=torch.float32))
87
+ cameras = self.cameras
88
+ if cameras is not None:
89
+ cameras = to_world.apply_cameras(replace(cameras, c2w=cameras.c2w.float()))
90
+ return GSOutput(gaussians, self.frame, space="world", cameras=cameras)
91
+
92
+
93
+ class GSPipeline:
94
+ """Load with `from_pretrained`, call on `Views`, render at `Cameras`.
95
+
96
+ >>> pipe = GSPipeline.from_pretrained("path/or/repo-id", device="cuda")
97
+ >>> out = pipe(Views(images, intrinsics, c2w)) # Gaussians in the model frame
98
+ >>> images = pipe.render(out, Cameras(c2w_new, intrinsics_new, (h, w)))["images"]
99
+ >>> out.to_world().gaussians.save_ply("scene.ply") # in the frame of c2w
100
+ """
101
+
102
+ def __init__(
103
+ self, model: nn.Module, processor: Processor, model_type: str | None = None
104
+ ) -> None:
105
+ self.model = model.eval()
106
+ self.processor = processor
107
+ self.model_type = model_type or spec_for_model(model).model_type
108
+
109
+ # --- loading / saving -------------------------------------------------------
110
+
111
+ @classmethod
112
+ def from_pretrained(
113
+ cls,
114
+ pretrained: str | Path,
115
+ *,
116
+ model_type: str | None = None,
117
+ device: torch.device | str | None = None,
118
+ processor_overrides: dict[str, Any] | None = None,
119
+ trust_remote_code: bool = False,
120
+ **hub_kwargs: Any,
121
+ ) -> GSPipeline:
122
+ """`pretrained`: a directory written by `save_pretrained`, a model zoo
123
+ name (`"<family>/<variant>"`, see `ffgs.zoo.list_models()`), or a Hub
124
+ repo id — tried in that order. A zoo family name is reserved: it is
125
+ never read as a Hub repo owner.
126
+
127
+ `model_type` overrides the one in config.json (for directories exported
128
+ before it was recorded). It resolves to a registered or built-in model,
129
+ then an installed `ffgs.models` plugin, then — only with
130
+ `trust_remote_code=True` — the code the repo lists under
131
+ config.json:auto_map (see `ffgs.registry`). Pin `revision=` when trusting
132
+ Hub code.
133
+
134
+ `hub_kwargs` (`revision`, `subfolder`, `cache_dir`, `token`,
135
+ `force_download`, `local_files_only`) apply to every file fetched from the
136
+ Hub: config, weights, processor config and code. `subfolder` selects a
137
+ variant saved in a subdirectory of a repo (or of a local directory). A zoo
138
+ entry pins its own revision and file, so it takes neither.
139
+ """
140
+ unknown = set(hub_kwargs) - HUB_KWARGS
141
+ if unknown:
142
+ raise TypeError(
143
+ f"unexpected arguments {sorted(unknown)}; Hub options are "
144
+ f"{sorted(HUB_KWARGS)}"
145
+ )
146
+ name = zoo.resolve(pretrained)
147
+ if name is not None:
148
+ return cls._from_zoo(
149
+ name, model_type, device, processor_overrides, **hub_kwargs
150
+ )
151
+ subfolder = hub_kwargs.pop("subfolder", None)
152
+ if subfolder:
153
+ pretrained = _fetch_subfolder(pretrained, subfolder, **hub_kwargs)
154
+ config = None
155
+ if model_type is None:
156
+ config = read_json(pretrained, CONFIG_NAME, **hub_kwargs)
157
+ model_type = config.get("model_type")
158
+ if model_type is None:
159
+ raise ValueError(
160
+ f"{CONFIG_NAME} of {pretrained} has no model_type; pass "
161
+ "model_type= or re-export with GSPipeline.save_pretrained"
162
+ )
163
+ spec = _resolve_spec(
164
+ model_type, pretrained, config, trust_remote_code, **hub_kwargs
165
+ )
166
+ model = spec.model_cls.from_pretrained(str(pretrained), **hub_kwargs)
167
+ processor = spec.processor_cls.from_pretrained(pretrained, **hub_kwargs)
168
+ if processor_overrides:
169
+ processor = spec.processor_cls(processor.config, **processor_overrides)
170
+ pipe = cls(model, processor, model_type)
171
+ return pipe.to(device) if device is not None else pipe
172
+
173
+ @classmethod
174
+ def _from_zoo(
175
+ cls,
176
+ name: str,
177
+ model_type: str | None,
178
+ device: torch.device | str | None,
179
+ processor_overrides: dict[str, Any] | None,
180
+ **hub_kwargs: Any,
181
+ ) -> GSPipeline:
182
+ entry = zoo.get_entry(name)
183
+ pinned = sorted({"revision", "subfolder"} & set(hub_kwargs))
184
+ if pinned:
185
+ raise TypeError(
186
+ f"{name} is a zoo entry pinned to {entry.weights.repo}/"
187
+ f"{entry.weights.file}@{entry.weights.revision}; {pinned} do not "
188
+ "apply to it"
189
+ )
190
+ if model_type is not None and model_type != entry.model_type:
191
+ raise ValueError(
192
+ f"zoo entry {name} is a {entry.model_type!r} model, not "
193
+ f"{model_type!r}"
194
+ )
195
+ spec = get_model_spec(entry.model_type)
196
+ model = spec.model_cls(**entry.model)
197
+ state_dict = zoo.load_state_dict(entry, spec, **hub_kwargs)
198
+ try:
199
+ model.load_state_dict(state_dict, strict=True)
200
+ except RuntimeError as err:
201
+ raise RuntimeError(
202
+ f"zoo entry {name}: weights do not fit {spec.model_cls.__name__} "
203
+ f"(check the entry's model config and the model_type's "
204
+ f"convert_state_dict): {err}"
205
+ ) from None
206
+ processor = spec.processor_cls.from_dict(
207
+ entry.processor, source=f"zoo entry {name}: processor"
208
+ )
209
+ if processor_overrides:
210
+ processor = spec.processor_cls(processor.config, **processor_overrides)
211
+ pipe = cls(model, processor, entry.model_type)
212
+ return pipe.to(device) if device is not None else pipe
213
+
214
+ def save_pretrained(
215
+ self, save_directory: str | Path, *, include_code: bool = False, **kwargs: Any
216
+ ) -> Path:
217
+ """Weights + config.json (with model_type) + processor_config.json; for a
218
+ model loaded from repo code, that code too (+ config.json:auto_map).
219
+
220
+ `include_code=True` also ships the code of an installed model: the package
221
+ holding the model and processor classes is copied in and listed under
222
+ config.json:auto_map, so the directory loads with `trust_remote_code=True`
223
+ where that package is not installed (see `ffgs.dynamic.export_code`)."""
224
+ save_directory = Path(save_directory)
225
+ model_cls, processor_cls = type(self.model), type(self.processor)
226
+ # Code first: a package that cannot be shipped fails before any write.
227
+ auto_map = dynamic.save_code(model_cls, processor_cls, save_directory)
228
+ if auto_map is None and include_code:
229
+ auto_map = dynamic.export_code(model_cls, processor_cls, save_directory)
230
+ self.model.save_pretrained(save_directory, **kwargs)
231
+ config_path = save_directory / CONFIG_NAME
232
+ config = json.loads(config_path.read_text()) if config_path.is_file() else {}
233
+ config["model_type"] = self.model_type
234
+ if auto_map is not None:
235
+ config["auto_map"] = auto_map
236
+ config_path.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n")
237
+ self.processor.save_pretrained(save_directory)
238
+ return save_directory
239
+
240
+ def to(self, device: torch.device | str) -> GSPipeline:
241
+ self.model.to(device)
242
+ return self
243
+
244
+ @property
245
+ def device(self) -> torch.device:
246
+ """Where the model is: its first parameter or buffer, else the CPU."""
247
+ tensor = next(chain(self.model.parameters(), self.model.buffers()), None)
248
+ return torch.device("cpu") if tensor is None else tensor.device
249
+
250
+ # --- inference --------------------------------------------------------------
251
+
252
+ def autocast(self):
253
+ dtype = self.processor.autocast_dtype
254
+ if dtype is None or self.device.type != "cuda":
255
+ return nullcontext()
256
+ return torch.autocast(device_type="cuda", dtype=dtype)
257
+
258
+ @torch.no_grad()
259
+ def __call__(self, views: Views, **options: Any) -> GSOutput:
260
+ """Predict Gaussians from `views`, in the model frame (`out.to_world()` for
261
+ the frame of `views.c2w`). Models that predict cameras run without c2w,
262
+ and with c2w fit their frame to them (`out.frame.residuals`). `options`
263
+ go to the processor (e.g. `scene_scale=`)."""
264
+ with torch.autocast(device_type=self.device.type, enabled=False):
265
+ prepared = self.processor.preprocess(views, self.device, **options)
266
+ with self.autocast():
267
+ raw = self.model(prepared.model_input)
268
+ gaussians = self.processor.postprocess(raw)
269
+ cameras = self.processor.predicted_cameras(raw)
270
+ frame = self.processor.output_frame(views, prepared, cameras)
271
+ return GSOutput(gaussians, frame, cameras=cameras)
272
+
273
+ @torch.no_grad()
274
+ def render(
275
+ self,
276
+ output: GSOutput,
277
+ cameras: Cameras,
278
+ **kwargs: Any,
279
+ ) -> dict[str, torch.Tensor]:
280
+ """Render `output` at `cameras`, given in the world frame of the input c2w
281
+ whichever `space` the Gaussians are in: for model-frame Gaussians the
282
+ cameras and the processor's near / far planes are taken into the model
283
+ frame first. Without a frame (no input poses) there is no world: `cameras`
284
+ are in the model's frame, like `output.cameras`. The background and other
285
+ render settings are the processor's (`render_settings`) unless given."""
286
+ near, far = self.processor.render_planes(output.frame)
287
+ if output.space == "model" and output.frame is not None:
288
+ cameras = output.frame.cameras_to_model(cameras)
289
+ near, far = near * output.frame.scale, far * output.frame.scale
290
+ kwargs.setdefault("background", self.processor.background)
291
+ for key, value in self.processor.render_settings.items():
292
+ kwargs.setdefault(key, value)
293
+ with torch.autocast(device_type=self.device.type, enabled=False):
294
+ return render_mod.render(
295
+ output.gaussians, cameras.to(self.device), near=near, far=far, **kwargs
296
+ )
297
+
298
+
299
+ def _fetch_subfolder(pretrained: str | Path, subfolder: str, **hub_kwargs: Any) -> Path:
300
+ """A local directory holding `subfolder` of a directory or Hub repo."""
301
+ local = Path(pretrained)
302
+ if local.is_dir():
303
+ path = local / subfolder
304
+ else:
305
+ from huggingface_hub import snapshot_download
306
+
307
+ patterns = [f"{subfolder}/{name}" for name in _PIPELINE_FILES]
308
+ path = Path(
309
+ snapshot_download(str(pretrained), allow_patterns=patterns, **hub_kwargs)
310
+ )
311
+ path = path / subfolder
312
+ if not path.is_dir():
313
+ raise FileNotFoundError(f"{pretrained} has no subfolder {subfolder!r}")
314
+ return path
315
+
316
+
317
+ def _resolve_spec(
318
+ model_type: str,
319
+ pretrained: str | Path,
320
+ config: dict | None,
321
+ trust_remote_code: bool,
322
+ **hub_kwargs: Any,
323
+ ) -> ModelSpec:
324
+ """Installed model first; repo code (config.json:auto_map) only if trusted."""
325
+ try:
326
+ return get_model_spec(model_type)
327
+ except KeyError:
328
+ if config is None:
329
+ config = read_json(pretrained, CONFIG_NAME, **hub_kwargs)
330
+ auto_map = config.get("auto_map")
331
+ if auto_map is None:
332
+ raise
333
+ if not trust_remote_code:
334
+ raise ValueError(
335
+ f"model_type {model_type!r} is not installed, and {pretrained} ships "
336
+ f"its own code for it ({CONFIG_NAME}:auto_map = {auto_map}). That code "
337
+ "would run on this machine: read it, then pass trust_remote_code=True "
338
+ "(with a pinned revision= for Hub repos). Or install a package that "
339
+ f"provides {model_type!r}."
340
+ )
341
+ return dynamic.spec_from_auto_map(model_type, auto_map, pretrained, **hub_kwargs)