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/processor.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Processor base: a model's pre / post-processing, stored as `processor_config.json`.
|
|
2
|
+
|
|
3
|
+
A processor owns everything between the standard `Views` / `Gaussians` and one
|
|
4
|
+
model's tensors: resizing to the training rule, normalising cameras into the
|
|
5
|
+
model's frame (the `ModelFrame` that also takes predictions back to the caller's
|
|
6
|
+
world frame; fitted to the given poses for models that predict cameras), and
|
|
7
|
+
building the model input. Its settings are plain JSON so they
|
|
8
|
+
travel with the weights.
|
|
9
|
+
|
|
10
|
+
Configs that subclass `ImageFitConfig` get `fit_views` / `fit_cameras`: resize +
|
|
11
|
+
centre crop by default (the training rule), or pad / pass-through, and optionally a
|
|
12
|
+
custom resize callable (`Processor(..., resize=fn)`, not serialised).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, ClassVar
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
|
|
24
|
+
from .geometry import ModelFrame
|
|
25
|
+
from .image import (
|
|
26
|
+
CROP_MODES,
|
|
27
|
+
RESIZE_MODES,
|
|
28
|
+
Resize,
|
|
29
|
+
fit_images,
|
|
30
|
+
fit_intrinsics,
|
|
31
|
+
)
|
|
32
|
+
from .types import Cameras, Gaussians, Views
|
|
33
|
+
|
|
34
|
+
PROCESSOR_CONFIG_NAME = "processor_config.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Prepared:
|
|
39
|
+
"""A processor's output for one call: what the model eats, and the frame.
|
|
40
|
+
|
|
41
|
+
`frame` is None for models that predict their cameras: it is then fitted
|
|
42
|
+
after the model, from the predicted cameras (see `Processor.output_frame`).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
model_input: Any
|
|
46
|
+
frame: ModelFrame | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ImageFitConfig:
|
|
51
|
+
"""How inputs are brought to the model's input shape. Subclass per model and
|
|
52
|
+
re-declare fields to change their defaults."""
|
|
53
|
+
|
|
54
|
+
# [h, w] the model sees.
|
|
55
|
+
image_shape: list[int] = field(default_factory=lambda: [256, 256])
|
|
56
|
+
# Built-in resize kernel (see `ffgs.image.rescale`).
|
|
57
|
+
resize_mode: str = "bilinear"
|
|
58
|
+
# "crop": cover + centre crop (the training rule); "pad": fit inside + pad;
|
|
59
|
+
# "none": inputs are already `image_shape`.
|
|
60
|
+
crop_mode: str = "crop"
|
|
61
|
+
# Fill value of the padded border for crop_mode="pad".
|
|
62
|
+
pad_value: float = 0.0
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
self.image_shape = [int(x) for x in self.image_shape]
|
|
66
|
+
if len(self.image_shape) != 2:
|
|
67
|
+
raise ValueError(f"image_shape must be [h, w], got {self.image_shape}")
|
|
68
|
+
if self.resize_mode not in RESIZE_MODES:
|
|
69
|
+
raise ValueError(f"resize_mode must be one of {RESIZE_MODES}")
|
|
70
|
+
if self.crop_mode not in CROP_MODES:
|
|
71
|
+
raise ValueError(f"crop_mode must be one of {CROP_MODES}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Processor:
|
|
75
|
+
config_cls: ClassVar[type]
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
config: Any | None = None,
|
|
80
|
+
*,
|
|
81
|
+
resize: Resize | None = None,
|
|
82
|
+
**overrides: Any,
|
|
83
|
+
) -> None:
|
|
84
|
+
if config is None:
|
|
85
|
+
config = self.config_cls()
|
|
86
|
+
elif isinstance(config, dict):
|
|
87
|
+
config = self.config_cls(**config)
|
|
88
|
+
if overrides:
|
|
89
|
+
config = self.config_cls(**{**asdict(config), **overrides})
|
|
90
|
+
self.config = config
|
|
91
|
+
# A custom resize callable for `fit_views`; replaces `resize_mode`.
|
|
92
|
+
self.resize = resize
|
|
93
|
+
|
|
94
|
+
# --- fitting inputs (configs subclassing ImageFitConfig) ----------------------
|
|
95
|
+
|
|
96
|
+
def fit_views(self, views: Views, image_shape: list[int] | None = None) -> Views:
|
|
97
|
+
"""Bring `views` to `image_shape` (default: the model's) by the config's
|
|
98
|
+
`crop_mode`. Also the way to derive ground truth for target views. Returns
|
|
99
|
+
normalised intrinsics (None if `views` had none); c2w untouched."""
|
|
100
|
+
cfg = self._fit_config()
|
|
101
|
+
shape = tuple(image_shape or cfg.image_shape)
|
|
102
|
+
if views.intrinsics is None:
|
|
103
|
+
# The images are fitted the same way whatever K is; any valid one does.
|
|
104
|
+
source_k = torch.tensor(
|
|
105
|
+
[[1.0, 0, 0.5], [0, 1.0, 0.5], [0, 0, 1]], device=views.images.device
|
|
106
|
+
).expand(*views.images.shape[:2], 3, 3)
|
|
107
|
+
else:
|
|
108
|
+
source_k = views.normalized_k
|
|
109
|
+
images, intrinsics = [], []
|
|
110
|
+
for i in range(views.images.shape[0]):
|
|
111
|
+
img, k = fit_images(
|
|
112
|
+
views.images[i],
|
|
113
|
+
source_k[i],
|
|
114
|
+
shape,
|
|
115
|
+
resize_mode=cfg.resize_mode,
|
|
116
|
+
crop_mode=cfg.crop_mode,
|
|
117
|
+
resize=self.resize,
|
|
118
|
+
pad_value=cfg.pad_value,
|
|
119
|
+
)
|
|
120
|
+
images.append(img)
|
|
121
|
+
intrinsics.append(k)
|
|
122
|
+
return Views(
|
|
123
|
+
torch.stack(images),
|
|
124
|
+
None if views.intrinsics is None else torch.stack(intrinsics),
|
|
125
|
+
views.c2w,
|
|
126
|
+
normalized_intrinsics=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def fit_cameras(
|
|
130
|
+
self, cameras: Cameras, image_shape: list[int] | None = None
|
|
131
|
+
) -> Cameras:
|
|
132
|
+
"""`fit_views` for cameras alone: where to render to compare against
|
|
133
|
+
`fit_views` images. `cameras.image_shape` is the source image size."""
|
|
134
|
+
cfg = self._fit_config()
|
|
135
|
+
shape = tuple(image_shape or cfg.image_shape)
|
|
136
|
+
k = fit_intrinsics(
|
|
137
|
+
cameras.normalized_k, cameras.image_shape, shape, cfg.crop_mode
|
|
138
|
+
)
|
|
139
|
+
return Cameras(cameras.c2w, k, shape, normalized_intrinsics=True)
|
|
140
|
+
|
|
141
|
+
def _fit_config(self) -> ImageFitConfig:
|
|
142
|
+
if not isinstance(self.config, ImageFitConfig):
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"{type(self.config).__name__} does not subclass ImageFitConfig"
|
|
145
|
+
)
|
|
146
|
+
return self.config
|
|
147
|
+
|
|
148
|
+
# --- serialisation ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
return asdict(self.config)
|
|
152
|
+
|
|
153
|
+
def save_pretrained(self, save_directory: str | Path) -> Path:
|
|
154
|
+
path = Path(save_directory) / PROCESSOR_CONFIG_NAME
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
path.write_text(json.dumps(self.to_dict(), indent=2, sort_keys=True) + "\n")
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_pretrained(cls, pretrained: str | Path, **hub_kwargs: Any) -> Processor:
|
|
161
|
+
data = read_json(pretrained, PROCESSOR_CONFIG_NAME, **hub_kwargs)
|
|
162
|
+
return cls.from_dict(data, source=PROCESSOR_CONFIG_NAME)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_dict(cls, data: dict[str, Any], source: str = "config") -> Processor:
|
|
166
|
+
known = {f.name for f in fields(cls.config_cls)}
|
|
167
|
+
unknown = set(data) - known
|
|
168
|
+
if unknown:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"{source} has keys {sorted(unknown)} unknown to {cls.__name__}"
|
|
171
|
+
)
|
|
172
|
+
return cls(data)
|
|
173
|
+
|
|
174
|
+
# --- the per-model contract -------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def preprocess(
|
|
177
|
+
self, views: Views, device: torch.device, **options: Any
|
|
178
|
+
) -> Prepared:
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
def postprocess(self, model_output: Any) -> Gaussians:
|
|
182
|
+
"""Raw model output -> model-frame `Gaussians`."""
|
|
183
|
+
raise NotImplementedError
|
|
184
|
+
|
|
185
|
+
def predicted_cameras(self, model_output: Any) -> Cameras | None:
|
|
186
|
+
"""The input cameras a model predicts, in its frame (normalised K for the
|
|
187
|
+
model's input images); None for models that take cameras as input."""
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def output_frame(
|
|
191
|
+
self, views: Views, prepared: Prepared, cameras: Cameras | None
|
|
192
|
+
) -> ModelFrame | None:
|
|
193
|
+
"""The model frame of this call. Posed models fix it in `preprocess`;
|
|
194
|
+
for models that predict cameras it is fitted to the given poses over all
|
|
195
|
+
views (`ModelFrame.fit`), and None when there are none."""
|
|
196
|
+
if prepared.frame is not None:
|
|
197
|
+
return prepared.frame
|
|
198
|
+
if cameras is None or views.c2w is None:
|
|
199
|
+
return None
|
|
200
|
+
return ModelFrame.fit(cameras.c2w, views.c2w)
|
|
201
|
+
|
|
202
|
+
def render_planes(self, frame: ModelFrame | None) -> tuple[float, float]:
|
|
203
|
+
"""(near, far) for Gaussians predicted in `frame`: in world units, or in
|
|
204
|
+
model units when there is no frame (no input poses)."""
|
|
205
|
+
raise NotImplementedError
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def background(self) -> tuple[float, float, float] | None:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def render_settings(self) -> dict[str, Any]:
|
|
213
|
+
"""The model's other rasteriser settings: keyword arguments of
|
|
214
|
+
`ffgs.render.render` (e.g. `radius_clip`, `rasterize_mode`, `clamp`)
|
|
215
|
+
that `GSPipeline.render` uses unless the caller passes them."""
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def autocast_dtype(self) -> torch.dtype | None:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def read_json(pretrained: str | Path, filename: str, **hub_kwargs: Any) -> dict:
|
|
224
|
+
"""`filename` from a local directory or an HF Hub repo id."""
|
|
225
|
+
local = Path(pretrained)
|
|
226
|
+
if local.is_dir():
|
|
227
|
+
path = local / filename
|
|
228
|
+
if not path.is_file():
|
|
229
|
+
raise FileNotFoundError(f"{filename} not found in {local}")
|
|
230
|
+
else:
|
|
231
|
+
from huggingface_hub import hf_hub_download
|
|
232
|
+
|
|
233
|
+
path = Path(
|
|
234
|
+
hf_hub_download(repo_id=str(pretrained), filename=filename, **hub_kwargs)
|
|
235
|
+
)
|
|
236
|
+
return json.loads(path.read_text())
|
ffgs/registry.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""model_type -> (model class, processor class).
|
|
2
|
+
|
|
3
|
+
A `model_type` is looked up in order:
|
|
4
|
+
|
|
5
|
+
1. models registered in this process (`register_model`), including built-ins,
|
|
6
|
+
which are imported only when their `model_type` is first asked for;
|
|
7
|
+
2. installed plugins: the `ffgs.models` entry point group, entry point name =
|
|
8
|
+
model_type, value = a `ModelSpec` (or a module that calls `register_model`
|
|
9
|
+
on import). Plugins are loaded only when their `model_type` is asked for;
|
|
10
|
+
3. code shipped in the model repo (`config.json:auto_map`), only with
|
|
11
|
+
`trust_remote_code=True` — see `ffgs.dynamic` and `GSPipeline.from_pretrained`.
|
|
12
|
+
|
|
13
|
+
`import ffgs` imports no plugin and runs no repo code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
import warnings
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .processor import Processor
|
|
26
|
+
|
|
27
|
+
ENTRY_POINT_GROUP = "ffgs.models"
|
|
28
|
+
|
|
29
|
+
# model_type -> module that calls `register_model` for it on import.
|
|
30
|
+
_BUILTIN: dict[str, str] = {
|
|
31
|
+
"anysplat": "ffgs.models.anysplat",
|
|
32
|
+
"tokengs": "ffgs.models.tokengs",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ModelSpec:
|
|
38
|
+
model_type: str
|
|
39
|
+
model_cls: type
|
|
40
|
+
processor_cls: type[Processor]
|
|
41
|
+
# Upstream checkpoint (as loaded from a zoo entry's weights file) -> state dict
|
|
42
|
+
# of `model_cls`. Not applied to directories written by `save_pretrained`.
|
|
43
|
+
convert_state_dict: Callable[[Any], dict[str, Any]] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_REGISTRY: dict[str, ModelSpec] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register_model(spec: ModelSpec) -> ModelSpec:
|
|
50
|
+
existing = _REGISTRY.get(spec.model_type)
|
|
51
|
+
if existing is not None and existing != spec:
|
|
52
|
+
raise ValueError(f"model_type {spec.model_type!r} is already registered")
|
|
53
|
+
_REGISTRY[spec.model_type] = spec
|
|
54
|
+
return spec
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_model_spec(model_type: str) -> ModelSpec:
|
|
58
|
+
if model_type not in _REGISTRY and model_type in _BUILTIN:
|
|
59
|
+
importlib.import_module(_BUILTIN[model_type])
|
|
60
|
+
if model_type not in _REGISTRY:
|
|
61
|
+
plugins = _plugin_entry_points().get(model_type, [])
|
|
62
|
+
if len(plugins) > 1:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"model_type {model_type!r} is provided by several installed "
|
|
65
|
+
f"plugins: {[ep.value for ep in plugins]}; uninstall all but one"
|
|
66
|
+
)
|
|
67
|
+
if plugins:
|
|
68
|
+
_load_plugin(plugins[0])
|
|
69
|
+
if model_type not in _REGISTRY:
|
|
70
|
+
raise KeyError(
|
|
71
|
+
f"unknown model_type {model_type!r}; known: {known_model_types()}. "
|
|
72
|
+
"Install the package that provides it (found through the "
|
|
73
|
+
f"{ENTRY_POINT_GROUP!r} entry point group) or call "
|
|
74
|
+
"ffgs.register_model(...) before loading. A model repo can also ship "
|
|
75
|
+
"its own code under config.json:auto_map, loaded with "
|
|
76
|
+
"GSPipeline.from_pretrained(..., trust_remote_code=True)."
|
|
77
|
+
)
|
|
78
|
+
return _REGISTRY[model_type]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def known_model_types() -> list[str]:
|
|
82
|
+
"""Registered, built-in and installed-plugin model types (nothing imported)."""
|
|
83
|
+
return sorted(set(_REGISTRY) | set(_BUILTIN) | set(_plugin_entry_points()))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def spec_for_model(model: object) -> ModelSpec:
|
|
87
|
+
for model_type in _BUILTIN:
|
|
88
|
+
get_model_spec(model_type)
|
|
89
|
+
for model_type, plugins in _plugin_entry_points().items():
|
|
90
|
+
if model_type in _REGISTRY:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
get_model_spec(model_type)
|
|
94
|
+
except Exception as err: # a broken plugin must not hide the others
|
|
95
|
+
warnings.warn(
|
|
96
|
+
f"skipping ffgs plugin {[ep.value for ep in plugins]}: {err}",
|
|
97
|
+
stacklevel=2,
|
|
98
|
+
)
|
|
99
|
+
# The model's own class first: a plugin may subclass another model.
|
|
100
|
+
for spec in _REGISTRY.values():
|
|
101
|
+
if type(model) is spec.model_cls:
|
|
102
|
+
return spec
|
|
103
|
+
for spec in _REGISTRY.values():
|
|
104
|
+
if isinstance(model, spec.model_cls):
|
|
105
|
+
return spec
|
|
106
|
+
raise KeyError(f"no model_type registered for {type(model).__name__}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _plugin_entry_points() -> dict[str, list[EntryPoint]]:
|
|
110
|
+
found: dict[str, list[EntryPoint]] = {}
|
|
111
|
+
for ep in entry_points(group=ENTRY_POINT_GROUP):
|
|
112
|
+
# The same distribution can be visible twice on sys.path.
|
|
113
|
+
if all(other.value != ep.value for other in found.get(ep.name, [])):
|
|
114
|
+
found.setdefault(ep.name, []).append(ep)
|
|
115
|
+
return found
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_plugin(ep: EntryPoint) -> None:
|
|
119
|
+
loaded = ep.load()
|
|
120
|
+
if isinstance(loaded, ModelSpec):
|
|
121
|
+
if loaded.model_type != ep.name:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"entry point {ep.name!r} = {ep.value!r} is a ModelSpec for "
|
|
124
|
+
f"model_type {loaded.model_type!r}; the entry point name must "
|
|
125
|
+
"be the model_type"
|
|
126
|
+
)
|
|
127
|
+
register_model(loaded)
|
|
128
|
+
elif ep.name not in _REGISTRY:
|
|
129
|
+
raise TypeError(
|
|
130
|
+
f"entry point {ep.name!r} = {ep.value!r} must be a ModelSpec, or a "
|
|
131
|
+
f"module that calls register_model for {ep.name!r} on import"
|
|
132
|
+
)
|
ffgs/render.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Rasterise `Gaussians` at `Cameras` with gsplat (imported on first use)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from .types import Cameras, Gaussians
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _rasterization():
|
|
13
|
+
try:
|
|
14
|
+
from gsplat import rasterization
|
|
15
|
+
except ImportError:
|
|
16
|
+
try:
|
|
17
|
+
from gsplat.rendering import rasterization
|
|
18
|
+
except ImportError as err:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
'rendering needs gsplat: pip install "ffgs[render]"'
|
|
21
|
+
) from err
|
|
22
|
+
return rasterization
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render(
|
|
26
|
+
gaussians: Gaussians,
|
|
27
|
+
cameras: Cameras,
|
|
28
|
+
*,
|
|
29
|
+
near: float | torch.Tensor,
|
|
30
|
+
far: float | torch.Tensor,
|
|
31
|
+
background: Sequence[float] | None = (0.5, 0.5, 0.5),
|
|
32
|
+
render_depth: bool = False,
|
|
33
|
+
radius_clip: float = 0.0,
|
|
34
|
+
rasterize_mode: str = "classic",
|
|
35
|
+
clamp: bool = False,
|
|
36
|
+
) -> dict[str, torch.Tensor]:
|
|
37
|
+
"""-> {"images": [B, V, 3, H, W], "alphas": [B, V, 1, H, W], ["depths"]}.
|
|
38
|
+
|
|
39
|
+
`near` / `far` are in the units of the frame the Gaussians and cameras share:
|
|
40
|
+
one value, or one per sample ([B]). `radius_clip` (pixels) and
|
|
41
|
+
`rasterize_mode` ("classic" / "antialiased") are gsplat's; `clamp` clips the
|
|
42
|
+
images to [0, 1]. A model's own values come from its processor
|
|
43
|
+
(`Processor.render_settings`, applied by `GSPipeline.render`).
|
|
44
|
+
"""
|
|
45
|
+
rasterization = _rasterization()
|
|
46
|
+
h, w = cameras.image_shape
|
|
47
|
+
device = gaussians.means.device
|
|
48
|
+
viewmats = torch.inverse(cameras.c2w.float()).to(device)
|
|
49
|
+
ks = cameras.pixel_k.to(device)
|
|
50
|
+
b, v = viewmats.shape[:2]
|
|
51
|
+
|
|
52
|
+
means = gaussians.means.contiguous().float()
|
|
53
|
+
quats = gaussians.quats.contiguous().float()
|
|
54
|
+
scales = gaussians.scales.contiguous().float()
|
|
55
|
+
opacities = gaussians.opacities.contiguous().float()
|
|
56
|
+
colors = gaussians.colors.contiguous().float()
|
|
57
|
+
kwargs = {}
|
|
58
|
+
if gaussians.sh_degree is not None:
|
|
59
|
+
kwargs["sh_degree"] = gaussians.sh_degree
|
|
60
|
+
backgrounds = None
|
|
61
|
+
if background is not None:
|
|
62
|
+
backgrounds = torch.tensor(background, dtype=torch.float32, device=device)
|
|
63
|
+
backgrounds = backgrounds[None] # [1, 3]: one camera per call
|
|
64
|
+
|
|
65
|
+
images, alphas, depths = [], [], []
|
|
66
|
+
for i in range(b):
|
|
67
|
+
# One camera per call: for SH colours gsplat materialises the coefficients
|
|
68
|
+
# per camera ([C, N, K, 3]), so memory would grow with the number of
|
|
69
|
+
# cameras. Cameras are rasterised independently; the images are the same.
|
|
70
|
+
per_camera = [
|
|
71
|
+
rasterization(
|
|
72
|
+
means=means[i],
|
|
73
|
+
quats=quats[i],
|
|
74
|
+
scales=scales[i],
|
|
75
|
+
opacities=opacities[i],
|
|
76
|
+
colors=colors[i],
|
|
77
|
+
viewmats=viewmats[i, j : j + 1],
|
|
78
|
+
Ks=ks[i, j : j + 1],
|
|
79
|
+
width=w,
|
|
80
|
+
height=h,
|
|
81
|
+
near_plane=_per_sample(near, i),
|
|
82
|
+
far_plane=_per_sample(far, i),
|
|
83
|
+
packed=False,
|
|
84
|
+
backgrounds=backgrounds,
|
|
85
|
+
render_mode="RGB+ED" if render_depth else "RGB",
|
|
86
|
+
radius_clip=radius_clip,
|
|
87
|
+
rasterize_mode=rasterize_mode,
|
|
88
|
+
**kwargs,
|
|
89
|
+
)[:2]
|
|
90
|
+
for j in range(v)
|
|
91
|
+
]
|
|
92
|
+
rendered = torch.cat([r for r, _ in per_camera])
|
|
93
|
+
alpha = torch.cat([a for _, a in per_camera])
|
|
94
|
+
image = rendered[..., :3]
|
|
95
|
+
if clamp:
|
|
96
|
+
image = image.clamp(0.0, 1.0)
|
|
97
|
+
images.append(image.permute(0, 3, 1, 2))
|
|
98
|
+
alphas.append(alpha.permute(0, 3, 1, 2))
|
|
99
|
+
if render_depth:
|
|
100
|
+
depths.append(rendered[..., 3:].permute(0, 3, 1, 2))
|
|
101
|
+
|
|
102
|
+
out = {"images": torch.stack(images), "alphas": torch.stack(alphas)}
|
|
103
|
+
if render_depth:
|
|
104
|
+
out["depths"] = torch.stack(depths)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _per_sample(value: float | torch.Tensor, index: int) -> float:
|
|
109
|
+
if isinstance(value, torch.Tensor):
|
|
110
|
+
return float(value.reshape(-1)[index] if value.numel() > 1 else value)
|
|
111
|
+
return value
|