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
ffgs/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Standard inference for feed-forward 3D Gaussian Splatting models.
2
+
3
+ Posed images in (`Views`), Gaussians out (in the model's frame, or the caller's
4
+ world frame with `out.to_world()`), one `GSPipeline.from_pretrained` for every
5
+ registered model. See README.md.
6
+
7
+ Depends on torch, torchvision, numpy, pillow, huggingface_hub and safetensors;
8
+ gsplat is imported only when rendering (`pip install ffgs[render]`).
9
+ """
10
+
11
+ import importlib.metadata as _metadata
12
+
13
+ from .geometry import ModelFrame
14
+ from .pipeline import GSOutput, GSPipeline
15
+ from .processor import ImageFitConfig, Prepared, Processor
16
+ from .registry import ModelSpec, get_model_spec, register_model
17
+ from .render import render
18
+ from .types import Cameras, Gaussians, Views
19
+
20
+ try:
21
+ __version__ = _metadata.version("ffgs")
22
+ except _metadata.PackageNotFoundError: # a source tree that is not installed
23
+ __version__ = "0+unknown"
24
+
25
+ __all__ = [
26
+ "Cameras",
27
+ "GSOutput",
28
+ "GSPipeline",
29
+ "Gaussians",
30
+ "ImageFitConfig",
31
+ "ModelFrame",
32
+ "ModelSpec",
33
+ "Prepared",
34
+ "Processor",
35
+ "Views",
36
+ "get_model_spec",
37
+ "register_model",
38
+ "render",
39
+ ]
ffgs/dynamic.py ADDED
@@ -0,0 +1,239 @@
1
+ """Model code shipped inside a model repo, named by `config.json:auto_map`.
2
+
3
+ The convention of transformers / diffusers: a repo whose model is not installed
4
+ lists its own classes,
5
+
6
+ "auto_map": {"model": "modeling_foo.FooModel",
7
+ "processor": "processing_foo.FooProcessor"}
8
+
9
+ and the `.py` files next to `config.json` are imported only when the caller passes
10
+ `trust_remote_code=True`. The files are imported as one package per directory
11
+ (Hub snapshot or local directory), so relative imports between them work.
12
+ `export_code` writes such a directory from an installed package
13
+ (`GSPipeline.save_pretrained(..., include_code=True)`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import hashlib
20
+ import importlib
21
+ import importlib.machinery
22
+ import importlib.util
23
+ import shutil
24
+ import sys
25
+ from pathlib import Path
26
+ from types import ModuleType
27
+ from typing import Any
28
+
29
+ from .processor import Processor
30
+ from .registry import ModelSpec
31
+
32
+ AUTO_MAP_KEYS = ("model", "processor")
33
+
34
+ # Parent package of every imported code directory.
35
+ _PACKAGE = "ffgs_remote_code"
36
+
37
+
38
+ def parse_auto_map(auto_map: Any) -> dict[str, tuple[str, str]]:
39
+ """`{"model": "module.Class", ...}` -> `{"model": ("module", "Class"), ...}`."""
40
+ if not isinstance(auto_map, dict) or set(auto_map) != set(AUTO_MAP_KEYS):
41
+ raise ValueError(
42
+ f"config.json:auto_map must map exactly {list(AUTO_MAP_KEYS)} to "
43
+ f'"module.Class" references, got {auto_map!r}'
44
+ )
45
+ parsed = {}
46
+ for key, reference in auto_map.items():
47
+ if not isinstance(reference, str) or "." not in reference:
48
+ raise ValueError(
49
+ f'auto_map["{key}"] must be "module.Class", got {reference!r}'
50
+ )
51
+ if "--" in reference:
52
+ raise ValueError(
53
+ f'auto_map["{key}"] = {reference!r} points at another repo; '
54
+ "ffgs only runs code from the repo being loaded, copy it there"
55
+ )
56
+ module, name = reference.rsplit(".", 1)
57
+ parsed[key] = (module, name)
58
+ return parsed
59
+
60
+
61
+ def spec_from_auto_map(
62
+ model_type: str, auto_map: Any, pretrained: str | Path, **hub_kwargs: Any
63
+ ) -> ModelSpec:
64
+ """Import the classes `auto_map` names from `pretrained` (runs its code)."""
65
+ parsed = parse_auto_map(auto_map)
66
+ package = _import_package(_code_directory(pretrained, **hub_kwargs))
67
+ classes = {
68
+ key: _load_class(package, module, name, pretrained)
69
+ for key, (module, name) in parsed.items()
70
+ }
71
+ if not issubclass(classes["processor"], Processor):
72
+ raise TypeError(
73
+ f"auto_map processor {classes['processor'].__name__} of {pretrained} "
74
+ "does not subclass ffgs.Processor"
75
+ )
76
+ return ModelSpec(model_type, classes["model"], classes["processor"])
77
+
78
+
79
+ def save_code(
80
+ model_cls: type, processor_cls: type, save_directory: Path
81
+ ) -> dict[str, str] | None:
82
+ """Copy the repo code `model_cls` / `processor_cls` came from (every `.py`
83
+ file of its directory) into `save_directory` and return the matching
84
+ auto_map; None for installed code."""
85
+ remote = [_is_remote(cls) for cls in (model_cls, processor_cls)]
86
+ if not any(remote):
87
+ return None
88
+ if not all(remote) or model_cls.__module__.split(".")[1] != (
89
+ processor_cls.__module__.split(".")[1]
90
+ ):
91
+ raise ValueError(
92
+ "cannot save a model and processor that come from different code "
93
+ "sources as one repo"
94
+ )
95
+ package = ".".join(model_cls.__module__.split(".")[:2])
96
+ source = Path(sys.modules[package].__path__[0])
97
+ # All of them, as they are downloaded: a module the model imports only when
98
+ # it runs is not imported yet.
99
+ for file in sorted(p for p in source.rglob("*.py") if "__pycache__" not in p.parts):
100
+ target = save_directory / file.relative_to(source)
101
+ if target.exists() and target.samefile(file):
102
+ continue
103
+ target.parent.mkdir(parents=True, exist_ok=True)
104
+ shutil.copyfile(file, target)
105
+ return {
106
+ key: f"{cls.__module__[len(package) + 1:]}.{cls.__qualname__}"
107
+ for key, cls in zip(AUTO_MAP_KEYS, (model_cls, processor_cls))
108
+ }
109
+
110
+
111
+ def export_code(
112
+ model_cls: type, processor_cls: type, save_directory: Path
113
+ ) -> dict[str, str]:
114
+ """Copy the installed package holding `model_cls` and `processor_cls` (every
115
+ `.py` file under it) into `save_directory` and return the matching auto_map,
116
+ so the directory loads with `trust_remote_code=True` where that package is not
117
+ installed.
118
+
119
+ The package is the innermost one holding both classes. Its code may import
120
+ ffgs and third-party packages, and itself only relatively: an absolute import
121
+ of its own distribution, or a relative one reaching above the package, would
122
+ not resolve in the copy and is refused.
123
+ """
124
+ package = _common_package(model_cls, processor_cls)
125
+ top = package.split(".")[0]
126
+ if top == "ffgs":
127
+ raise ValueError(
128
+ f"{model_cls.__name__} is built into ffgs; its directories load "
129
+ "without shipping code"
130
+ )
131
+ source = Path(sys.modules[package].__path__[0])
132
+ files = sorted(p for p in source.rglob("*.py") if "__pycache__" not in p.parts)
133
+ problems = [
134
+ problem for file in files for problem in _outside_imports(file, source, top)
135
+ ]
136
+ if problems:
137
+ raise ValueError(
138
+ f"cannot ship package {package!r} as model code: it imports code "
139
+ "that is not in it (import within the package relatively):\n "
140
+ + "\n ".join(problems)
141
+ )
142
+ for file in files:
143
+ target = save_directory / file.relative_to(source)
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ shutil.copyfile(file, target)
146
+ return {
147
+ key: f"{cls.__module__[len(package) + 1:]}.{cls.__qualname__}"
148
+ for key, cls in zip(AUTO_MAP_KEYS, (model_cls, processor_cls))
149
+ }
150
+
151
+
152
+ def _common_package(*classes: type) -> str:
153
+ paths = []
154
+ for cls in classes:
155
+ module = sys.modules[cls.__module__]
156
+ name = module.__name__
157
+ paths.append(name if hasattr(module, "__path__") else name.rpartition(".")[0])
158
+ common = []
159
+ if all(paths):
160
+ for names in zip(*(path.split(".") for path in paths)):
161
+ if len(set(names)) > 1:
162
+ break
163
+ common.append(names[0])
164
+ if not common:
165
+ raise ValueError(
166
+ "the model and processor classes must live in one package to be "
167
+ f"shipped as model code, got {[cls.__module__ for cls in classes]}"
168
+ )
169
+ return ".".join(common)
170
+
171
+
172
+ def _outside_imports(file: Path, source: Path, top: str) -> list[str]:
173
+ """Imports in `file` that would not resolve once `source` is copied alone."""
174
+ depth = len(file.parent.relative_to(source).parts)
175
+ problems = []
176
+ for node in ast.walk(ast.parse(file.read_text(), filename=str(file))):
177
+ if isinstance(node, ast.ImportFrom) and node.level:
178
+ if node.level - 1 > depth:
179
+ where = f"{file.relative_to(source)}:{node.lineno}"
180
+ problems.append(f"{where}: {'.' * node.level}{node.module or ''}")
181
+ continue
182
+ if isinstance(node, ast.ImportFrom):
183
+ names = [node.module]
184
+ elif isinstance(node, ast.Import):
185
+ names = [alias.name for alias in node.names]
186
+ else:
187
+ continue
188
+ for name in names:
189
+ if name.split(".")[0] == top:
190
+ problems.append(f"{file.relative_to(source)}:{node.lineno}: {name}")
191
+ return problems
192
+
193
+
194
+ def _is_remote(cls: type) -> bool:
195
+ return cls.__module__.startswith(_PACKAGE + ".")
196
+
197
+
198
+ def _code_directory(pretrained: str | Path, **hub_kwargs: Any) -> Path:
199
+ local = Path(pretrained)
200
+ if local.is_dir():
201
+ return local.resolve()
202
+ from huggingface_hub import snapshot_download
203
+
204
+ return Path(
205
+ snapshot_download(str(pretrained), allow_patterns=["*.py"], **hub_kwargs)
206
+ )
207
+
208
+
209
+ def _import_package(directory: Path) -> str:
210
+ """Register `directory` as a package (its `__init__.py` is not run)."""
211
+ if _PACKAGE not in sys.modules:
212
+ root = importlib.machinery.ModuleSpec(_PACKAGE, None, is_package=True)
213
+ root.submodule_search_locations = []
214
+ sys.modules[_PACKAGE] = importlib.util.module_from_spec(root)
215
+ digest = hashlib.sha256(str(directory).encode()).hexdigest()[:16]
216
+ name = f"{_PACKAGE}.m{digest}"
217
+ if name not in sys.modules:
218
+ spec = importlib.machinery.ModuleSpec(name, None, is_package=True)
219
+ spec.submodule_search_locations = [str(directory)]
220
+ module: ModuleType = importlib.util.module_from_spec(spec)
221
+ sys.modules[name] = module
222
+ setattr(sys.modules[_PACKAGE], f"m{digest}", module)
223
+ return name
224
+
225
+
226
+ def _load_class(package: str, module: str, name: str, pretrained: str | Path) -> type:
227
+ try:
228
+ imported = importlib.import_module(f"{package}.{module}")
229
+ except ModuleNotFoundError as err:
230
+ if err.name != f"{package}.{module}":
231
+ raise
232
+ raise ModuleNotFoundError(
233
+ f"auto_map names module {module!r} but {module.replace('.', '/')}.py "
234
+ f"is not in {pretrained}"
235
+ ) from None
236
+ cls = getattr(imported, name, None)
237
+ if not isinstance(cls, type):
238
+ raise ImportError(f"{module}.py of {pretrained} defines no class {name!r}")
239
+ return cls
ffgs/geometry.py ADDED
@@ -0,0 +1,418 @@
1
+ """The model's working frame, and moving cameras / Gaussians in and out of it.
2
+
3
+ Feed-forward models are trained on normalised cameras: here, every pose relative to
4
+ the first context view, then translations scaled by a constant `scene_scale`. A
5
+ `ModelFrame` records that normalisation per sample, so inputs go in with exactly the
6
+ training arithmetic, and predicted Gaussians can be taken to the caller's world
7
+ frame by one `Similarity` shared by every model (SH rotated to any degree).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass, replace
14
+
15
+ import torch
16
+
17
+ from .types import Cameras, Gaussians
18
+
19
+ # Tolerance of R R^T = I for a rotation taken from caller c2w (fp32, maybe parsed
20
+ # from text): loose enough for rounded poses, tight enough to catch any scale.
21
+ _ORTHONORMAL_ATOL = 1e-3
22
+
23
+
24
+ def _check_rotation(r: torch.Tensor, what: str) -> None:
25
+ """Raise unless every r [.., 3, 3] is a proper rotation."""
26
+ r = r.double()
27
+ if not torch.isfinite(r).all():
28
+ raise ValueError(f"{what}: the rotation is not finite")
29
+ eye = torch.eye(3, dtype=r.dtype, device=r.device)
30
+ error = (r @ r.transpose(-1, -2) - eye).abs().amax(dim=(-2, -1))
31
+ if (error > _ORTHONORMAL_ATOL).any():
32
+ raise ValueError(
33
+ f"{what}: the rotation is not orthonormal (non-uniform scale or "
34
+ f"shear; max |R R^T - I| = {error.max().item():.2e})"
35
+ )
36
+ if (torch.linalg.det(r) < 0).any():
37
+ raise ValueError(
38
+ f"{what}: the rotation has det < 0 (a reflection, e.g. left-handed "
39
+ "c2w axes)"
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class PoseResiduals:
45
+ """How far the cameras a fitted frame maps to the world land from the given
46
+ ones, per view [B, V]."""
47
+
48
+ rotation_deg: torch.Tensor # angle between the mapped and the given rotation
49
+ center_error: torch.Tensor # distance between the camera centres, world units
50
+ # center_error over the RMS distance of the given centres from their mean.
51
+ relative_center_error: torch.Tensor
52
+
53
+
54
+ @dataclass
55
+ class ModelFrame:
56
+ """world -> model: x_m = scale * inverse(anchor_c2w) @ x_w, per sample.
57
+
58
+ `scale` is one number for the batch, or a [B] tensor (fitted frames).
59
+ `residuals` is set on frames fitted to given poses (`ModelFrame.fit`).
60
+ """
61
+
62
+ anchor_c2w: torch.Tensor # [B, 4, 4], world frame
63
+ scale: float | torch.Tensor
64
+ residuals: PoseResiduals | None = None
65
+
66
+ @classmethod
67
+ def fit(cls, predicted_c2w: torch.Tensor, given_c2w: torch.Tensor) -> ModelFrame:
68
+ """The frame that maps a model's predicted cameras `predicted_c2w` onto
69
+ the caller's `given_c2w` (both [B, V, 4, 4], the same views).
70
+
71
+ Closed form over all V >= 2 views, in fp64: the rotation is the chordal
72
+ mean of R_given R_predicted^T (SVD, projected to det +1); with it fixed,
73
+ scale and translation are the least-squares fit of the camera centres.
74
+ """
75
+ pred, given = predicted_c2w.double(), given_c2w.to(predicted_c2w).double()
76
+ if pred.shape != given.shape:
77
+ raise ValueError(
78
+ f"predicted cameras {tuple(pred.shape)} and given c2w "
79
+ f"{tuple(given.shape)} disagree"
80
+ )
81
+ if pred.shape[1] < 2:
82
+ raise ValueError(
83
+ f"fitting a frame to given poses needs at least 2 views, got "
84
+ f"{pred.shape[1]}"
85
+ )
86
+ _check_rotation(given[..., :3, :3], "given c2w")
87
+ _check_rotation(pred[..., :3, :3], "predicted c2w")
88
+ for what, c2w in (("given", given), ("predicted", pred)):
89
+ if not torch.isfinite(c2w[..., :3, 3]).all():
90
+ raise ValueError(f"{what} c2w: the camera centres are not finite")
91
+ r_pred, r_given = pred[..., :3, :3], given[..., :3, :3]
92
+ c_pred, c_given = pred[..., :3, 3], given[..., :3, 3]
93
+ u, _, vh = torch.linalg.svd((r_given @ r_pred.transpose(-1, -2)).sum(dim=1))
94
+ flip = torch.ones_like(u[..., 0])
95
+ flip[..., -1] = torch.sign(torch.linalg.det(u @ vh))
96
+ rotation = u @ torch.diag_embed(flip) @ vh # [B, 3, 3]
97
+
98
+ d_pred = c_pred - c_pred.mean(dim=1, keepdim=True)
99
+ d_given = c_given - c_given.mean(dim=1, keepdim=True)
100
+ spread = (d_pred**2).sum(dim=(1, 2))
101
+ extent = (d_given**2).sum(dim=-1).mean(dim=1).sqrt() # RMS, given
102
+ if (spread <= 1e-12 * (c_pred**2).sum(dim=(1, 2)).clamp_min(1e-300)).any():
103
+ raise ValueError("the predicted camera centres coincide: no scale to fit")
104
+ rotated = torch.einsum("bij,bvj->bvi", rotation, d_pred)
105
+ scale = (d_given * rotated).sum(dim=(1, 2)) / spread # model -> world
106
+ if (scale <= 0).any():
107
+ raise ValueError(
108
+ "the given poses do not fit the predicted cameras (fitted scale "
109
+ f"{scale.tolist()} <= 0)"
110
+ )
111
+ translation = c_given.mean(dim=1) - scale[:, None] * torch.einsum(
112
+ "bij,bj->bi", rotation, c_pred.mean(dim=1)
113
+ )
114
+
115
+ delta = r_given @ (rotation[:, None] @ r_pred).transpose(-1, -2)
116
+ cos = (delta.diagonal(dim1=-2, dim2=-1).sum(-1) - 1) / 2
117
+ skew = delta - delta.transpose(-1, -2) # atan2: accurate at small angles
118
+ sin = torch.stack([skew[..., 2, 1], skew[..., 0, 2], skew[..., 1, 0]], -1)
119
+ sin = sin.norm(dim=-1) / 2
120
+ mapped_c = scale[:, None, None] * torch.einsum("bij,bvj->bvi", rotation, c_pred)
121
+ error = (mapped_c + translation[:, None] - c_given).norm(dim=-1)
122
+ residuals = PoseResiduals(
123
+ rotation_deg=torch.rad2deg(torch.atan2(sin, cos)).float(),
124
+ center_error=error.float(),
125
+ relative_center_error=(error / extent[:, None].clamp_min(1e-300)).float(),
126
+ )
127
+ anchor = torch.eye(4, dtype=torch.float64, device=pred.device)
128
+ anchor = anchor.repeat(pred.shape[0], 1, 1)
129
+ anchor[:, :3, :3] = rotation
130
+ anchor[:, :3, 3] = translation
131
+ return cls(
132
+ anchor_c2w=anchor.float(),
133
+ scale=(1 / scale).float(),
134
+ residuals=residuals,
135
+ )
136
+
137
+ def c2w_to_model(self, c2w: torch.Tensor) -> torch.Tensor:
138
+ """World c2w [B, V, 4, 4] -> model frame.
139
+
140
+ The dataset's arithmetic, op for op (`camera_normalization` followed by the
141
+ in-place translation scale), so the result is bit-identical to training.
142
+ """
143
+ if c2w.shape[0] != self.anchor_c2w.shape[0]:
144
+ raise ValueError(
145
+ f"{c2w.shape[0]} camera samples for a frame of "
146
+ f"{self.anchor_c2w.shape[0]}"
147
+ )
148
+ out = []
149
+ for anchor, poses in zip(self.anchor_c2w, c2w):
150
+ canonical = torch.eye(4, dtype=torch.float32, device=anchor.device)[None]
151
+ norm = torch.bmm(canonical, torch.inverse(anchor[None]))
152
+ out.append(torch.bmm(norm.repeat(poses.shape[0], 1, 1), poses))
153
+ model = torch.stack(out)
154
+ if isinstance(self.scale, torch.Tensor):
155
+ model[..., :3, 3] *= self.scale.to(model)[:, None, None]
156
+ elif self.scale != 1.0:
157
+ model[..., :3, 3] *= self.scale
158
+ return model
159
+
160
+ def cameras_to_model(self, cameras: Cameras) -> Cameras:
161
+ """World-frame cameras -> model frame (fp32, on the frame's device)."""
162
+ c2w = cameras.c2w.float().to(self.anchor_c2w.device)
163
+ return replace(cameras, c2w=self.c2w_to_model(c2w))
164
+
165
+ def model_to_world(self) -> Similarity:
166
+ """model -> world: x_w = anchor_c2w @ (x_m / scale)."""
167
+ anchor = self.anchor_c2w
168
+ if isinstance(self.scale, torch.Tensor):
169
+ scale = 1.0 / self.scale.to(anchor)
170
+ else:
171
+ scale = torch.full_like(anchor[:, 0, 0], 1.0 / self.scale)
172
+ return Similarity(
173
+ rotation=anchor[:, :3, :3], scale=scale, translation=anchor[:, :3, 3]
174
+ )
175
+
176
+
177
+ @dataclass
178
+ class Similarity:
179
+ """x -> scale * rotation @ x + translation, per sample.
180
+
181
+ The one transform every model's Gaussians go through between frames (the
182
+ public entry is `GSOutput.to_world`). Only rotations, uniform scales and
183
+ translations keep a Gaussian a Gaussian with the same opacity and its colour
184
+ an SH field, so anything else is rejected.
185
+ """
186
+
187
+ rotation: torch.Tensor # [B, 3, 3], proper (det +1)
188
+ scale: torch.Tensor # [B], > 0
189
+ translation: torch.Tensor # [B, 3]
190
+
191
+ def __post_init__(self) -> None:
192
+ _check_rotation(self.rotation, "not a similarity")
193
+ if not (torch.isfinite(self.scale) & (self.scale > 0)).all():
194
+ raise ValueError(f"scale must be finite and > 0, got {self.scale}")
195
+ if not torch.isfinite(self.translation).all():
196
+ raise ValueError(f"translation must be finite, got {self.translation}")
197
+
198
+ def apply_cameras(self, cameras: Cameras) -> Cameras:
199
+ """Camera poses moved with the scene: rotations by `rotation`, centres
200
+ as points. Intrinsics are unchanged."""
201
+ c2w = cameras.c2w
202
+ rotation = self.rotation.to(c2w)[:, None]
203
+ out = c2w.clone()
204
+ out[..., :3, :3] = rotation @ c2w[..., :3, :3]
205
+ centers = (c2w[..., :3, 3] * self.scale.to(c2w)[:, None, None])[..., None]
206
+ out[..., :3, 3] = (rotation @ centers)[..., 0]
207
+ out[..., :3, 3] += self.translation.to(c2w)[:, None]
208
+ return replace(cameras, c2w=out)
209
+
210
+ def apply(self, gaussians: Gaussians) -> Gaussians:
211
+ """Each attribute as the transform acts on it: means fully, scales by
212
+ `scale`, quats by `rotation`, SH of degree >= 1 rotated; opacities and RGB /
213
+ degree-0 colours unchanged. Computed in the dtype of `gaussians`."""
214
+ g = gaussians
215
+ rotation = self.rotation.to(g.means)
216
+ scale = self.scale.to(g.means)
217
+ means = (g.means * scale[:, None, None]) @ rotation.transpose(-1, -2)
218
+ means = means + self.translation.to(g.means)[:, None]
219
+ quats = quat_multiply(matrix_to_quat(rotation)[:, None], g.quats)
220
+ colors = g.colors
221
+ if g.sh_degree is not None and g.sh_degree >= 1:
222
+ colors = rotate_sh(colors, rotation, g.sh_degree)
223
+ return replace(
224
+ g,
225
+ means=means,
226
+ scales=g.scales * scale[:, None, None],
227
+ quats=quats,
228
+ colors=colors,
229
+ )
230
+
231
+
232
+ def anchored_frame(views_c2w: torch.Tensor, scale: float) -> ModelFrame:
233
+ """The frame anchored to the first context view of each sample."""
234
+ return ModelFrame(anchor_c2w=views_c2w[:, 0].float(), scale=float(scale))
235
+
236
+
237
+ def matrix_to_quat(rotation: torch.Tensor) -> torch.Tensor:
238
+ """[.., 3, 3] rotation -> [.., 4] unit quaternion (wxyz, w >= 0)."""
239
+ m = rotation
240
+ m00, m11, m22 = m[..., 0, 0], m[..., 1, 1], m[..., 2, 2]
241
+ m01, m02, m10 = m[..., 0, 1], m[..., 0, 2], m[..., 1, 0]
242
+ m12, m20, m21 = m[..., 1, 2], m[..., 2, 0], m[..., 2, 1]
243
+ # Shepperd: take the largest of |w|, |x|, |y|, |z| from the diagonal and the
244
+ # other three from off-diagonal sums divided by it -- accurate for every
245
+ # rotation, unlike sqrt-ing each component (lossy near 0) or the trace-only
246
+ # formula (breaks near 180 degrees).
247
+ squares = torch.stack(
248
+ (
249
+ 1 + m00 + m11 + m22,
250
+ 1 + m00 - m11 - m22,
251
+ 1 - m00 + m11 - m22,
252
+ 1 - m00 - m11 + m22,
253
+ ),
254
+ dim=-1,
255
+ )
256
+ root = torch.sqrt(squares.clamp_min(1e-12)) # 2 * |component|
257
+ candidates = torch.stack(
258
+ (
259
+ torch.stack((root[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01), -1),
260
+ torch.stack((m21 - m12, root[..., 1] ** 2, m10 + m01, m02 + m20), -1),
261
+ torch.stack((m02 - m20, m10 + m01, root[..., 2] ** 2, m12 + m21), -1),
262
+ torch.stack((m10 - m01, m20 + m02, m21 + m12, root[..., 3] ** 2), -1),
263
+ ),
264
+ dim=-2,
265
+ ) / (2 * root[..., None])
266
+ best = squares.argmax(dim=-1)
267
+ q = torch.gather(
268
+ candidates, -2, best[..., None, None].expand(*best.shape, 1, 4)
269
+ ).squeeze(-2)
270
+ q = q / q.norm(dim=-1, keepdim=True)
271
+ return torch.where(q[..., :1] < 0, -q, q)
272
+
273
+
274
+ def quat_to_matrix(q: torch.Tensor) -> torch.Tensor:
275
+ """[.., 4] quaternion (wxyz, any norm) -> [.., 3, 3] rotation."""
276
+ q = q / q.norm(dim=-1, keepdim=True)
277
+ w, x, y, z = q.unbind(-1)
278
+ return torch.stack(
279
+ (
280
+ 1 - 2 * (y * y + z * z),
281
+ 2 * (x * y - w * z),
282
+ 2 * (x * z + w * y),
283
+ 2 * (x * y + w * z),
284
+ 1 - 2 * (x * x + z * z),
285
+ 2 * (y * z - w * x),
286
+ 2 * (x * z - w * y),
287
+ 2 * (y * z + w * x),
288
+ 1 - 2 * (x * x + y * y),
289
+ ),
290
+ dim=-1,
291
+ ).reshape(*q.shape[:-1], 3, 3)
292
+
293
+
294
+ def quat_multiply(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
295
+ """Hamilton product a * b (wxyz): rotate by b, then by a."""
296
+ aw, ax, ay, az = a.unbind(-1)
297
+ bw, bx, by, bz = b.unbind(-1)
298
+ return torch.stack(
299
+ (
300
+ aw * bw - ax * bx - ay * by - az * bz,
301
+ aw * bx + ax * bw + ay * bz - az * by,
302
+ aw * by - ax * bz + ay * bw + az * bx,
303
+ aw * bz + ax * by - ay * bx + az * bw,
304
+ ),
305
+ dim=-1,
306
+ )
307
+
308
+
309
+ def rotate_sh(
310
+ colors: torch.Tensor, rotation: torch.Tensor, degree: int
311
+ ) -> torch.Tensor:
312
+ """Rotate SH coefficients [B, N, K, 3] by per-sample rotations [B, 3, 3].
313
+
314
+ The rotated field along d is the original along R^T d, in the real SH basis of
315
+ 3DGS / gsplat, any degree. Degree 0 is rotation invariant. Degree 1 is
316
+ C1 * (-y c1 + z c2 - x c3), a linear form a . d with a = (-c3, -c1, c2), so it
317
+ turns into R a. Degrees >= 2 use `sh_rotation_blocks`, built in fp64.
318
+ """
319
+ if colors.shape[-2] != (degree + 1) ** 2:
320
+ raise ValueError(
321
+ f"degree {degree} needs {(degree + 1) ** 2} SH coefficients, got "
322
+ f"{tuple(colors.shape)}"
323
+ )
324
+ rotated = colors.clone()
325
+ if degree < 1:
326
+ return rotated
327
+ c1, c2, c3 = colors[:, :, 1], colors[:, :, 2], colors[:, :, 3]
328
+ a = torch.stack((-c3, -c1, c2), dim=2) # [B, N, xyz, rgb]
329
+ a = torch.einsum("bij,bnjc->bnic", rotation, a)
330
+ rotated[:, :, 1] = -a[:, :, 1]
331
+ rotated[:, :, 2] = a[:, :, 2]
332
+ rotated[:, :, 3] = -a[:, :, 0]
333
+ if degree < 2:
334
+ return rotated
335
+ blocks = sh_rotation_blocks(rotation.double(), degree)
336
+ for degree_l in range(2, degree + 1):
337
+ band = slice(degree_l**2, (degree_l + 1) ** 2)
338
+ block = blocks[degree_l].to(colors.dtype)
339
+ rotated[:, :, band] = torch.einsum("bij,bnjc->bnic", block, colors[:, :, band])
340
+ return rotated
341
+
342
+
343
+ def sh_rotation_blocks(rotation: torch.Tensor, degree: int) -> list[torch.Tensor]:
344
+ """Real SH rotation matrices D_l [.., 2l+1, 2l+1] for l = 0..degree, in the
345
+ 3DGS / gsplat basis: coefficients c_l of degree l become D_l @ c_l.
346
+
347
+ Ivanic & Ruedenberg's recursion (J. Phys. Chem. 100, 6342 (1996); errata 102,
348
+ 9099 (1998)), exact for any degree: D_l from D_{l-1} and D_1, which is R in the
349
+ (y, z, x) order of the real basis without the Condon-Shortley phase. gsplat's
350
+ basis carries that phase, (-1)^m on order m, so each D_l is conjugated by it.
351
+ """
352
+ order = [1, 2, 0] # m = -1, 0, 1 <-> y, z, x
353
+ r1 = rotation[..., order, :][..., :, order]
354
+ blocks = [torch.ones_like(rotation[..., :1, :1]), r1]
355
+ for degree_l in range(2, degree + 1):
356
+ blocks.append(_ivanic_ruedenberg_step(r1, blocks[-1], degree_l))
357
+ out = []
358
+ for degree_l, block in enumerate(blocks[: degree + 1]):
359
+ m = torch.arange(-degree_l, degree_l + 1, device=rotation.device)
360
+ phase = (1 - 2 * (m % 2)).to(rotation.dtype)
361
+ out.append(block * phase[:, None] * phase[None, :])
362
+ return out
363
+
364
+
365
+ def _ivanic_ruedenberg_step(
366
+ r1: torch.Tensor, prev: torch.Tensor, degree: int
367
+ ) -> torch.Tensor:
368
+ """D_l [.., 2l+1, 2l+1] from D_1 and D_{l-1}, indexed by order m in -l..l."""
369
+ lv = degree
370
+
371
+ def d1(i: int, j: int) -> torch.Tensor:
372
+ return r1[..., i + 1, j + 1]
373
+
374
+ def d_prev(a: int, b: int) -> torch.Tensor:
375
+ return prev[..., a + lv - 1, b + lv - 1]
376
+
377
+ def p(i: int, a: int, b: int) -> torch.Tensor:
378
+ if b == lv:
379
+ return d1(i, 1) * d_prev(a, lv - 1) - d1(i, -1) * d_prev(a, -lv + 1)
380
+ if b == -lv:
381
+ return d1(i, 1) * d_prev(a, -lv + 1) + d1(i, -1) * d_prev(a, lv - 1)
382
+ return d1(i, 0) * d_prev(a, b)
383
+
384
+ def u(m: int, n: int) -> torch.Tensor:
385
+ return p(0, m, n)
386
+
387
+ def v(m: int, n: int) -> torch.Tensor:
388
+ if m == 0:
389
+ return p(1, 1, n) + p(-1, -1, n)
390
+ if m > 0:
391
+ first = p(1, m - 1, n) * math.sqrt(1 + (m == 1))
392
+ return first if m == 1 else first - p(-1, -m + 1, n)
393
+ first = p(-1, -m - 1, n) * math.sqrt(1 + (m == -1))
394
+ return first if m == -1 else first + p(1, m + 1, n)
395
+
396
+ def w(m: int, n: int) -> torch.Tensor:
397
+ if m > 0:
398
+ return p(1, m + 1, n) + p(-1, -m - 1, n)
399
+ return p(1, m - 1, n) - p(-1, -m + 1, n)
400
+
401
+ rows = []
402
+ for m in range(-lv, lv + 1):
403
+ row = []
404
+ for n in range(-lv, lv + 1):
405
+ am = abs(m)
406
+ denom = 2 * lv * (2 * lv - 1) if abs(n) == lv else (lv + n) * (lv - n)
407
+ cu = math.sqrt((lv + m) * (lv - m) / denom)
408
+ cv = 0.5 * math.sqrt((1 + (m == 0)) * (lv + am - 1) * (lv + am) / denom)
409
+ cv = -cv if m == 0 else cv
410
+ cw = 0.0 if m == 0 else -0.5 * math.sqrt((lv - am - 1) * (lv - am) / denom)
411
+ entry = cv * v(m, n)
412
+ if cu:
413
+ entry = entry + cu * u(m, n)
414
+ if cw:
415
+ entry = entry + cw * w(m, n)
416
+ row.append(entry)
417
+ rows.append(torch.stack(row, dim=-1))
418
+ return torch.stack(rows, dim=-2)