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.
- ffgs/__init__.py +39 -0
- ffgs/dynamic.py +239 -0
- ffgs/geometry.py +418 -0
- ffgs/image.py +204 -0
- ffgs/models/__init__.py +2 -0
- ffgs/models/anysplat/LICENSE +1120 -0
- ffgs/models/anysplat/__init__.py +49 -0
- ffgs/models/anysplat/encoder.py +372 -0
- ffgs/models/anysplat/evaluation.py +98 -0
- ffgs/models/anysplat/gaussian_adapter.py +96 -0
- ffgs/models/anysplat/gs_head.py +166 -0
- ffgs/models/anysplat/modeling.py +95 -0
- ffgs/models/anysplat/processing.py +249 -0
- ffgs/models/anysplat/vggt/__init__.py +0 -0
- ffgs/models/anysplat/vggt/heads/__init__.py +0 -0
- ffgs/models/anysplat/vggt/heads/camera_head.py +181 -0
- ffgs/models/anysplat/vggt/heads/dpt_head.py +612 -0
- ffgs/models/anysplat/vggt/heads/head_act.py +134 -0
- ffgs/models/anysplat/vggt/heads/utils.py +126 -0
- ffgs/models/anysplat/vggt/layers/__init__.py +28 -0
- ffgs/models/anysplat/vggt/layers/attention.py +92 -0
- ffgs/models/anysplat/vggt/layers/block.py +157 -0
- ffgs/models/anysplat/vggt/layers/drop_path.py +42 -0
- ffgs/models/anysplat/vggt/layers/layer_scale.py +32 -0
- ffgs/models/anysplat/vggt/layers/mlp.py +45 -0
- ffgs/models/anysplat/vggt/layers/patch_embed.py +106 -0
- ffgs/models/anysplat/vggt/layers/rope.py +211 -0
- ffgs/models/anysplat/vggt/layers/swiglu_ffn.py +77 -0
- ffgs/models/anysplat/vggt/layers/vision_transformer.py +463 -0
- ffgs/models/anysplat/vggt/models/__init__.py +0 -0
- ffgs/models/anysplat/vggt/models/aggregator.py +398 -0
- ffgs/models/anysplat/vggt/utils/__init__.py +0 -0
- ffgs/models/anysplat/vggt/utils/geometry.py +230 -0
- ffgs/models/anysplat/vggt/utils/pose_enc.py +144 -0
- ffgs/models/anysplat/vggt/utils/rotation.py +148 -0
- ffgs/models/tokengs/__init__.py +43 -0
- ffgs/models/tokengs/activations.py +135 -0
- ffgs/models/tokengs/attention.py +189 -0
- ffgs/models/tokengs/enc_dec.py +323 -0
- ffgs/models/tokengs/modeling.py +173 -0
- ffgs/models/tokengs/processing.py +339 -0
- ffgs/pipeline.py +341 -0
- ffgs/processor.py +236 -0
- ffgs/registry.py +132 -0
- ffgs/render.py +111 -0
- ffgs/types.py +291 -0
- ffgs/zoo/__init__.py +234 -0
- ffgs/zoo/anysplat/default.json +34 -0
- ffgs/zoo/tokengs/dl3dv-2v.json +29 -0
- ffgs/zoo/tokengs/dl3dv-4v.json +29 -0
- ffgs/zoo/tokengs/dl3dv-6v.json +29 -0
- ffgs/zoo/tokengs/dl3dv-base.json +29 -0
- ffgs/zoo/tokengs/dl3dv-latent-2v-lpips.json +48 -0
- ffgs/zoo/tokengs/dl3dv-latent-2v-ssim.json +48 -0
- ffgs/zoo/tokengs/dl3dv-latent-4v-lpips.json +48 -0
- ffgs/zoo/tokengs/dl3dv-latent-4v-ssim.json +48 -0
- ffgs/zoo/tokengs/dl3dv-latent-6v-lpips.json +48 -0
- ffgs/zoo/tokengs/dl3dv-latent-6v-ssim.json +48 -0
- ffgs-0.1.0.dev0.dist-info/METADATA +320 -0
- ffgs-0.1.0.dev0.dist-info/RECORD +63 -0
- ffgs-0.1.0.dev0.dist-info/WHEEL +4 -0
- ffgs-0.1.0.dev0.dist-info/licenses/LICENSE +237 -0
- 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)
|