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
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)
|