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