multimodalart HF Staff commited on
Commit
c9ad6b5
·
verified ·
1 Parent(s): ef7ba95

CodecSplat demo

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +42 -0
  2. LICENSE +21 -0
  3. README.md +33 -38
  4. app.py +402 -193
  5. colmap_io.py +414 -0
  6. colmap_sfm.py +4 -98
  7. examples/counter/counter_00.jpg +3 -0
  8. examples/counter/counter_01.jpg +3 -0
  9. examples/counter/counter_02.jpg +3 -0
  10. examples/counter/counter_03.jpg +3 -0
  11. examples/counter/counter_04.jpg +3 -0
  12. examples/counter/counter_05.jpg +3 -0
  13. examples/counter/counter_06.jpg +3 -0
  14. examples/counter/counter_07.jpg +3 -0
  15. examples/counter/counter_08.jpg +3 -0
  16. examples/counter/counter_09.jpg +3 -0
  17. examples/counter/counter_10.jpg +3 -0
  18. examples/counter/counter_11.jpg +3 -0
  19. examples/counter/counter_12.jpg +3 -0
  20. examples/counter/counter_13.jpg +3 -0
  21. examples/garden/garden_00.jpg +3 -0
  22. examples/garden/garden_01.jpg +3 -0
  23. examples/garden/garden_02.jpg +3 -0
  24. examples/garden/garden_03.jpg +3 -0
  25. examples/garden/garden_04.jpg +3 -0
  26. examples/garden/garden_05.jpg +3 -0
  27. examples/garden/garden_06.jpg +3 -0
  28. examples/garden/garden_07.jpg +3 -0
  29. examples/garden/garden_08.jpg +3 -0
  30. examples/garden/garden_09.jpg +3 -0
  31. examples/garden/garden_10.jpg +3 -0
  32. examples/garden/garden_11.jpg +3 -0
  33. examples/garden/garden_12.jpg +3 -0
  34. examples/garden/garden_13.jpg +3 -0
  35. examples/kitchen/kitchen_00.jpg +3 -0
  36. examples/kitchen/kitchen_01.jpg +3 -0
  37. examples/kitchen/kitchen_02.jpg +3 -0
  38. examples/kitchen/kitchen_03.jpg +3 -0
  39. examples/kitchen/kitchen_04.jpg +3 -0
  40. examples/kitchen/kitchen_05.jpg +3 -0
  41. examples/kitchen/kitchen_06.jpg +3 -0
  42. examples/kitchen/kitchen_07.jpg +3 -0
  43. examples/kitchen/kitchen_08.jpg +3 -0
  44. examples/kitchen/kitchen_09.jpg +3 -0
  45. examples/kitchen/kitchen_10.jpg +3 -0
  46. examples/kitchen/kitchen_11.jpg +3 -0
  47. examples/kitchen/kitchen_12.jpg +3 -0
  48. examples/kitchen/kitchen_13.jpg +3 -0
  49. requirements.txt +16 -13
  50. src/main.py +339 -0
.gitattributes CHANGED
@@ -83,3 +83,45 @@ examples/scene_b/21.jpg filter=lfs diff=lfs merge=lfs -text
83
  examples/scene_b/22.jpg filter=lfs diff=lfs merge=lfs -text
84
  examples/scene_b/23.jpg filter=lfs diff=lfs merge=lfs -text
85
  examples/scene_b/24.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  examples/scene_b/22.jpg filter=lfs diff=lfs merge=lfs -text
84
  examples/scene_b/23.jpg filter=lfs diff=lfs merge=lfs -text
85
  examples/scene_b/24.jpg filter=lfs diff=lfs merge=lfs -text
86
+ examples/counter/counter_00.jpg filter=lfs diff=lfs merge=lfs -text
87
+ examples/counter/counter_01.jpg filter=lfs diff=lfs merge=lfs -text
88
+ examples/counter/counter_02.jpg filter=lfs diff=lfs merge=lfs -text
89
+ examples/counter/counter_03.jpg filter=lfs diff=lfs merge=lfs -text
90
+ examples/counter/counter_04.jpg filter=lfs diff=lfs merge=lfs -text
91
+ examples/counter/counter_05.jpg filter=lfs diff=lfs merge=lfs -text
92
+ examples/counter/counter_06.jpg filter=lfs diff=lfs merge=lfs -text
93
+ examples/counter/counter_07.jpg filter=lfs diff=lfs merge=lfs -text
94
+ examples/counter/counter_08.jpg filter=lfs diff=lfs merge=lfs -text
95
+ examples/counter/counter_09.jpg filter=lfs diff=lfs merge=lfs -text
96
+ examples/counter/counter_10.jpg filter=lfs diff=lfs merge=lfs -text
97
+ examples/counter/counter_11.jpg filter=lfs diff=lfs merge=lfs -text
98
+ examples/counter/counter_12.jpg filter=lfs diff=lfs merge=lfs -text
99
+ examples/counter/counter_13.jpg filter=lfs diff=lfs merge=lfs -text
100
+ examples/garden/garden_00.jpg filter=lfs diff=lfs merge=lfs -text
101
+ examples/garden/garden_01.jpg filter=lfs diff=lfs merge=lfs -text
102
+ examples/garden/garden_02.jpg filter=lfs diff=lfs merge=lfs -text
103
+ examples/garden/garden_03.jpg filter=lfs diff=lfs merge=lfs -text
104
+ examples/garden/garden_04.jpg filter=lfs diff=lfs merge=lfs -text
105
+ examples/garden/garden_05.jpg filter=lfs diff=lfs merge=lfs -text
106
+ examples/garden/garden_06.jpg filter=lfs diff=lfs merge=lfs -text
107
+ examples/garden/garden_07.jpg filter=lfs diff=lfs merge=lfs -text
108
+ examples/garden/garden_08.jpg filter=lfs diff=lfs merge=lfs -text
109
+ examples/garden/garden_09.jpg filter=lfs diff=lfs merge=lfs -text
110
+ examples/garden/garden_10.jpg filter=lfs diff=lfs merge=lfs -text
111
+ examples/garden/garden_11.jpg filter=lfs diff=lfs merge=lfs -text
112
+ examples/garden/garden_12.jpg filter=lfs diff=lfs merge=lfs -text
113
+ examples/garden/garden_13.jpg filter=lfs diff=lfs merge=lfs -text
114
+ examples/kitchen/kitchen_00.jpg filter=lfs diff=lfs merge=lfs -text
115
+ examples/kitchen/kitchen_01.jpg filter=lfs diff=lfs merge=lfs -text
116
+ examples/kitchen/kitchen_02.jpg filter=lfs diff=lfs merge=lfs -text
117
+ examples/kitchen/kitchen_03.jpg filter=lfs diff=lfs merge=lfs -text
118
+ examples/kitchen/kitchen_04.jpg filter=lfs diff=lfs merge=lfs -text
119
+ examples/kitchen/kitchen_05.jpg filter=lfs diff=lfs merge=lfs -text
120
+ examples/kitchen/kitchen_06.jpg filter=lfs diff=lfs merge=lfs -text
121
+ examples/kitchen/kitchen_07.jpg filter=lfs diff=lfs merge=lfs -text
122
+ examples/kitchen/kitchen_08.jpg filter=lfs diff=lfs merge=lfs -text
123
+ examples/kitchen/kitchen_09.jpg filter=lfs diff=lfs merge=lfs -text
124
+ examples/kitchen/kitchen_10.jpg filter=lfs diff=lfs merge=lfs -text
125
+ examples/kitchen/kitchen_11.jpg filter=lfs diff=lfs merge=lfs -text
126
+ examples/kitchen/kitchen_12.jpg filter=lfs diff=lfs merge=lfs -text
127
+ examples/kitchen/kitchen_13.jpg filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pengpeng Yu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -6,7 +6,7 @@ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
  app_file: app.py
9
- short_description: Compress photos into a tiny 3D Gaussian splat bitstream
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
  models:
@@ -15,40 +15,35 @@ models:
15
 
16
  # CodecSplat: Ultra-Compact Latent Coding for Feed-Forward 3D Gaussian Splatting
17
 
18
- Demo of [**CodecSplat**](https://huggingface.co/papers/2605.25563) (Yu et al.). CodecSplat
19
- entropy-codes the 2D features that a feed-forward 3DGS model (DepthSplat-style) uses for depth
20
- and Gaussian prediction. A scene becomes a bitstream of a few tens of KB, which is then decoded
21
- into renderable 3D Gaussians.
22
-
23
- How the Space works:
24
-
25
- 1. **COLMAP** (`pycolmap`, CPU) estimates intrinsics and camera poses for the uploaded photos.
26
- The reconstruction is normalised and undistorted to PINHOLE.
27
- 2. 8 context views are picked by farthest-point sampling over the cameras. They are resized and
28
- center-cropped to 256×448 with the authors' crop shim.
29
- 3. CodecSplat (`codecsplat-base-dl3dv-256x448-view8`, the authors' DL3DV evaluation preset)
30
- runs the **complete bitstream path** from `ModelWrapper.test_step`: encode context →
31
- compress to a real bitstream at the chosen λ → decompress → predict Gaussians, using bf16
32
- autocast like `trainer.precision=bf16-mixed`.
33
- 4. The authors' `splatting_cuda` decoder (Inria `diff-gaussian-rasterization`, prebuilt sm_120
34
- wheel) renders held-out views (PSNR is reported
35
- against the real photos) and a fly-through video. The decoded Gaussians are shown in an
36
- interactive `gr.Model3D` splat viewer, and the `.bin` bitstream is available to download.
37
-
38
- Notes:
39
- * The authors' evaluation scripts render with gsplat (antialiased). This Space uses the
40
- repo's other supported decoder, `model/decoder=splatting_cuda`, because a prebuilt Blackwell
41
- wheel exists for it. Renders can differ very slightly from the paper's numbers.
42
- * COLMAP runs in a separate process with 16 threads. On the ~192-CPU ZeroGPU hosts COLMAP's
43
- default thread count makes the mapper fail to register images.
44
- * The viewer gets a compact copy of the Gaussians: DC colour only, near-transparent splats
45
- pruned, re-centred so the scene is framed.
46
-
47
- Code: https://github.com/pengpeng-yu/CodecSplat (MIT) · Weights: https://huggingface.co/pengpeng-yu/CodecSplat (MIT)
48
-
49
- ## Example scenes
50
-
51
- The example frames are from the DL3DV demo scene that the DepthSplat authors redistribute in
52
- [`haofeixu/depthsplat`](https://huggingface.co/datasets/haofeixu/depthsplat) (MIT-labelled).
53
- The imagery itself comes from [DL3DV-10K](https://dl3dv-10k.github.io/DL3DV-10K/), credit to
54
- the DL3DV authors.
 
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
  app_file: app.py
9
+ short_description: Compress a 3D Gaussian scene into a few KB
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
  models:
 
15
 
16
  # CodecSplat: Ultra-Compact Latent Coding for Feed-Forward 3D Gaussian Splatting
17
 
18
+ Interactive demo of [CodecSplat](https://huggingface.co/papers/2605.25563)
19
+ ([code](https://github.com/pengpeng-yu/CodecSplat), MIT;
20
+ [weights](https://huggingface.co/pengpeng-yu/CodecSplat)).
21
+
22
+ Upload overlapping photos of a static scene. The Space:
23
+
24
+ 1. runs **COLMAP** (`pycolmap`, CPU) to estimate intrinsics and camera poses,
25
+ 2. picks 8 context views by farthest-point sampling and resizes them to 256x448
26
+ (the `codecsplat-base-dl3dv-256x448-view8` checkpoint),
27
+ 3. runs the authors' **complete bitstream path** (`EncoderCodecSplat.codec_forward`):
28
+ it encodes the latent features into a real entropy-coded scene bitstream at the
29
+ chosen rate point λ (16-1024), then decodes that bitstream back to 3D Gaussians,
30
+ 4. renders held-out views (with PSNR/SSIM against the real photos) and a
31
+ fly-through with `gsplat`, and shows the Gaussians in an interactive viewer.
32
+
33
+ You can download both the `.bin` bitstream and the decoded `.ply`.
34
+
35
+ ## Implementation notes
36
+
37
+ * Model construction uses the exact Hydra overrides of the authors'
38
+ `scripts/test_dl3dv_view8_256x448.sh` (bf16 autocast, gsplat antialiased rasterizer).
39
+ * COLMAP stage and scene loader are adapted from the
40
+ [ReSplat Space](https://huggingface.co/spaces/hugging-apps/resplat-recurrent-gs) / the ReSplat
41
+ reference `scripts/infer_colmap.py` (same DepthSplat camera conventions). The reconstruction is
42
+ normalised to a fixed extent so that the DL3DV near/far planes (0.5 / 200) are meaningful.
43
+ * `gsplat`'s CUDA kernels are JIT-compiled once at startup (no prebuilt sm_120 wheel).
44
+
45
+ ## Example assets
46
+
47
+ `examples/kitchen` and `examples/garden` are 14 frames each from the
48
+ [Mip-NeRF 360](https://jonbarron.info/mipnerf360/) dataset (Barron et al.), downscaled to 960px,
49
+ as redistributed by [`nvs-bench/mipnerf360`](https://huggingface.co/datasets/nvs-bench/mipnerf360) (MIT).
 
 
 
 
 
app.py CHANGED
@@ -1,27 +1,36 @@
1
  """CodecSplat: Ultra-Compact Latent Coding for Feed-Forward 3D Gaussian Splatting.
2
 
3
- Upload overlapping photos of a static scene. The Space runs COLMAP (pycolmap, CPU) to get
4
- camera poses, then CodecSplat (authors' DL3DV 8-view 256x448 checkpoint):
5
- context views -> 2D latent features -> learned entropy coder -> real bitstream
6
- -> decoder -> 3D Gaussians -> Gaussian-splatting rasterizer.
7
- You get an interactive splat viewer, a fly-through video, held-out novel views, the
8
- size of the compressed scene bitstream (and the bitstream itself).
 
 
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
 
 
13
  import os
14
  import sys
15
  import tempfile
16
  import time
 
17
  from pathlib import Path
18
 
19
  APP_DIR = Path(__file__).parent.resolve()
20
  sys.path.insert(0, str(APP_DIR))
21
 
22
- # pycolmap's bundled OpenBLAS segfaults ("Bad memory unallocation") when many matcher
23
- # threads each spin up a multi-threaded BLAS; keep BLAS single-threaded.
24
- os.environ["OPENBLAS_NUM_THREADS"] = "1"
25
 
26
  import spaces # noqa: E402 (must precede any CUDA-touching import)
27
  import torch # noqa: E402
@@ -29,224 +38,425 @@ import torch # noqa: E402
29
  torch.set_float32_matmul_precision("high")
30
 
31
 
32
- def _nvidia_fds(stage: str) -> None:
33
- """Diagnostics: open /dev/nvidia* handles in the (GPU-less) main process mean the
34
- CUDA driver was initialised there, which breaks ZeroGPU's forked workers."""
35
- try:
36
- fds = []
37
- for fd in os.listdir("/proc/self/fd"):
38
- try:
39
- t = os.readlink(f"/proc/self/fd/{fd}")
40
- except OSError:
41
- continue
42
- if "nvidia" in t:
43
- fds.append(t)
44
- print(f"[diag] {stage}: nvidia fds={sorted(set(fds))}", flush=True)
45
- except Exception as exc:
46
- print(f"[diag] {stage}: {exc}", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
 
 
 
 
 
48
 
49
- _nvidia_fds("after torch import")
 
50
 
 
 
 
 
 
 
 
51
 
52
  # ---------------------------------------------------------------------------
53
- # 2. Model
54
  # ---------------------------------------------------------------------------
 
55
  import imageio.v2 as imageio # noqa: E402
56
  import numpy as np # noqa: E402
57
- import gradio as gr # noqa: E402
58
  from huggingface_hub import hf_hub_download # noqa: E402
59
  from PIL import Image # noqa: E402
 
60
 
61
- import codecsplat_pipeline as cp # noqa: E402
62
-
63
- _nvidia_fds("after pipeline import")
64
-
65
  from colmap_sfm import IMAGE_EXT, run_sfm # noqa: E402
66
 
67
  MODEL_REPO = "pengpeng-yu/CodecSplat"
68
  CHECKPOINT_FILE = "codecsplat-base-dl3dv-256x448-view8-075062d0.pth"
69
  NUM_CONTEXT = 8
70
- NUM_TARGET = 8
71
- MAX_INPUT_IMAGES = 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  print("[setup] downloading checkpoint...", flush=True)
74
- ckpt_path = hf_hub_download(MODEL_REPO, CHECKPOINT_FILE)
75
  print("[setup] building CodecSplat...", flush=True)
76
- ENCODER = cp.build_encoder(ckpt_path).to("cuda")
77
- DECODER = cp.build_decoder().to("cuda")
 
78
  print("[setup] model ready", flush=True)
79
- _nvidia_fds("after model build")
80
 
 
 
 
 
 
 
 
81
 
82
- def _u8(t: torch.Tensor) -> np.ndarray:
 
 
 
 
83
  return (t.clamp(0, 1).permute(1, 2, 0).float().cpu().numpy() * 255).round().astype(np.uint8)
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  # ---------------------------------------------------------------------------
87
- # 3. GPU part: encode -> bitstream -> decode -> render
88
  # ---------------------------------------------------------------------------
89
- @spaces.GPU(duration=60)
90
- def codecsplat_gpu(image_paths: list[str], c2w: np.ndarray, K: np.ndarray, lmb: float = 1024.0):
91
- ctx_idx, tgt_idx = cp.select_views(c2w, NUM_CONTEXT, NUM_TARGET)
92
- batch = cp.to_device(cp.build_example(image_paths, c2w, K, ctx_idx, tgt_idx), "cuda")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  h, w = batch["context"]["image"].shape[-2:]
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  torch.cuda.synchronize()
96
- t0 = time.perf_counter()
97
- out = cp.encode_decode(ENCODER, batch, lmb)
98
- torch.cuda.synchronize()
99
- t_codec = time.perf_counter() - t0
100
- g = out["gaussians"]
101
-
102
- # held-out novel views (+ PSNR against the real photos)
103
- renders, score = None, float("nan")
104
- if len(tgt_idx) > 0:
105
- renders = cp.render(DECODER, g, batch["target"]["extrinsics"][0],
106
- batch["target"]["intrinsics"][0], (h, w))
107
- score = cp.psnr(batch["target"]["image"][0].float(), renders)
108
-
109
- # smooth fly-through through the context cameras
110
- traj_c2w, traj_K = cp.interpolate_trajectory(
111
- batch["context"]["extrinsics"][0], batch["context"]["intrinsics"][0]
112
- )
113
- video = cp.render(DECODER, g, traj_c2w.cuda(), traj_K.cuda(), (h, w))
114
- t_total = time.perf_counter() - t0
115
-
116
- gauss = {
117
- "means": g.means[0].float().cpu(),
118
- "scales": g.scales[0].float().cpu(),
119
- "rotations": g.rotations[0].float().cpu(),
120
- "harmonics": g.harmonics[0].float().cpu(),
121
- "opacities": g.opacities[0].float().cpu(),
122
- }
123
- return {
124
- "bitstream": out["bitstream"],
125
- "num_bytes": float(out["codec_num_bytes"]),
126
- "camera_bytes": float(out["codec_camera_num_bytes"]),
127
- "feature_bytes": float(out["codec_feature_num_bytes"]),
128
- "gaussians": gauss,
129
- "context_c2w": batch["context"]["extrinsics"][0].cpu(),
130
- "context_images": [_u8(x) for x in batch["context"]["image"][0]],
131
- "target_images": [_u8(x) for x in batch["target"]["image"][0]],
132
- "renders": [] if renders is None else [_u8(x) for x in renders],
133
- "video": [_u8(x) for x in video],
134
- "psnr": score,
135
- "t_codec": t_codec,
136
- "t_total": t_total,
137
- "hw": (int(h), int(w)),
138
- "ctx_idx": ctx_idx.tolist(),
139
- "tgt_idx": tgt_idx.tolist(),
140
  }
 
 
141
 
142
 
143
- # ---------------------------------------------------------------------------
144
- # 4. Full pipeline (CPU COLMAP -> GPU CodecSplat -> CPU export)
145
- # ---------------------------------------------------------------------------
146
- def _scene_arrays(frames: list[dict]):
147
- if len(frames) < 2:
148
- raise gr.Error("COLMAP registered fewer than 2 usable images.")
149
- paths, c2ws, Ks = [], [], []
150
- for f in frames:
151
- w2c = np.eye(4, dtype=np.float64)
152
- w2c[:3, :4] = np.asarray(f["w2c"], dtype=np.float64)
153
- paths.append(f["path"])
154
- c2ws.append(np.linalg.inv(w2c).astype(np.float32))
155
- Ks.append(np.asarray(f["K"], dtype=np.float32))
156
- return paths, np.stack(c2ws), np.stack(Ks)
157
-
158
-
159
- def reconstruct(images, lmb: float = 1024.0, matcher: str = "exhaustive",
160
- progress=gr.Progress(track_tqdm=False)):
161
- """Reconstruct a compressed 3D Gaussian scene from multi-view photos with CodecSplat.
162
 
163
  Args:
164
- images: 3-60 overlapping photos of one static scene (camera poses are estimated with COLMAP).
165
- lmb: rate-distortion trade-off lambda (16 = smallest bitstream, 1024 = best quality).
166
- matcher: COLMAP matching mode, "exhaustive" (unordered photos) or "sequential" (video frames).
167
 
168
  Returns:
169
- Interactive splat (.ply), fly-through video, held-out novel views (render vs. photo),
170
- selected input views, the scene bitstream (.bin) and a summary.
171
  """
172
  if not images:
173
- raise gr.Error("Please upload at least 3 overlapping photos of the same scene.")
174
  if isinstance(images, (str, Path)):
175
  images = [images]
176
  paths = [str(im if isinstance(im, (str, Path)) else getattr(im, "name", im)) for im in images]
177
  paths = sorted([p for p in paths if p.lower().endswith(IMAGE_EXT)], key=os.path.basename)
178
  if len(paths) < 3:
179
  raise gr.Error(f"Got {len(paths)} usable image(s); COLMAP needs at least 3 overlapping photos.")
180
- note = ""
181
  if len(paths) > MAX_INPUT_IMAGES:
182
  paths = [paths[i] for i in np.linspace(0, len(paths) - 1, MAX_INPUT_IMAGES).astype(int)]
183
- note = f" (sub-sampled to {MAX_INPUT_IMAGES} images)"
184
 
185
  t_start = time.time()
186
  workdir = Path(tempfile.mkdtemp(prefix="codecsplat_"))
187
- progress(0.05, desc=f"COLMAP on {len(paths)} images")
 
188
  try:
189
- sfm = run_sfm(paths, workdir, max_image_size=1024, matcher=matcher,
190
  log=lambda m: print(m, flush=True))
191
- except (ValueError, RuntimeError) as exc:
192
  raise gr.Error(str(exc)) from exc
193
- t_sfm = time.time() - t_start
194
- img_paths, c2w, K = _scene_arrays(sfm["frames"])
195
- _nvidia_fds("before GPU call")
196
-
197
- progress(0.55, desc="CodecSplat: encode -> bitstream -> decode -> render")
198
- r = codecsplat_gpu(img_paths, c2w, K, float(lmb))
199
-
200
- progress(0.85, desc="Exporting Gaussians")
201
- out = workdir / "out"
202
- out.mkdir(exist_ok=True)
203
- bin_path = out / f"codecsplat_scene_lmb{int(round(lmb))}.bin"
204
- bin_path.write_bytes(r["bitstream"])
205
- viewer_ply = cp.export_gaussians(r["gaussians"], r["context_c2w"], out)
206
-
207
- video_path = str(out / "flythrough.mp4")
208
- imageio.mimwrite(video_path, r["video"], fps=24, quality=8, macro_block_size=16)
209
-
210
- gallery_ctx = [(im, f"input view {i}") for im, i in zip(r["context_images"], r["ctx_idx"])]
211
- gallery_nv = []
212
- for rend, gt, i in zip(r["renders"], r["target_images"], r["tgt_idx"]):
213
- gallery_nv.append((np.concatenate([rend, gt], axis=1), f"held-out view {i}: render | photo"))
214
-
215
- n_g = int(r["gaussians"]["means"].shape[0])
216
- raw_bytes = n_g * (3 + 3 + 4 + 1 + r["gaussians"]["harmonics"][0].numel()) * 4
217
- kb = r["num_bytes"] / 1024
218
  summary = (
219
- f"**Compressed scene: {kb:.1f} KB** (features {r['feature_bytes']/1024:.1f} KB + cameras "
220
- f"{r['camera_bytes']/1024:.2f} KB) at λ = {lmb:g} — decodes to **{n_g:,} Gaussians** "
221
- f"(≈{raw_bytes/2**20:.0f} MB as raw float32, **{raw_bytes/max(r['num_bytes'],1):,.0f}× smaller**).\n\n"
222
- f"Held-out novel views: **PSNR {r['psnr']:.2f} dB** · {len(r['ctx_idx'])} input views at "
223
- f"{r['hw'][0]}×{r['hw'][1]} · COLMAP registered {sfm['num_registered']}/{sfm['num_input']} "
224
- f"images{note} in {t_sfm:.1f}s · CodecSplat encode+decode {r['t_codec']:.2f}s · "
225
- f"total {time.time() - t_start:.1f}s"
226
  )
227
- return viewer_ply, video_path, gallery_nv, gallery_ctx, str(bin_path), summary
228
 
229
 
230
  # ---------------------------------------------------------------------------
231
- # 5. UI
232
  # ---------------------------------------------------------------------------
233
- EX_DIR = APP_DIR / "examples"
234
- EXAMPLES = [sorted(str(p) for p in (EX_DIR / s).glob("*.jpg")) for s in ("scene_a", "scene_b")]
 
 
 
 
235
 
236
- DESCRIPTION = """
237
- # CodecSplat: Ultra-Compact Latent Coding for Feed-Forward 3D Gaussian Splatting
 
 
238
 
239
- CodecSplat turns a few photos into 3D Gaussians **through a real compressed bitstream**: the 2D
240
- features used for depth and Gaussian prediction are entropy-coded (tens of KB per scene), then
241
- decoded into a renderable 3D Gaussian scene. Upload overlapping photos of a static scene —
242
- camera poses are estimated with COLMAP right here — and pick a rate (λ).
243
 
244
- [Paper](https://huggingface.co/papers/2605.25563) · [Code](https://github.com/pengpeng-yu/CodecSplat) · [Weights](https://huggingface.co/pengpeng-yu/CodecSplat)
245
- """
 
 
246
 
247
- CSS = """
248
- #col-container { max-width: 1280px; margin: 0 auto; }
249
- .dark .gradio-container { color: var(--body-text-color); }
250
  """
251
 
252
  with gr.Blocks(title="CodecSplat") as demo:
@@ -255,48 +465,47 @@ with gr.Blocks(title="CodecSplat") as demo:
255
  with gr.Row():
256
  with gr.Column(scale=1):
257
  image_files = gr.File(
258
- label="Photos of one static scene (8-30 overlapping shots work best)",
259
  file_count="multiple", file_types=["image"], type="filepath",
260
  )
261
- lmb = gr.Slider(16, 1024, value=1024, step=1, label="Rate λ",
262
- info="16 = smallest bitstream, 1024 = highest quality")
263
  run_btn = gr.Button("Compress & reconstruct", variant="primary")
264
  with gr.Accordion("Advanced options", open=False):
265
  matcher = gr.Radio(["exhaustive", "sequential"], value="exhaustive",
266
  label="COLMAP matching",
267
- info="exhaustive = unordered photos; sequential = video frames")
268
  gr.Markdown(
269
- "8 input views are picked by farthest-point sampling over the COLMAP cameras and "
270
- "resized/cropped to 256×448 (the checkpoint's training resolution); up to 8 other "
271
- "photos are held out to measure novel-view quality.\n\n"
272
  "**Viewer**: drag to orbit, right-drag to pan, scroll to zoom."
273
  )
274
  with gr.Column(scale=2):
275
- summary = gr.Markdown()
276
- splat = gr.Model3D(label="Decoded 3D Gaussians (interactive)", height=440,
277
- clear_color=[0.0, 0.0, 0.0, 1.0])
278
- video = gr.Video(label="Fly-through rendered from the decoded Gaussians", autoplay=True, loop=True)
279
- bin_out = gr.File(label="Compressed scene bitstream (.bin)")
280
- novel = gr.Gallery(label="Held-out novel views: render | real photo", columns=2, height=320,
281
- object_fit="contain")
282
- inputs_gal = gr.Gallery(label="Selected input views (after crop)", columns=4, height=200,
283
- object_fit="contain")
284
-
285
- outputs = [splat, video, novel, inputs_gal, bin_out, summary]
286
  run_btn.click(reconstruct, inputs=[image_files, lmb, matcher], outputs=outputs,
287
  api_name="reconstruct")
288
 
289
- gr.Examples(
290
- examples=[[EXAMPLES[0], 1024], [EXAMPLES[1], 64]],
291
- inputs=[image_files, lmb],
292
- outputs=outputs,
293
- fn=reconstruct,
294
- cache_examples=True,
295
- cache_mode="lazy",
296
- label="Frames from the DL3DV demo scene (via the DepthSplat authors)",
297
- )
298
-
299
- _nvidia_fds("after UI build")
300
 
301
  if __name__ == "__main__":
302
  demo.queue(max_size=12).launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True, show_error=True)
 
1
  """CodecSplat: Ultra-Compact Latent Coding for Feed-Forward 3D Gaussian Splatting.
2
 
3
+ Upload overlapping photos of a static scene. The Space recovers the cameras with
4
+ COLMAP (pycolmap, CPU), then CodecSplat (DL3DV 8-view 256x448 checkpoint):
5
+
6
+ 1. encodes the 8 context views into an intermediate 2D latent,
7
+ 2. entropy-codes that latent (+ cameras) into a real scene bitstream at the
8
+ chosen rate point lambda,
9
+ 3. decodes the bitstream back into 3D Gaussians,
10
+ 4. renders held-out views / a fly-through with gsplat.
11
+
12
+ The inference path follows the authors' evaluation (`scripts/test_dl3dv_view8_256x448.sh`
13
+ -> `ModelWrapper.test_step` -> `EncoderCodecSplat.codec_forward`), with the exact same
14
+ encoder/decoder overrides.
15
  """
16
 
17
  from __future__ import annotations
18
 
19
+ import ctypes
20
+ import glob
21
  import os
22
  import sys
23
  import tempfile
24
  import time
25
+ import types
26
  from pathlib import Path
27
 
28
  APP_DIR = Path(__file__).parent.resolve()
29
  sys.path.insert(0, str(APP_DIR))
30
 
31
+ os.environ.setdefault("TORCH_CUDA_ARCH_LIST", "12.0")
32
+ os.environ.setdefault("MAX_JOBS", str(min(32, (os.cpu_count() or 8))))
33
+ os.environ.setdefault("WANDB_MODE", "disabled")
34
 
35
  import spaces # noqa: E402 (must precede any CUDA-touching import)
36
  import torch # noqa: E402
 
38
  torch.set_float32_matmul_precision("high")
39
 
40
 
41
+ # ---------------------------------------------------------------------------
42
+ # 1. gsplat CUDA backend (JIT-compiled once at startup against the ZeroGPU
43
+ # image's CUDA toolkit -- gsplat has no prebuilt sm_120 wheel).
44
+ # ---------------------------------------------------------------------------
45
+ def _pick_cuda_home() -> str | None:
46
+ torch_cuda = (torch.version.cuda or "").split(".")[0]
47
+ candidates: list[str] = []
48
+ for pattern in ("/cuda-image/usr/local/cuda-*", "/usr/local/cuda-*"):
49
+ candidates += sorted(glob.glob(pattern), reverse=True)
50
+ candidates += ["/cuda-image/usr/local/cuda", "/usr/local/cuda"]
51
+ usable = [c for c in candidates if os.path.isfile(os.path.join(c, "bin", "nvcc"))]
52
+ if not usable:
53
+ return None
54
+ for c in usable:
55
+ if torch_cuda and f"cuda-{torch_cuda}" in c:
56
+ return c
57
+ return usable[0]
58
+
59
+
60
+ CUDA_HOME = _pick_cuda_home()
61
+ print(f"[setup] torch {torch.__version__} (cuda {torch.version.cuda}) | CUDA_HOME={CUDA_HOME}", flush=True)
62
+ if CUDA_HOME:
63
+ os.environ["CUDA_HOME"] = CUDA_HOME
64
+ os.environ["CUDA_PATH"] = CUDA_HOME
65
+ os.environ["PATH"] = os.path.join(CUDA_HOME, "bin") + os.pathsep + os.environ.get("PATH", "")
66
+ _libdir = os.path.join(CUDA_HOME, "lib64")
67
+ os.environ["LD_LIBRARY_PATH"] = _libdir + os.pathsep + os.environ.get("LD_LIBRARY_PATH", "")
68
+ for _so in sorted(glob.glob(os.path.join(_libdir, "libcudart.so*")), reverse=True):
69
+ try:
70
+ ctypes.CDLL(_so, mode=ctypes.RTLD_GLOBAL)
71
+ break
72
+ except OSError:
73
+ continue
74
+
75
+ import torch.utils.cpp_extension as _cppext # noqa: E402
76
+
77
+ _cppext._check_cuda_version = lambda *a, **k: None
78
+
79
+ print("[setup] compiling / loading gsplat CUDA kernels...", flush=True)
80
+ _t0 = time.time()
81
+ from gsplat.cuda._backend import _C as _GSPLAT_C # noqa: E402
82
+
83
+ if _GSPLAT_C is None:
84
+ raise RuntimeError(f"gsplat CUDA backend unavailable (CUDA_HOME={CUDA_HOME}).")
85
+ print(f"[setup] gsplat backend ready in {time.time() - _t0:.1f}s", flush=True)
86
 
87
+ # ---------------------------------------------------------------------------
88
+ # 2. Compatibility shims for the vendored reference code
89
+ # ---------------------------------------------------------------------------
90
+ import matplotlib.cm # noqa: E402
91
+ import matplotlib.pyplot as _plt # noqa: E402
92
 
93
+ if not hasattr(matplotlib.cm, "get_cmap"):
94
+ matplotlib.cm.get_cmap = _plt.get_cmap
95
 
96
+ if "skvideo" not in sys.modules: # training-only import in some helpers
97
+ _sk = types.ModuleType("skvideo")
98
+ _sk.__path__ = []
99
+ _skio = types.ModuleType("skvideo.io")
100
+ _sk.io = _skio
101
+ sys.modules["skvideo"] = _sk
102
+ sys.modules["skvideo.io"] = _skio
103
 
104
  # ---------------------------------------------------------------------------
105
+ # 3. Model
106
  # ---------------------------------------------------------------------------
107
+ import gradio as gr # noqa: E402
108
  import imageio.v2 as imageio # noqa: E402
109
  import numpy as np # noqa: E402
 
110
  from huggingface_hub import hf_hub_download # noqa: E402
111
  from PIL import Image # noqa: E402
112
+ from scipy.spatial.transform import Rotation, Slerp # noqa: E402
113
 
114
+ from colmap_io import load_colmap_scene, select_context_views, select_target_views # noqa: E402
 
 
 
115
  from colmap_sfm import IMAGE_EXT, run_sfm # noqa: E402
116
 
117
  MODEL_REPO = "pengpeng-yu/CodecSplat"
118
  CHECKPOINT_FILE = "codecsplat-base-dl3dv-256x448-view8-075062d0.pth"
119
  NUM_CONTEXT = 8
120
+ IMAGE_SHAPE = (256, 448)
121
+ NEAR, FAR = 0.5, 200.0 # config/experiment/dl3dv.yaml
122
+
123
+ # Exactly the overrides of scripts/test_dl3dv_view8_256x448.sh
124
+ OVERRIDES = [
125
+ "+experiment=dl3dv",
126
+ "mode=test",
127
+ f"dataset.image_shape=[{IMAGE_SHAPE[0]},{IMAGE_SHAPE[1]}]",
128
+ "model.encoder.num_scales=2",
129
+ "model.encoder.upsample_factor=4",
130
+ "model.encoder.lowest_feature_resolution=8",
131
+ "model.encoder.monodepth_vit_type=vitb",
132
+ "model.encoder.enable_feature_codec=true",
133
+ "model.encoder.enable_depth_guided_mv_refiner=true",
134
+ "model.encoder.enable_post_vae_mv_refiner=true",
135
+ "model.encoder.supervise_intermediate_depth=false",
136
+ "model/decoder=splatting_gsplat",
137
+ "model.decoder.use_covariances=true",
138
+ "model.decoder.rasterize_mode=antialiased",
139
+ "model.decoder.eps2d=0.1",
140
+ "model.decoder.packed=true",
141
+ "model.decoder.scale_invariant=false",
142
+ "trainer.precision=bf16-mixed",
143
+ "wandb.mode=disabled",
144
+ "output_dir=/tmp/codecsplat_out",
145
+ ]
146
+
147
+
148
+ def build_model(checkpoint: str):
149
+ from hydra import compose, initialize_config_dir
150
+ from hydra.core.global_hydra import GlobalHydra
151
+
152
+ GlobalHydra.instance().clear()
153
+ with initialize_config_dir(config_dir=str(APP_DIR / "config"), version_base=None):
154
+ cfg_dict = compose(config_name="main", overrides=OVERRIDES)
155
+
156
+ from src.config import load_typed_root_config
157
+ from src.global_cfg import set_cfg
158
+ from src.model.decoder import get_decoder
159
+ from src.model.encoder import get_encoder
160
+
161
+ set_cfg(cfg_dict)
162
+ cfg = load_typed_root_config(cfg_dict)
163
+ encoder = get_encoder(cfg.model.encoder)
164
+ decoder = get_decoder(cfg.model.decoder, cfg.dataset)
165
+
166
+ state = torch.load(checkpoint, map_location="cpu", weights_only=False)
167
+ if "state_dict" in state:
168
+ state = state["state_dict"]
169
+ enc_sd = {k[len("encoder."):]: v for k, v in state.items() if k.startswith("encoder.")}
170
+ dec_sd = {k[len("decoder."):]: v for k, v in state.items() if k.startswith("decoder.")}
171
+ missing, unexpected = encoder.load_state_dict(enc_sd, strict=False)
172
+ print(f"[setup] encoder: {len(enc_sd)} tensors, missing={len(missing)} unexpected={len(unexpected)}", flush=True)
173
+ if missing:
174
+ print(f"[setup] missing (first 10): {missing[:10]}", flush=True)
175
+ if unexpected:
176
+ print(f"[setup] unexpected (first 10): {unexpected[:10]}", flush=True)
177
+ if dec_sd:
178
+ decoder.load_state_dict(dec_sd, strict=False)
179
+ encoder.eval()
180
+ decoder.eval()
181
+ return encoder, decoder, encoder.get_data_shim()
182
+
183
 
184
  print("[setup] downloading checkpoint...", flush=True)
185
+ CHECKPOINT_PATH = hf_hub_download(MODEL_REPO, CHECKPOINT_FILE)
186
  print("[setup] building CodecSplat...", flush=True)
187
+ ENCODER, DECODER, DATA_SHIM = build_model(CHECKPOINT_PATH)
188
+ ENCODER.to("cuda")
189
+ DECODER.to("cuda")
190
  print("[setup] model ready", flush=True)
 
191
 
192
+ from src.dataset.shims.crop_shim import rescale_and_crop # noqa: E402
193
+ from src.evaluation.metrics import compute_psnr, compute_ssim # noqa: E402
194
+ from src.model.ply_export import export_ply # noqa: E402
195
+
196
+ MAX_INPUT_IMAGES = 40
197
+ VIEWER_MAX_GAUSSIANS = 600_000
198
+ VIEWER_MIN_OPACITY = 0.02
199
 
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # 4. Helpers
203
+ # ---------------------------------------------------------------------------
204
+ def _to_uint8(t) -> np.ndarray:
205
  return (t.clamp(0, 1).permute(1, 2, 0).float().cpu().numpy() * 255).round().astype(np.uint8)
206
 
207
 
208
+ def _load_views(paths, intrinsics, shape):
209
+ """Load images + normalized intrinsics and rescale/center-crop them to `shape`
210
+ with the same crop shim the DL3DV loader uses."""
211
+ imgs = []
212
+ for p in paths:
213
+ im = np.asarray(Image.open(p).convert("RGB"), dtype=np.float32) / 255.0
214
+ imgs.append(torch.from_numpy(im).permute(2, 0, 1))
215
+ h0 = min(i.shape[1] for i in imgs)
216
+ w0 = min(i.shape[2] for i in imgs)
217
+ imgs = torch.stack([i[:, :h0, :w0] for i in imgs])
218
+ K = torch.tensor(np.asarray(intrinsics), dtype=torch.float32)
219
+ return rescale_and_crop(imgs, K, shape, depths=None)
220
+
221
+
222
+ def _interpolate_path(c2w: torch.Tensor, frames_per_segment: int = 12) -> torch.Tensor:
223
+ """Smooth fly-through visiting the context cameras in order (slerp + lerp)."""
224
+ c2w = c2w.cpu().numpy()
225
+ out = []
226
+ for a, b in zip(c2w[:-1], c2w[1:]):
227
+ slerp = Slerp([0, 1], Rotation.from_matrix(np.stack([a[:3, :3], b[:3, :3]])))
228
+ for t in np.linspace(0, 1, frames_per_segment, endpoint=False):
229
+ m = np.eye(4, dtype=np.float32)
230
+ m[:3, :3] = slerp([t]).as_matrix()[0]
231
+ m[:3, 3] = (1 - t) * a[:3, 3] + t * b[:3, 3]
232
+ out.append(m)
233
+ out.append(c2w[-1])
234
+ return torch.from_numpy(np.stack(out)).float()
235
+
236
+
237
+ def export_viewer_ply(gaussians, extrinsics, path: Path) -> str:
238
+ """Compact, well-framed copy of the Gaussians for the in-browser splat viewer
239
+ (DC colour only, near-transparent splats pruned, re-centred and re-scaled)."""
240
+ means = gaussians.means[0]
241
+ scales = gaussians.scales[0]
242
+ rotations = gaussians.rotations[0]
243
+ harmonics = gaussians.harmonics[0]
244
+ opacities = gaussians.opacities[0]
245
+
246
+ keep = opacities > VIEWER_MIN_OPACITY
247
+ if int(keep.sum()) < 1000:
248
+ keep = torch.ones_like(opacities, dtype=torch.bool)
249
+ idx = torch.nonzero(keep, as_tuple=False).squeeze(1)
250
+ if idx.numel() > VIEWER_MAX_GAUSSIANS:
251
+ idx = idx[torch.randperm(idx.numel(), device=idx.device)[:VIEWER_MAX_GAUSSIANS]]
252
+ means, scales = means[idx].float(), scales[idx].float()
253
+ rotations, harmonics, opacities = rotations[idx], harmonics[idx], opacities[idx]
254
+
255
+ rot = extrinsics[:3, :3].float().inverse().to(means.device)
256
+ world = (rot @ means.T).T
257
+ center = world.median(dim=0).values
258
+ radius = torch.quantile((world - center).norm(dim=-1), 0.7)
259
+ s = float(3.0 / max(float(radius), 1e-6))
260
+ means = means * s - rot.T @ (center * s)
261
+ scales = scales * s
262
+ export_ply(extrinsics, means, scales, rotations, harmonics[..., :1], opacities, path,
263
+ align_to_view=True)
264
+ return str(path)
265
+
266
+
267
  # ---------------------------------------------------------------------------
268
+ # 5. GPU stage: encode -> bitstream -> decode -> render
269
  # ---------------------------------------------------------------------------
270
+ @spaces.GPU(duration=40)
271
+ def codecsplat_run(scene_dir: str, lmb: float = 1024.0):
272
+ """Run CodecSplat on a COLMAP scene directory (undistorted images + sparse model)."""
273
+ out = Path(scene_dir) / "out"
274
+ out.mkdir(parents=True, exist_ok=True)
275
+
276
+ scene = load_colmap_scene(scene_dir, "sparse", "images")
277
+ n = len(scene["image_paths"])
278
+ if n < 2:
279
+ raise gr.Error(f"Only {n} usable image(s) after COLMAP -- need at least 2.")
280
+
281
+ w0, h0 = Image.open(scene["image_paths"][0]).size
282
+ shape = IMAGE_SHAPE if w0 >= h0 else IMAGE_SHAPE[::-1]
283
+
284
+ num_context = min(NUM_CONTEXT, n)
285
+ ctx_idx = select_context_views(scene["c2w"], num_context, "fps")
286
+ tgt_idx = select_target_views(n, ctx_idx, "remaining", 8)
287
+ if len(tgt_idx) == 0:
288
+ tgt_idx = ctx_idx.copy()
289
+
290
+ ctx_img, ctx_K = _load_views([scene["image_paths"][i] for i in ctx_idx], scene["intrinsics"][ctx_idx], shape)
291
+ tgt_img, tgt_K = _load_views([scene["image_paths"][i] for i in tgt_idx], scene["intrinsics"][tgt_idx], shape)
292
+
293
+ c2w = torch.tensor(scene["c2w"], dtype=torch.float32)
294
+ pivot = torch.inverse(c2w[ctx_idx[len(ctx_idx) // 2]])
295
+ c2w = pivot[None] @ c2w # express all cameras in the middle context view's frame
296
+ ctx_c2w, tgt_c2w = c2w[ctx_idx], c2w[tgt_idx]
297
+
298
+ def views(img, K, E):
299
+ v = len(E)
300
+ return {
301
+ "image": img[None].cuda(),
302
+ "intrinsics": K[None].cuda(),
303
+ "extrinsics": E[None].cuda(),
304
+ "near": torch.full((1, v), NEAR, device="cuda"),
305
+ "far": torch.full((1, v), FAR, device="cuda"),
306
+ "index": torch.arange(v, device="cuda")[None],
307
+ }
308
+
309
+ batch = DATA_SHIM({
310
+ "context": views(ctx_img, ctx_K, ctx_c2w),
311
+ "target": views(tgt_img, tgt_K, tgt_c2w),
312
+ "scene": ["user_scene"],
313
+ })
314
  h, w = batch["context"]["image"].shape[-2:]
315
 
316
+ ENCODER.feature_codec.cfg.lmb = float(lmb)
317
+ t0 = time.time()
318
+ with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
319
+ enc = ENCODER(batch["context"], 0, return_bitstream=True)
320
+ gaussians = enc["gaussians"]
321
+
322
+ def render(E, K, chunk=8):
323
+ frames = []
324
+ for i in range(0, E.shape[1], chunk):
325
+ o = DECODER.forward(
326
+ gaussians, E[:, i:i + chunk], K[:, i:i + chunk],
327
+ torch.full_like(E[:, i:i + chunk, 0, 0], NEAR),
328
+ torch.full_like(E[:, i:i + chunk, 0, 0], FAR),
329
+ (h, w), depth_mode=None,
330
+ )
331
+ frames.append(o.color[0].float())
332
+ return torch.cat(frames)
333
+
334
+ rendered = render(batch["target"]["extrinsics"], batch["target"]["intrinsics"])
335
+ path_E = _interpolate_path(batch["context"]["extrinsics"][0])[None].cuda()
336
+ path_K = batch["context"]["intrinsics"][:, :1].expand(-1, path_E.shape[1], -1, -1)
337
+ video = render(path_E, path_K)
338
  torch.cuda.synchronize()
339
+ t_run = time.time() - t0
340
+
341
+ gt = batch["target"]["image"][0].float()
342
+ psnr = float(compute_psnr(gt, rendered.clamp(0, 1)).mean())
343
+ ssim = float(compute_ssim(gt, rendered.clamp(0, 1)).mean())
344
+
345
+ bitstream = enc["bitstream"]
346
+ bin_path = out / f"codecsplat_scene_lmb{int(lmb)}.bin"
347
+ bin_path.write_bytes(bitstream)
348
+
349
+ video_path = out / "flythrough.mp4"
350
+ imageio.mimwrite(video_path, [_to_uint8(f) for f in video], fps=24, quality=8, macro_block_size=1)
351
+
352
+ gallery = []
353
+ for i, (r, g) in enumerate(zip(rendered, gt)):
354
+ p = out / f"heldout_{i:02d}.png"
355
+ Image.fromarray(np.concatenate([_to_uint8(r), _to_uint8(g)], axis=1)).save(p)
356
+ gallery.append((str(p), f"held-out view {int(tgt_idx[i])}: CodecSplat (left) | real photo (right)"))
357
+
358
+ ref_E = batch["context"]["extrinsics"][0, len(ctx_idx) // 2]
359
+ ply_path = out / "codecsplat_gaussians.ply"
360
+ export_ply(ref_E, gaussians.means[0], gaussians.scales[0], gaussians.rotations[0],
361
+ gaussians.harmonics[0], gaussians.opacities[0], ply_path, align_to_view=True)
362
+ viewer_ply = export_viewer_ply(gaussians, ref_E, out / "codecsplat_viewer.ply")
363
+
364
+ stats = {
365
+ "num_bytes": len(bitstream),
366
+ "camera_bytes": float(enc["codec_camera_num_bytes"]),
367
+ "feature_bytes": float(enc["codec_feature_num_bytes"]),
368
+ "num_gaussians": int(gaussians.means.shape[1]),
369
+ "ply_bytes": os.path.getsize(ply_path),
370
+ "num_context": len(ctx_idx),
371
+ "num_target": len(tgt_idx),
372
+ "resolution": f"{h}x{w}",
373
+ "psnr": psnr,
374
+ "ssim": ssim,
375
+ "t_run": t_run,
376
+ "t_encode": float(enc["codec_encode_context_seconds"] + enc["codec_compress_seconds"]),
377
+ "t_decode": float(enc["codec_decompress_seconds"]),
 
 
 
 
 
378
  }
379
+ print(f"[run] lmb={lmb} bytes={len(bitstream)} psnr={psnr:.2f} t={t_run:.1f}s", flush=True)
380
+ return viewer_ply, str(video_path), gallery, str(bin_path), str(ply_path), stats
381
 
382
 
383
+ def reconstruct(
384
+ images,
385
+ lmb: float = 1024.0,
386
+ matcher: str = "exhaustive",
387
+ progress=gr.Progress(track_tqdm=False),
388
+ ):
389
+ """COLMAP + CodecSplat: compress a scene into a tiny bitstream and decode it to 3D Gaussians.
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
  Args:
392
+ images: Overlapping photos of one static scene (8-30 works well).
393
+ lmb: Rate point lambda in [16, 1024]. Higher = more bytes, better quality.
394
+ matcher: COLMAP matching -- "exhaustive" (unordered photos) or "sequential" (video frames).
395
 
396
  Returns:
397
+ Interactive 3D Gaussian splat, a rendered fly-through video, held-out view comparisons,
398
+ the compressed scene bitstream, the decoded Gaussians as .ply, and a summary.
399
  """
400
  if not images:
401
+ raise gr.Error("Please upload at least 3 overlapping images of the same scene.")
402
  if isinstance(images, (str, Path)):
403
  images = [images]
404
  paths = [str(im if isinstance(im, (str, Path)) else getattr(im, "name", im)) for im in images]
405
  paths = sorted([p for p in paths if p.lower().endswith(IMAGE_EXT)], key=os.path.basename)
406
  if len(paths) < 3:
407
  raise gr.Error(f"Got {len(paths)} usable image(s); COLMAP needs at least 3 overlapping photos.")
 
408
  if len(paths) > MAX_INPUT_IMAGES:
409
  paths = [paths[i] for i in np.linspace(0, len(paths) - 1, MAX_INPUT_IMAGES).astype(int)]
 
410
 
411
  t_start = time.time()
412
  workdir = Path(tempfile.mkdtemp(prefix="codecsplat_"))
413
+ progress(0.05, desc=f"COLMAP: estimating cameras for {len(paths)} images")
414
+ t_sfm = time.time()
415
  try:
416
+ sfm = run_sfm(paths, workdir, max_image_size=960, matcher=matcher,
417
  log=lambda m: print(m, flush=True))
418
+ except ValueError as exc:
419
  raise gr.Error(str(exc)) from exc
420
+ t_sfm = time.time() - t_sfm
421
+
422
+ progress(0.6, desc="CodecSplat: encode -> bitstream -> decode -> render")
423
+ viewer_ply, video, gallery, bin_path, ply_path, s = codecsplat_run(sfm["scene_dir"], float(lmb))
424
+
425
+ ratio = s["ply_bytes"] / max(s["num_bytes"], 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  summary = (
427
+ f"### Scene bitstream: **{s['num_bytes'] / 1024:.1f} KB** at λ = {lmb:g}\n"
428
+ f"(features {s['feature_bytes'] / 1024:.1f} KB + cameras {s['camera_bytes'] / 1024:.2f} KB) → decodes to "
429
+ f"**{s['num_gaussians']:,} Gaussians** (uncompressed .ply: {s['ply_bytes'] / 2**20:.1f} MB, **{ratio:,.0f}× smaller**).\n\n"
430
+ f"Held-out views ({s['num_target']}): **PSNR {s['psnr']:.2f} dB** · SSIM {s['ssim']:.3f} at {s['resolution']} \n"
431
+ f"COLMAP registered {sfm['num_registered']}/{sfm['num_input']} images in {t_sfm:.1f}s · "
432
+ f"encode {s['t_encode']:.2f}s · decode {s['t_decode']:.2f}s · {time.time() - t_start:.1f}s total."
 
433
  )
434
+ return viewer_ply, video, gallery, bin_path, ply_path, summary
435
 
436
 
437
  # ---------------------------------------------------------------------------
438
+ # 6. UI
439
  # ---------------------------------------------------------------------------
440
+ EXAMPLES_DIR = APP_DIR / "examples"
441
+ EXAMPLES = [
442
+ [sorted(str(p) for p in (EXAMPLES_DIR / s).glob("*.jpg"))]
443
+ for s in ("kitchen", "garden")
444
+ if (EXAMPLES_DIR / s).is_dir()
445
+ ]
446
 
447
+ CSS = """
448
+ #col-container { max-width: 1200px; margin: 0 auto; }
449
+ .dark .gradio-container { color: var(--body-text-color); }
450
+ """
451
 
452
+ DESCRIPTION = """# CodecSplat: Ultra-Compact Latent Coding for Feed-Forward 3D Gaussian Splatting
 
 
 
453
 
454
+ CodecSplat doesn't compress the final 3D Gaussians. It entropy-codes the **2D latent features** a feed-forward
455
+ 3DGS model uses to predict them, so a whole scene fits in a **few kilobytes** and decodes back to Gaussians
456
+ in one forward pass. Upload overlapping photos of a scene. COLMAP estimates the cameras, CodecSplat
457
+ compresses 8 views into a real bitstream, then decodes it into 3D Gaussians you can fly through.
458
 
459
+ [Paper](https://huggingface.co/papers/2605.25563) · [Code](https://github.com/pengpeng-yu/CodecSplat) · [Weights](https://huggingface.co/pengpeng-yu/CodecSplat)
 
 
460
  """
461
 
462
  with gr.Blocks(title="CodecSplat") as demo:
 
465
  with gr.Row():
466
  with gr.Column(scale=1):
467
  image_files = gr.File(
468
+ label="Photos of one static scene (8-30, overlapping)",
469
  file_count="multiple", file_types=["image"], type="filepath",
470
  )
471
+ lmb = gr.Slider(16, 1024, value=1024, step=1, label="Rate point λ",
472
+ info="Higher λ = more bytes and higher fidelity. The model covers 16-1024.")
473
  run_btn = gr.Button("Compress & reconstruct", variant="primary")
474
  with gr.Accordion("Advanced options", open=False):
475
  matcher = gr.Radio(["exhaustive", "sequential"], value="exhaustive",
476
  label="COLMAP matching",
477
+ info="exhaustive = unordered photos; sequential = video frames.")
478
  gr.Markdown(
479
+ "**Tips**: walk around the scene, keep lots of overlap between neighbouring shots, "
480
+ "and avoid moving objects. The 8 context views are picked by farthest-point sampling "
481
+ "over the COLMAP cameras. The remaining views are held out for evaluation. "
482
  "**Viewer**: drag to orbit, right-drag to pan, scroll to zoom."
483
  )
484
  with gr.Column(scale=2):
485
+ summary_out = gr.Markdown()
486
+ splat_out = gr.Model3D(label="Decoded 3D Gaussians (interactive)", height=440,
487
+ clear_color=[0.0, 0.0, 0.0, 1.0])
488
+ video_out = gr.Video(label="Fly-through through the context cameras", autoplay=True, loop=True)
489
+ gallery_out = gr.Gallery(label="Held-out views: CodecSplat | real photo", columns=2,
490
+ height=260, object_fit="contain")
491
+ with gr.Row():
492
+ bin_out = gr.File(label="Compressed scene bitstream (.bin)")
493
+ ply_out = gr.File(label="Decoded 3D Gaussians (.ply)")
494
+
495
+ outputs = [splat_out, video_out, gallery_out, bin_out, ply_out, summary_out]
496
  run_btn.click(reconstruct, inputs=[image_files, lmb, matcher], outputs=outputs,
497
  api_name="reconstruct")
498
 
499
+ if EXAMPLES:
500
+ gr.Examples(
501
+ examples=EXAMPLES,
502
+ inputs=[image_files],
503
+ outputs=outputs,
504
+ fn=reconstruct,
505
+ cache_examples=True,
506
+ cache_mode="lazy",
507
+ label="Mip-NeRF 360 scenes (kitchen, garden): 14 frames each",
508
+ )
 
509
 
510
  if __name__ == "__main__":
511
  demo.queue(max_size=12).launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True, show_error=True)
colmap_io.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """COLMAP scene loading + view selection.
2
+
3
+ Verbatim helpers from the ReSplat reference inference script
4
+ (https://github.com/cvg/resplat, scripts/infer_colmap.py, MIT), which shares
5
+ the DepthSplat camera conventions used by CodecSplat.
6
+ """
7
+
8
+ import collections
9
+ import os
10
+ import struct
11
+
12
+ import numpy as np
13
+ import torch
14
+
15
+ CameraModel = collections.namedtuple(
16
+ "CameraModel", ["model_id", "model_name", "num_params"]
17
+ )
18
+ ColmapCamera = collections.namedtuple(
19
+ "ColmapCamera", ["id", "model", "width", "height", "params"]
20
+ )
21
+ BaseImage = collections.namedtuple(
22
+ "ColmapImage",
23
+ ["id", "qvec", "tvec", "camera_id", "name", "xys", "point3D_ids"],
24
+ )
25
+
26
+ CAMERA_MODELS = {
27
+ CameraModel(model_id=0, model_name="SIMPLE_PINHOLE", num_params=3),
28
+ CameraModel(model_id=1, model_name="PINHOLE", num_params=4),
29
+ CameraModel(model_id=2, model_name="SIMPLE_RADIAL", num_params=4),
30
+ CameraModel(model_id=3, model_name="RADIAL", num_params=5),
31
+ CameraModel(model_id=4, model_name="OPENCV", num_params=8),
32
+ CameraModel(model_id=5, model_name="OPENCV_FISHEYE", num_params=8),
33
+ CameraModel(model_id=6, model_name="FULL_OPENCV", num_params=12),
34
+ CameraModel(model_id=7, model_name="FOV", num_params=5),
35
+ CameraModel(model_id=8, model_name="SIMPLE_RADIAL_FISHEYE", num_params=4),
36
+ CameraModel(model_id=9, model_name="RADIAL_FISHEYE", num_params=5),
37
+ CameraModel(model_id=10, model_name="THIN_PRISM_FISHEYE", num_params=12),
38
+ }
39
+ CAMERA_MODEL_IDS = dict(
40
+ [(camera_model.model_id, camera_model) for camera_model in CAMERA_MODELS]
41
+ )
42
+
43
+
44
+ class ColmapImage(BaseImage):
45
+ def qvec2rotmat(self):
46
+ return qvec2rotmat(self.qvec)
47
+
48
+
49
+ def qvec2rotmat(qvec):
50
+ return np.array(
51
+ [
52
+ [
53
+ 1 - 2 * qvec[2] ** 2 - 2 * qvec[3] ** 2,
54
+ 2 * qvec[1] * qvec[2] - 2 * qvec[0] * qvec[3],
55
+ 2 * qvec[3] * qvec[1] + 2 * qvec[0] * qvec[2],
56
+ ],
57
+ [
58
+ 2 * qvec[1] * qvec[2] + 2 * qvec[0] * qvec[3],
59
+ 1 - 2 * qvec[1] ** 2 - 2 * qvec[3] ** 2,
60
+ 2 * qvec[2] * qvec[3] - 2 * qvec[0] * qvec[1],
61
+ ],
62
+ [
63
+ 2 * qvec[3] * qvec[1] - 2 * qvec[0] * qvec[2],
64
+ 2 * qvec[2] * qvec[3] + 2 * qvec[0] * qvec[1],
65
+ 1 - 2 * qvec[1] ** 2 - 2 * qvec[2] ** 2,
66
+ ],
67
+ ]
68
+ )
69
+
70
+
71
+ def read_next_bytes(fid, num_bytes, format_char_sequence, endian_character="<"):
72
+ data = fid.read(num_bytes)
73
+ return struct.unpack(endian_character + format_char_sequence, data)
74
+
75
+
76
+ def read_extrinsics_binary(path_to_model_file):
77
+ images = {}
78
+ with open(path_to_model_file, "rb") as fid:
79
+ num_reg_images = read_next_bytes(fid, 8, "Q")[0]
80
+ for _ in range(num_reg_images):
81
+ binary_image_properties = read_next_bytes(
82
+ fid, num_bytes=64, format_char_sequence="idddddddi"
83
+ )
84
+ image_id = binary_image_properties[0]
85
+ qvec = np.array(binary_image_properties[1:5])
86
+ tvec = np.array(binary_image_properties[5:8])
87
+ camera_id = binary_image_properties[8]
88
+ image_name = ""
89
+ current_char = read_next_bytes(fid, 1, "c")[0]
90
+ while current_char != b"\x00":
91
+ image_name += current_char.decode("utf-8")
92
+ current_char = read_next_bytes(fid, 1, "c")[0]
93
+ num_points2D = read_next_bytes(fid, num_bytes=8, format_char_sequence="Q")[
94
+ 0
95
+ ]
96
+ x_y_id_s = read_next_bytes(
97
+ fid,
98
+ num_bytes=24 * num_points2D,
99
+ format_char_sequence="ddq" * num_points2D,
100
+ )
101
+ xys = np.column_stack(
102
+ [
103
+ tuple(map(float, x_y_id_s[0::3])),
104
+ tuple(map(float, x_y_id_s[1::3])),
105
+ ]
106
+ )
107
+ point3D_ids = np.array(tuple(map(int, x_y_id_s[2::3])))
108
+ images[image_id] = ColmapImage(
109
+ id=image_id,
110
+ qvec=qvec,
111
+ tvec=tvec,
112
+ camera_id=camera_id,
113
+ name=image_name,
114
+ xys=xys,
115
+ point3D_ids=point3D_ids,
116
+ )
117
+ return images
118
+
119
+
120
+ def read_extrinsics_text(path):
121
+ images = {}
122
+ with open(path, "r") as fid:
123
+ while True:
124
+ line = fid.readline()
125
+ if not line:
126
+ break
127
+ line = line.strip()
128
+ if len(line) > 0 and line[0] != "#":
129
+ elems = line.split()
130
+ image_id = int(elems[0])
131
+ qvec = np.array(tuple(map(float, elems[1:5])))
132
+ tvec = np.array(tuple(map(float, elems[5:8])))
133
+ camera_id = int(elems[8])
134
+ image_name = elems[9]
135
+ elems = fid.readline().split()
136
+ xys = np.column_stack(
137
+ [
138
+ tuple(map(float, elems[0::3])),
139
+ tuple(map(float, elems[1::3])),
140
+ ]
141
+ )
142
+ point3D_ids = np.array(tuple(map(int, elems[2::3])))
143
+ images[image_id] = ColmapImage(
144
+ id=image_id,
145
+ qvec=qvec,
146
+ tvec=tvec,
147
+ camera_id=camera_id,
148
+ name=image_name,
149
+ xys=xys,
150
+ point3D_ids=point3D_ids,
151
+ )
152
+ return images
153
+
154
+
155
+ def read_intrinsics_binary(path_to_model_file):
156
+ cameras = {}
157
+ with open(path_to_model_file, "rb") as fid:
158
+ num_cameras = read_next_bytes(fid, 8, "Q")[0]
159
+ for _ in range(num_cameras):
160
+ camera_properties = read_next_bytes(
161
+ fid, num_bytes=24, format_char_sequence="iiQQ"
162
+ )
163
+ camera_id = camera_properties[0]
164
+ model_id = camera_properties[1]
165
+ model_name = CAMERA_MODEL_IDS[camera_properties[1]].model_name
166
+ width = camera_properties[2]
167
+ height = camera_properties[3]
168
+ num_params = CAMERA_MODEL_IDS[model_id].num_params
169
+ params = read_next_bytes(
170
+ fid, num_bytes=8 * num_params, format_char_sequence="d" * num_params
171
+ )
172
+ cameras[camera_id] = ColmapCamera(
173
+ id=camera_id,
174
+ model=model_name,
175
+ width=width,
176
+ height=height,
177
+ params=np.array(params),
178
+ )
179
+ assert len(cameras) == num_cameras
180
+ return cameras
181
+
182
+
183
+ def read_intrinsics_text(path):
184
+ cameras = {}
185
+ with open(path, "r") as fid:
186
+ while True:
187
+ line = fid.readline()
188
+ if not line:
189
+ break
190
+ line = line.strip()
191
+ if len(line) > 0 and line[0] != "#":
192
+ elems = line.split()
193
+ camera_id = int(elems[0])
194
+ model = elems[1]
195
+ width = int(elems[2])
196
+ height = int(elems[3])
197
+ params = np.array(tuple(map(float, elems[4:])))
198
+ cameras[camera_id] = ColmapCamera(
199
+ id=camera_id,
200
+ model=model,
201
+ width=width,
202
+ height=height,
203
+ params=params,
204
+ )
205
+ return cameras
206
+
207
+
208
+ # =============================================================================
209
+ # Scene Loading
210
+ # =============================================================================
211
+
212
+
213
+ def load_colmap_scene(scene_path, sparse_dir="sparse/0", images_dir="images"):
214
+ """Load COLMAP reconstruction and convert to ReSplat format.
215
+
216
+ Returns dict with:
217
+ image_paths: list[str] - full paths to images
218
+ image_names: list[str] - image filenames
219
+ c2w: np.ndarray [N, 4, 4] - camera-to-world matrices (OpenCV convention)
220
+ intrinsics: np.ndarray [N, 3, 3] - normalized intrinsic matrices
221
+ image_sizes: list[tuple[int, int]] - (width, height) per image
222
+ """
223
+ sparse_path = os.path.join(scene_path, sparse_dir)
224
+
225
+ # Try binary format first, fall back to text
226
+ cameras_bin = os.path.join(sparse_path, "cameras.bin")
227
+ cameras_txt = os.path.join(sparse_path, "cameras.txt")
228
+ images_bin = os.path.join(sparse_path, "images.bin")
229
+ images_txt = os.path.join(sparse_path, "images.txt")
230
+
231
+ if os.path.exists(cameras_bin) and os.path.exists(images_bin):
232
+ cam_intrinsics = read_intrinsics_binary(cameras_bin)
233
+ cam_extrinsics = read_extrinsics_binary(images_bin)
234
+ print(f"Loaded COLMAP binary format from {sparse_path}")
235
+ elif os.path.exists(cameras_txt) and os.path.exists(images_txt):
236
+ cam_intrinsics = read_intrinsics_text(cameras_txt)
237
+ cam_extrinsics = read_extrinsics_text(images_txt)
238
+ print(f"Loaded COLMAP text format from {sparse_path}")
239
+ else:
240
+ raise FileNotFoundError(
241
+ f"No COLMAP reconstruction found in {sparse_path}. "
242
+ "Expected cameras.bin/txt and images.bin/txt"
243
+ )
244
+
245
+ images_root = os.path.join(scene_path, images_dir)
246
+
247
+ # Sort images by name for deterministic ordering
248
+ sorted_images = sorted(cam_extrinsics.values(), key=lambda x: x.name)
249
+
250
+ image_paths = []
251
+ image_names = []
252
+ c2w_list = []
253
+ intrinsics_list = []
254
+ image_sizes = []
255
+ point3D_ids_list = []
256
+
257
+ for img in sorted_images:
258
+ # Check image exists
259
+ img_path = os.path.join(images_root, img.name)
260
+ if not os.path.exists(img_path):
261
+ print(f"Warning: Image not found, skipping: {img_path}")
262
+ continue
263
+
264
+ # Get camera intrinsics
265
+ cam = cam_intrinsics[img.camera_id]
266
+
267
+ # Only support PINHOLE and SIMPLE_PINHOLE (undistorted images)
268
+ if cam.model == "PINHOLE":
269
+ fx, fy, cx, cy = cam.params[:4]
270
+ elif cam.model == "SIMPLE_PINHOLE":
271
+ f, cx, cy = cam.params[:3]
272
+ fx = fy = f
273
+ else:
274
+ print(
275
+ f"Warning: Unsupported camera model '{cam.model}' for image "
276
+ f"{img.name}, skipping. Only PINHOLE and SIMPLE_PINHOLE are "
277
+ f"supported. Please undistort images first using COLMAP."
278
+ )
279
+ continue
280
+
281
+ # Build normalized intrinsic matrix (fx/width, fy/height, cx/width, cy/height)
282
+ K = np.eye(3, dtype=np.float32)
283
+ K[0, 0] = fx / cam.width
284
+ K[1, 1] = fy / cam.height
285
+ K[0, 2] = cx / cam.width
286
+ K[1, 2] = cy / cam.height
287
+
288
+ # Build W2C matrix from COLMAP qvec + tvec
289
+ # COLMAP: qvec is world-to-camera rotation, tvec is translation in camera frame
290
+ R = qvec2rotmat(img.qvec)
291
+ w2c = np.eye(4, dtype=np.float32)
292
+ w2c[:3, :3] = R
293
+ w2c[:3, 3] = img.tvec
294
+
295
+ # Invert to get C2W (camera-to-world)
296
+ # COLMAP is already in OpenCV convention (Y-down, Z-forward)
297
+ c2w = np.linalg.inv(w2c).astype(np.float32)
298
+
299
+ # Extract valid point3D_ids (filter out -1 which means unmatched)
300
+ valid_pts = img.point3D_ids[img.point3D_ids >= 0]
301
+
302
+ image_paths.append(img_path)
303
+ image_names.append(img.name)
304
+ c2w_list.append(c2w)
305
+ intrinsics_list.append(K)
306
+ image_sizes.append((cam.width, cam.height))
307
+ point3D_ids_list.append(set(valid_pts.tolist()))
308
+
309
+ if len(image_paths) == 0:
310
+ raise RuntimeError(f"No valid images found in {images_root}")
311
+
312
+ return {
313
+ "image_paths": image_paths,
314
+ "image_names": image_names,
315
+ "c2w": np.stack(c2w_list, axis=0), # [N, 4, 4]
316
+ "intrinsics": np.stack(intrinsics_list, axis=0), # [N, 3, 3]
317
+ "image_sizes": image_sizes,
318
+ "point3D_ids": point3D_ids_list, # list of sets, one per image
319
+ }
320
+
321
+
322
+ # =============================================================================
323
+ # View Selection
324
+ # =============================================================================
325
+
326
+
327
+ def farthest_point_sample(xyz, npoint):
328
+ """Farthest point sampling on camera positions.
329
+
330
+ Adapted from src/dataset/view_sampler/view_sampler_bounded_v2.py
331
+
332
+ Args:
333
+ xyz: [B, N, 3] point cloud data
334
+ npoint: number of samples
335
+ Returns:
336
+ centroids: [B, npoint] sampled indices
337
+ """
338
+ device = xyz.device
339
+ B, N, C = xyz.shape
340
+
341
+ centroids = torch.zeros(B, npoint, dtype=torch.long).to(device)
342
+ distance = torch.ones(B, N).to(device) * 1e10
343
+
344
+ batch_indices = torch.arange(B, dtype=torch.long).to(device)
345
+
346
+ barycenter = torch.sum(xyz, 1)
347
+ barycenter = barycenter / xyz.shape[1]
348
+ barycenter = barycenter.view(B, 1, 3)
349
+
350
+ dist = torch.sum((xyz - barycenter) ** 2, -1)
351
+ farthest = torch.max(dist, 1)[1]
352
+
353
+ for i in range(npoint):
354
+ centroids[:, i] = farthest
355
+ centroid = xyz[batch_indices, farthest, :].view(B, 1, 3)
356
+ dist = torch.sum((xyz - centroid) ** 2, -1)
357
+ mask = dist < distance
358
+ distance[mask] = dist[mask]
359
+ farthest = torch.max(distance, -1)[1]
360
+
361
+ return centroids
362
+
363
+
364
+ def select_context_views(c2w, num_context, strategy="fps"):
365
+ """Select context view indices from all available views.
366
+
367
+ Args:
368
+ c2w: [N, 4, 4] camera-to-world matrices
369
+ num_context: number of context views to select
370
+ strategy: "fps" | "uniform"
371
+ Returns:
372
+ context_indices: sorted numpy array of indices
373
+ """
374
+ N = len(c2w)
375
+ if num_context >= N:
376
+ return np.arange(N)
377
+
378
+ if strategy == "fps":
379
+ positions = torch.tensor(c2w[:, :3, 3], dtype=torch.float32).unsqueeze(0)
380
+ indices = farthest_point_sample(positions, num_context)[0].numpy()
381
+ return np.sort(indices)
382
+ elif strategy == "uniform":
383
+ return np.linspace(0, N - 1, num_context, dtype=int)
384
+ else:
385
+ raise ValueError(f"Unknown context selection strategy: {strategy}")
386
+
387
+
388
+ def select_target_views(num_images, context_indices, target_selection="remaining",
389
+ num_target=None):
390
+ """Select target views for rendering.
391
+
392
+ Args:
393
+ num_images: total number of images
394
+ context_indices: indices used as context
395
+ target_selection: "remaining" | "all"
396
+ num_target: optional max number of target views
397
+ Returns:
398
+ target_indices: numpy array of indices
399
+ """
400
+ if target_selection == "remaining":
401
+ all_indices = np.arange(num_images)
402
+ target_indices = np.setdiff1d(all_indices, context_indices)
403
+ elif target_selection == "all":
404
+ target_indices = np.arange(num_images)
405
+ else:
406
+ raise ValueError(f"Unknown target selection: {target_selection}")
407
+
408
+ if num_target is not None and len(target_indices) > num_target:
409
+ step = len(target_indices) / num_target
410
+ selected = [int(i * step) for i in range(num_target)]
411
+ target_indices = target_indices[selected]
412
+
413
+ return target_indices
414
+
colmap_sfm.py CHANGED
@@ -7,7 +7,7 @@ to a PINHOLE camera model and writes a standard COLMAP scene layout
7
  <out>/images/*.jpg
8
  <out>/sparse/{cameras,images,points3D}.bin
9
 
10
- which is exactly what CodecSplat's reference COLMAP loader expects.
11
  """
12
 
13
  from __future__ import annotations
@@ -18,11 +18,6 @@ from pathlib import Path
18
  import pycolmap
19
  from PIL import Image, ImageOps
20
 
21
- # ZeroGPU hosts expose ~192 CPUs. With COLMAP's default num_threads=-1 the incremental
22
- # mapper then fails to register anything beyond the initial pair (reproduced locally by
23
- # forcing 192 threads), so cap the thread count explicitly.
24
- NUM_THREADS = 16
25
-
26
  IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff")
27
 
28
 
@@ -80,12 +75,6 @@ def run_sfm(
80
  "Upload several overlapping photos of the same scene."
81
  )
82
  log(f"[colmap] {len(names)} images prepared at <= {max_image_size}px")
83
- try:
84
- import os as _os
85
- cpu = next((l.split(":", 1)[1].strip() for l in open("/proc/cpuinfo") if l.startswith("model name")), "?")
86
- log(f"[colmap] cpu={cpu} ncpu={_os.cpu_count()} threads={NUM_THREADS}")
87
- except Exception:
88
- pass
89
 
90
  database_path = workdir / "colmap.db"
91
  if database_path.exists():
@@ -97,7 +86,6 @@ def run_sfm(
97
  extraction_options.sift.domain_size_pooling = False
98
  extraction_options.use_gpu = False
99
  extraction_options.max_image_size = int(max_image_size)
100
- extraction_options.num_threads = NUM_THREADS
101
 
102
  reader_options = pycolmap.ImageReaderOptions()
103
  reader_options.camera_model = "SIMPLE_RADIAL"
@@ -112,28 +100,23 @@ def run_sfm(
112
  )
113
  log("[colmap] features extracted")
114
 
115
- matching_options = pycolmap.FeatureMatchingOptions()
116
- matching_options.num_threads = NUM_THREADS
117
  if matcher == "sequential":
118
  pairing = pycolmap.SequentialPairingOptions()
119
  pairing.overlap = 10
120
  pairing.quadratic_overlap = True
121
  pycolmap.match_sequential(
122
  database_path=database_path,
123
- matching_options=matching_options,
124
  pairing_options=pairing,
125
  device=pycolmap.Device.cpu,
126
  )
127
  else:
128
- pycolmap.match_exhaustive(database_path=database_path, matching_options=matching_options,
129
- device=pycolmap.Device.cpu)
130
  log(f"[colmap] {matcher} matching done")
131
 
132
  sparse_dir = workdir / "sparse_raw"
133
  sparse_dir.mkdir(parents=True, exist_ok=True)
134
  options = pycolmap.IncrementalPipelineOptions()
135
  options.min_num_matches = 15
136
- options.num_threads = NUM_THREADS
137
  options.multiple_models = False
138
  options.extract_colors = True
139
  maps = pycolmap.incremental_mapping(
@@ -151,7 +134,7 @@ def run_sfm(
151
  "rotation / blurry frames, and keep the scene static."
152
  )
153
  # Deterministic world scale (same convention COLMAP itself uses), so that the
154
- # fixed near/far planes of the CodecSplat checkpoint stay meaningful.
155
  rec.normalize(fixed_scale=False, extent=10.0)
156
  log(f"[colmap] mapped {rec.num_reg_images()}/{len(names)} images, {rec.num_points3D()} points")
157
 
@@ -171,7 +154,7 @@ def run_sfm(
171
  )
172
  log("[colmap] undistorted to PINHOLE")
173
 
174
- # Keep only what CodecSplat reads (the undistorter also writes stereo/ scaffolding).
175
  for extra in ("stereo",):
176
  p = scene_dir / extra
177
  if p.is_dir():
@@ -183,32 +166,7 @@ def run_sfm(
183
  except Exception:
184
  pass
185
 
186
- # Read back the undistorted PINHOLE cameras (normalized K, OpenCV c2w), sorted by name.
187
- und = pycolmap.Reconstruction(str(scene_dir / "sparse"))
188
- frames = []
189
- for _, img in und.images.items():
190
- cam = und.cameras[img.camera_id]
191
- p = list(cam.params)
192
- if cam.model.name == "PINHOLE":
193
- fx, fy, cx, cy = p[:4]
194
- elif cam.model.name == "SIMPLE_PINHOLE":
195
- fx = fy = p[0]
196
- cx, cy = p[1:3]
197
- else:
198
- continue
199
- cfw = img.cam_from_world() if callable(img.cam_from_world) else img.cam_from_world
200
- w2c = cfw.matrix() # 3x4
201
- path = scene_dir / "images" / img.name
202
- if path.exists():
203
- frames.append({
204
- "name": img.name, "path": str(path),
205
- "K": [[fx / cam.width, 0, cx / cam.width], [0, fy / cam.height, cy / cam.height], [0, 0, 1]],
206
- "w2c": [list(map(float, row)) for row in w2c],
207
- })
208
- frames.sort(key=lambda f: f["name"])
209
-
210
  return {
211
- "frames": frames,
212
  "scene_dir": str(scene_dir),
213
  "sparse_sub": "sparse",
214
  "images_sub": "images",
@@ -217,55 +175,3 @@ def run_sfm(
217
  "num_points3D": int(rec.num_points3D()),
218
  "camera_model": cam_model,
219
  }
220
-
221
-
222
- def run_sfm_subprocess(image_paths, workdir, max_image_size=1280, matcher="exhaustive",
223
- timeout=900, log=print) -> dict:
224
- """Run `run_sfm` in a fresh Python process that only imports pycolmap.
225
-
226
- Inside the Gradio process torch / CUDA / OpenMP / BLAS libraries are already loaded,
227
- which makes pycolmap's bundled OpenBLAS / OpenMP misbehave (crashes in matching and
228
- failed image registration). A clean child process avoids all of that.
229
- """
230
- import json
231
- import os
232
- import subprocess
233
- import sys
234
-
235
- workdir = Path(workdir)
236
- workdir.mkdir(parents=True, exist_ok=True)
237
- spec = workdir / "sfm_job.json"
238
- result = workdir / "sfm_result.json"
239
- spec.write_text(json.dumps({
240
- "image_paths": [str(p) for p in image_paths], "workdir": str(workdir),
241
- "max_image_size": int(max_image_size), "matcher": matcher, "result": str(result),
242
- }))
243
- env = {k: v for k, v in os.environ.items() if k not in ("LD_PRELOAD",)}
244
- env["LD_LIBRARY_PATH"] = os.environ.get("_ORIG_LD_LIBRARY_PATH", "")
245
- env["OPENBLAS_NUM_THREADS"] = "1"
246
- env.pop("CUDA_VISIBLE_DEVICES", None)
247
- proc = subprocess.run([sys.executable, os.path.abspath(__file__), str(spec)], env=env,
248
- capture_output=True, text=True, timeout=timeout)
249
- for line in (proc.stdout or "").splitlines():
250
- if line.startswith("[colmap]"):
251
- log(line)
252
- if not result.exists():
253
- tail = "\n".join(((proc.stderr or "") + (proc.stdout or "")).splitlines()[-15:])
254
- raise RuntimeError(f"COLMAP subprocess failed (exit {proc.returncode}):\n{tail}")
255
- data = json.loads(result.read_text())
256
- if "error" in data:
257
- raise ValueError(data["error"])
258
- return data
259
-
260
-
261
- if __name__ == "__main__":
262
- import json
263
- import sys
264
-
265
- job = json.loads(Path(sys.argv[1]).read_text())
266
- try:
267
- out = run_sfm(job["image_paths"], Path(job["workdir"]), job["max_image_size"],
268
- job["matcher"], log=lambda m: print(m, flush=True))
269
- except ValueError as exc:
270
- out = {"error": str(exc)}
271
- Path(job["result"]).write_text(json.dumps(out))
 
7
  <out>/images/*.jpg
8
  <out>/sparse/{cameras,images,points3D}.bin
9
 
10
+ which is exactly what ReSplat's reference COLMAP loader expects.
11
  """
12
 
13
  from __future__ import annotations
 
18
  import pycolmap
19
  from PIL import Image, ImageOps
20
 
 
 
 
 
 
21
  IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff")
22
 
23
 
 
75
  "Upload several overlapping photos of the same scene."
76
  )
77
  log(f"[colmap] {len(names)} images prepared at <= {max_image_size}px")
 
 
 
 
 
 
78
 
79
  database_path = workdir / "colmap.db"
80
  if database_path.exists():
 
86
  extraction_options.sift.domain_size_pooling = False
87
  extraction_options.use_gpu = False
88
  extraction_options.max_image_size = int(max_image_size)
 
89
 
90
  reader_options = pycolmap.ImageReaderOptions()
91
  reader_options.camera_model = "SIMPLE_RADIAL"
 
100
  )
101
  log("[colmap] features extracted")
102
 
 
 
103
  if matcher == "sequential":
104
  pairing = pycolmap.SequentialPairingOptions()
105
  pairing.overlap = 10
106
  pairing.quadratic_overlap = True
107
  pycolmap.match_sequential(
108
  database_path=database_path,
 
109
  pairing_options=pairing,
110
  device=pycolmap.Device.cpu,
111
  )
112
  else:
113
+ pycolmap.match_exhaustive(database_path=database_path, device=pycolmap.Device.cpu)
 
114
  log(f"[colmap] {matcher} matching done")
115
 
116
  sparse_dir = workdir / "sparse_raw"
117
  sparse_dir.mkdir(parents=True, exist_ok=True)
118
  options = pycolmap.IncrementalPipelineOptions()
119
  options.min_num_matches = 15
 
120
  options.multiple_models = False
121
  options.extract_colors = True
122
  maps = pycolmap.incremental_mapping(
 
134
  "rotation / blurry frames, and keep the scene static."
135
  )
136
  # Deterministic world scale (same convention COLMAP itself uses), so that the
137
+ # fixed near/far planes of the ReSplat checkpoint stay meaningful.
138
  rec.normalize(fixed_scale=False, extent=10.0)
139
  log(f"[colmap] mapped {rec.num_reg_images()}/{len(names)} images, {rec.num_points3D()} points")
140
 
 
154
  )
155
  log("[colmap] undistorted to PINHOLE")
156
 
157
+ # Keep only what ReSplat reads (the undistorter also writes stereo/ scaffolding).
158
  for extra in ("stereo",):
159
  p = scene_dir / extra
160
  if p.is_dir():
 
166
  except Exception:
167
  pass
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  return {
 
170
  "scene_dir": str(scene_dir),
171
  "sparse_sub": "sparse",
172
  "images_sub": "images",
 
175
  "num_points3D": int(rec.num_points3D()),
176
  "camera_model": cam_model,
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
examples/counter/counter_00.jpg ADDED

Git LFS Details

  • SHA256: 55861fb1ef4c3706f791792f6499dc8362abec51e08e687e431f235d7aae745a
  • Pointer size: 131 Bytes
  • Size of remote file: 155 kB
examples/counter/counter_01.jpg ADDED

Git LFS Details

  • SHA256: 9cd4f301243ff680ede6bb14d0626297358f343237a6555ec1dd0d9867f18c5f
  • Pointer size: 131 Bytes
  • Size of remote file: 150 kB
examples/counter/counter_02.jpg ADDED

Git LFS Details

  • SHA256: 368b0d29e19e95f8d2e022fa1786d348fcc1c6826bfd6a5809473370edb571a4
  • Pointer size: 131 Bytes
  • Size of remote file: 153 kB
examples/counter/counter_03.jpg ADDED

Git LFS Details

  • SHA256: 80e632d5a9131dcbe9979002cac5a148cdf9d502921a9b16cc3ff8adca1a2d5d
  • Pointer size: 131 Bytes
  • Size of remote file: 165 kB
examples/counter/counter_04.jpg ADDED

Git LFS Details

  • SHA256: 9b0b1ef3118ae61cff522eaa589d90f0a8ba8f4d6e6c6eed50816003aa43917e
  • Pointer size: 131 Bytes
  • Size of remote file: 159 kB
examples/counter/counter_05.jpg ADDED

Git LFS Details

  • SHA256: d3d24b2aa67d18d66880c493e893a30be1fc01415e605956790fcdef94ab441a
  • Pointer size: 131 Bytes
  • Size of remote file: 157 kB
examples/counter/counter_06.jpg ADDED

Git LFS Details

  • SHA256: 6fa5aa6458400c1f5720ff0b34e4e9f87b016e4adea92bdab089819feb3d8ef3
  • Pointer size: 131 Bytes
  • Size of remote file: 145 kB
examples/counter/counter_07.jpg ADDED

Git LFS Details

  • SHA256: a0270c24dfc52c690b97b8b01bce0ccb24a6792fba871798a8e252df62ab5819
  • Pointer size: 131 Bytes
  • Size of remote file: 161 kB
examples/counter/counter_08.jpg ADDED

Git LFS Details

  • SHA256: d8a765d64e817d2fbbb3bf793e23b172ce460237d9c8a86944ee8b130121a50a
  • Pointer size: 131 Bytes
  • Size of remote file: 133 kB
examples/counter/counter_09.jpg ADDED

Git LFS Details

  • SHA256: 326bce521fe4bae35c6db4974bbd33edf11285531398a5461d94009a7bd897a3
  • Pointer size: 131 Bytes
  • Size of remote file: 139 kB
examples/counter/counter_10.jpg ADDED

Git LFS Details

  • SHA256: d4436bf228ecb79b9f6f065df8dd57a5c0d2af63339d3b07bdab04b77620c51f
  • Pointer size: 131 Bytes
  • Size of remote file: 131 kB
examples/counter/counter_11.jpg ADDED

Git LFS Details

  • SHA256: 77c459e34478d364b486349c269e6c6fc7f52a38d3f92d6e4a9d2c1291d3bf25
  • Pointer size: 131 Bytes
  • Size of remote file: 134 kB
examples/counter/counter_12.jpg ADDED

Git LFS Details

  • SHA256: 3681d14a5f00f93baaa2851474bc8cfe33af5f6df8c283bdbaf908bf9a9c4f2d
  • Pointer size: 131 Bytes
  • Size of remote file: 134 kB
examples/counter/counter_13.jpg ADDED

Git LFS Details

  • SHA256: d892ac18f9e40600cf0ed7fc3bbaa85038b4989815c1f9322737691aa1d0a78f
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
examples/garden/garden_00.jpg ADDED

Git LFS Details

  • SHA256: e4d991b1ef48375a72cab84679d15224e7e733f55ce6818d279de6d24462b718
  • Pointer size: 131 Bytes
  • Size of remote file: 263 kB
examples/garden/garden_01.jpg ADDED

Git LFS Details

  • SHA256: ee05e4a6b6f0d97f3acdfc7c217ab4638be72525a0d9c5fe3f094a4356d28efa
  • Pointer size: 131 Bytes
  • Size of remote file: 263 kB
examples/garden/garden_02.jpg ADDED

Git LFS Details

  • SHA256: 3283c838528eb146bb81d822072bd51d8a3fbeb7a8c4df3974062eb7a8320e77
  • Pointer size: 131 Bytes
  • Size of remote file: 257 kB
examples/garden/garden_03.jpg ADDED

Git LFS Details

  • SHA256: e0cc90c70c0f8bc070c659c6020d96f8c6058a2ec03987a60f2ef4d3cf67da34
  • Pointer size: 131 Bytes
  • Size of remote file: 244 kB
examples/garden/garden_04.jpg ADDED

Git LFS Details

  • SHA256: 0a5fe978ab7f45ac68d3cf7bcdc4d3444e2479001ced9ce325858a5be7dfcabd
  • Pointer size: 131 Bytes
  • Size of remote file: 242 kB
examples/garden/garden_05.jpg ADDED

Git LFS Details

  • SHA256: fada3657684ebc8aadb5db2fbe889423490ba05347609adf8a3454dfe2453204
  • Pointer size: 131 Bytes
  • Size of remote file: 234 kB
examples/garden/garden_06.jpg ADDED

Git LFS Details

  • SHA256: 40daa01a73b176797d8aec8e212ae4a07100412c231ab96a73408c2eaa0cf3b0
  • Pointer size: 131 Bytes
  • Size of remote file: 243 kB
examples/garden/garden_07.jpg ADDED

Git LFS Details

  • SHA256: 405dc63c34ac8b84892094e75b27940819e9615b0d2e5d1ee0908cae0b4e47cb
  • Pointer size: 131 Bytes
  • Size of remote file: 249 kB
examples/garden/garden_08.jpg ADDED

Git LFS Details

  • SHA256: 3219899ccc8ec7f05d90c0e63358330f144566f16200a032df42b316ed14ae9e
  • Pointer size: 131 Bytes
  • Size of remote file: 251 kB
examples/garden/garden_09.jpg ADDED

Git LFS Details

  • SHA256: 14b2ae74378da4561b2b87e132c4366911729d5c3ef718c69c1d591cb04bce7f
  • Pointer size: 131 Bytes
  • Size of remote file: 254 kB
examples/garden/garden_10.jpg ADDED

Git LFS Details

  • SHA256: 51dd65c626bbb84a831d49d09d20ea1c9b04d4896e0ac939ce2c662a94202330
  • Pointer size: 131 Bytes
  • Size of remote file: 264 kB
examples/garden/garden_11.jpg ADDED

Git LFS Details

  • SHA256: 63d8b21d8d7482641a7f9f46adada683188c527c3efeb046bf8fa00dcc7971c9
  • Pointer size: 131 Bytes
  • Size of remote file: 258 kB
examples/garden/garden_12.jpg ADDED

Git LFS Details

  • SHA256: 9823dc7364e2348feb9af224c052058dfa4e80c9f1128d0f0a983e33f85d241c
  • Pointer size: 131 Bytes
  • Size of remote file: 256 kB
examples/garden/garden_13.jpg ADDED

Git LFS Details

  • SHA256: e98b2b00200c618d360d7fee41e70c159bbfb57eec9dea45c7f19c09f890c849
  • Pointer size: 131 Bytes
  • Size of remote file: 244 kB
examples/kitchen/kitchen_00.jpg ADDED

Git LFS Details

  • SHA256: 65983e10ba2c32f8ccd1a1c7161f32da551a3dff29f95e411e6b9152e6fb7e86
  • Pointer size: 131 Bytes
  • Size of remote file: 174 kB
examples/kitchen/kitchen_01.jpg ADDED

Git LFS Details

  • SHA256: 973d24f802907f706ea270463aabf94346f00442fb6dc89fce0692467183fea8
  • Pointer size: 131 Bytes
  • Size of remote file: 176 kB
examples/kitchen/kitchen_02.jpg ADDED

Git LFS Details

  • SHA256: 93d0074367a09807bceda9794f9fb1dd6e7123c5a8a75d68daad317ef4dee72d
  • Pointer size: 131 Bytes
  • Size of remote file: 175 kB
examples/kitchen/kitchen_03.jpg ADDED

Git LFS Details

  • SHA256: e9658f6d941e27882e9ff7fdad4466661551ce840a7eca1ce142283c0577a8b4
  • Pointer size: 131 Bytes
  • Size of remote file: 178 kB
examples/kitchen/kitchen_04.jpg ADDED

Git LFS Details

  • SHA256: 3233937a8bea606d086e9bd8b3295a869df294c91b110d9a5bd1ec2729421bfb
  • Pointer size: 131 Bytes
  • Size of remote file: 181 kB
examples/kitchen/kitchen_05.jpg ADDED

Git LFS Details

  • SHA256: 63be0d19f9c07da6ea35f1e4cea8e0bcf5a698277e339bfcfb309e3c3923b19b
  • Pointer size: 131 Bytes
  • Size of remote file: 180 kB
examples/kitchen/kitchen_06.jpg ADDED

Git LFS Details

  • SHA256: 1d7de183db23070776186de17a6af7025f4e943e25b1df08d32664a4e6b7cae4
  • Pointer size: 131 Bytes
  • Size of remote file: 174 kB
examples/kitchen/kitchen_07.jpg ADDED

Git LFS Details

  • SHA256: 92a92e068cd7789d033119ae0279609fa919ec491224a34e54985c55436d27c0
  • Pointer size: 131 Bytes
  • Size of remote file: 183 kB
examples/kitchen/kitchen_08.jpg ADDED

Git LFS Details

  • SHA256: 02afd2f9570a59d916b786fc0e1a60b7b47d7a0b22e36c5f57d2de41b3078a47
  • Pointer size: 131 Bytes
  • Size of remote file: 204 kB
examples/kitchen/kitchen_09.jpg ADDED

Git LFS Details

  • SHA256: 63207aa638cfe8d1d843869c43ad0ace49d664b2e3f516d2b56acc8ccdeaf841
  • Pointer size: 131 Bytes
  • Size of remote file: 195 kB
examples/kitchen/kitchen_10.jpg ADDED

Git LFS Details

  • SHA256: 3fe3f4b06105e6b1382c69aa650eb265d9acd8fbfa25f984d600847c0f1ee042
  • Pointer size: 131 Bytes
  • Size of remote file: 181 kB
examples/kitchen/kitchen_11.jpg ADDED

Git LFS Details

  • SHA256: 902486cb003afceded1e36c252afe6f9ccc55c5d4d00215a21f33b8c9ba472ef
  • Pointer size: 131 Bytes
  • Size of remote file: 190 kB
examples/kitchen/kitchen_12.jpg ADDED

Git LFS Details

  • SHA256: c58a7144ef46804658b8170a1b20a91763124f923000726492d21e99114c0f7f
  • Pointer size: 131 Bytes
  • Size of remote file: 179 kB
examples/kitchen/kitchen_13.jpg ADDED

Git LFS Details

  • SHA256: d3b5f2057aae83f4b39dff64afc6d31aec0ca5d5adb0ee5faed52a363fba98f6
  • Pointer size: 131 Bytes
  • Size of remote file: 180 kB
requirements.txt CHANGED
@@ -1,24 +1,27 @@
1
- torch==2.11.0
2
- torchvision==0.26.0
3
- https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt211-cu130-cp312/diff_gaussian_rasterization-0.0.0-cp312-cp312-linux_x86_64.whl
4
- compressai==1.2.8
5
  numpy<2
 
6
  pycolmap==4.1.1
7
- einops
8
- jaxtyping>=0.3.7,<0.3.11
9
  beartype
10
- hydra-core
11
- omegaconf
12
- dacite
13
  colorama
14
  colorspacious
 
15
  e3nn
 
 
 
 
 
 
16
  lpips
17
- plyfile
18
- scipy
19
  matplotlib
20
  opencv-python-headless
21
  Pillow
 
 
 
22
  tqdm
23
- imageio
24
- imageio-ffmpeg
 
1
+ torchvision
2
+ gsplat==1.5.3
3
+ ninja
4
+ rich
5
  numpy<2
6
+ compressai==1.2.8
7
  pycolmap==4.1.1
 
 
8
  beartype
 
 
 
9
  colorama
10
  colorspacious
11
+ dacite
12
  e3nn
13
+ einops
14
+ hydra-core
15
+ omegaconf
16
+ imageio
17
+ imageio-ffmpeg
18
+ jaxtyping>=0.3.7,<0.3.11
19
  lpips
 
 
20
  matplotlib
21
  opencv-python-headless
22
  Pillow
23
+ plyfile
24
+ pytorch-lightning
25
+ scipy
26
  tqdm
27
+ wandb
 
src/main.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ import copy
4
+
5
+ import hydra
6
+ import torch
7
+ import wandb
8
+ from colorama import Fore
9
+ from jaxtyping import install_import_hook
10
+ from omegaconf import DictConfig, OmegaConf
11
+ from pytorch_lightning import Trainer
12
+ from pytorch_lightning.callbacks import (
13
+ LearningRateMonitor,
14
+ ModelCheckpoint,
15
+ )
16
+ from pytorch_lightning.loggers.wandb import WandbLogger
17
+
18
+ from pytorch_lightning.plugins.environments import LightningEnvironment
19
+
20
+
21
+ # Configure beartype and jaxtyping.
22
+ with install_import_hook(
23
+ ("src",),
24
+ ("beartype", "beartype"),
25
+ ):
26
+ from src.config import load_typed_root_config
27
+ from src.dataset.data_module import DataModule
28
+ from src.global_cfg import set_cfg
29
+ from src.loss import get_losses
30
+ from src.misc.LocalLogger import LocalLogger
31
+ from src.misc.step_tracker import StepTracker
32
+ from src.misc.wandb_tools import update_checkpoint_path
33
+ from src.misc.resume_ckpt import find_latest_ckpt
34
+ from src.model.decoder import get_decoder
35
+ from src.model.encoder import get_encoder
36
+ from src.model.model_wrapper import ModelWrapper
37
+
38
+
39
+ def cyan(text: str) -> str:
40
+ return f"{Fore.CYAN}{text}{Fore.RESET}"
41
+
42
+
43
+ def load_state_dict_with_shape_filter(
44
+ module: torch.nn.Module,
45
+ state_dict: dict[str, torch.Tensor],
46
+ strict: bool,
47
+ label: str,
48
+ ) -> None:
49
+ if strict:
50
+ module.load_state_dict(state_dict, strict=True)
51
+ return
52
+
53
+ module_state = module.state_dict()
54
+ filtered_state_dict = {}
55
+ shape_mismatches = []
56
+ for key, value in state_dict.items():
57
+ if key not in module_state:
58
+ continue
59
+ if module_state[key].shape != value.shape:
60
+ shape_mismatches.append(
61
+ (key, tuple(value.shape), tuple(module_state[key].shape))
62
+ )
63
+ continue
64
+ filtered_state_dict[key] = value
65
+
66
+ incompatible = module.load_state_dict(filtered_state_dict, strict=False)
67
+ missing_keys = sorted(incompatible.missing_keys)
68
+ unexpected_keys = sorted(incompatible.unexpected_keys)
69
+
70
+ print(cyan(f"{label}: strict=False, filtered checkpoint loading summary"))
71
+ print(f" loaded tensors: {len(filtered_state_dict)}")
72
+ print(f" missing model tensors: {len(missing_keys)}")
73
+ for key in missing_keys:
74
+ print(f" [missing] {key}")
75
+ print(f" shape-mismatched checkpoint tensors: {len(shape_mismatches)}")
76
+ for key, ckpt_shape, model_shape in shape_mismatches:
77
+ print(
78
+ f" [shape-mismatch] {key}: checkpoint {ckpt_shape} vs model {model_shape}"
79
+ )
80
+ print(f" unexpected checkpoint tensors: {len(unexpected_keys)}")
81
+ for key in unexpected_keys:
82
+ print(f" [unexpected] {key}")
83
+
84
+
85
+ @hydra.main(
86
+ version_base=None,
87
+ config_path="../config",
88
+ config_name="main",
89
+ )
90
+ def train(cfg_dict: DictConfig):
91
+ if cfg_dict["mode"] == "train" and cfg_dict["train"]["eval_model_every_n_val"] > 0:
92
+ eval_cfg_dict = copy.deepcopy(cfg_dict)
93
+ dataset_dir = str(cfg_dict["dataset"]["roots"]).lower()
94
+ eval_num_context_views = cfg_dict["dataset"]["view_sampler"][
95
+ "num_context_views"
96
+ ]
97
+ if "re10k" in dataset_dir:
98
+ if cfg_dict["dataset"]["view_sampler"]["num_context_views"] == 2:
99
+ eval_path = "assets/evaluation_index_re10k.json"
100
+ else:
101
+ raise ValueError("unsupported number of views for re10k")
102
+ elif "dl3dv" in dataset_dir:
103
+ if eval_num_context_views == 8:
104
+ eval_path = (
105
+ "assets/dl3dv_evaluation/"
106
+ "dl3dv_start_0_distance_40_ctx_8v_tgt_8v.json"
107
+ )
108
+ else:
109
+ raise ValueError("unsupported number of views for dl3dv")
110
+ else:
111
+ raise Exception("Fail to load eval index path")
112
+ eval_cfg_dict["dataset"]["view_sampler"] = {
113
+ "name": "evaluation",
114
+ "index_path": eval_path,
115
+ "num_context_views": eval_num_context_views,
116
+ }
117
+ eval_cfg = load_typed_root_config(eval_cfg_dict)
118
+ else:
119
+ eval_cfg = None
120
+
121
+ cfg = load_typed_root_config(cfg_dict)
122
+ set_cfg(cfg_dict)
123
+
124
+ # Set up the output directory.
125
+ if cfg_dict.output_dir is None:
126
+ output_dir = Path(
127
+ hydra.core.hydra_config.HydraConfig.get()["runtime"]["output_dir"]
128
+ )
129
+ else: # for resuming
130
+ output_dir = Path(cfg_dict.output_dir)
131
+ os.makedirs(output_dir, exist_ok=True)
132
+ print(cyan(f"Saving outputs to {output_dir}."))
133
+
134
+ # Set up logging with wandb.
135
+ callbacks = []
136
+ if cfg_dict.wandb.mode != "disabled" and cfg.mode == "train":
137
+ wandb_extra_kwargs = {}
138
+ if cfg_dict.wandb.id is not None:
139
+ wandb_extra_kwargs.update({'id': cfg_dict.wandb.id,
140
+ 'resume': "must"})
141
+ logger = WandbLogger(
142
+ entity=cfg_dict.wandb.entity,
143
+ project=cfg_dict.wandb.project,
144
+ mode=cfg_dict.wandb.mode,
145
+ name=os.path.basename(cfg_dict.output_dir),
146
+ tags=cfg_dict.wandb.get("tags", None),
147
+ log_model=False,
148
+ save_dir=output_dir,
149
+ config=OmegaConf.to_container(cfg_dict),
150
+ **wandb_extra_kwargs,
151
+ )
152
+ callbacks.append(LearningRateMonitor("step", True))
153
+
154
+ if wandb.run is not None:
155
+ wandb.run.log_code("src")
156
+ else:
157
+ logger = LocalLogger(output_dir / "local")
158
+
159
+ # Set up checkpointing.
160
+ callbacks.append(
161
+ ModelCheckpoint(
162
+ output_dir / "checkpoints",
163
+ every_n_train_steps=cfg.checkpointing.every_n_train_steps,
164
+ save_top_k=cfg.checkpointing.save_top_k,
165
+ monitor="info/global_step",
166
+ mode="max",
167
+ )
168
+ )
169
+ for cb in callbacks:
170
+ cb.CHECKPOINT_EQUALS_CHAR = '_'
171
+
172
+ # Prepare the checkpoint for loading.
173
+ if cfg.checkpointing.resume:
174
+ if not os.path.exists(output_dir / 'checkpoints'):
175
+ checkpoint_path = None
176
+ else:
177
+ checkpoint_path = find_latest_ckpt(output_dir / 'checkpoints')
178
+ print(f'resume from {checkpoint_path}')
179
+ else:
180
+ checkpoint_path = update_checkpoint_path(cfg.checkpointing.load, cfg.wandb)
181
+
182
+ # This allows the current step to be shared with the data loader processes.
183
+ step_tracker = StepTracker()
184
+
185
+ if cfg.mode == "test":
186
+ trainer_devices = 1
187
+ trainer_strategy = "auto"
188
+ trainer_num_nodes = 1
189
+ else:
190
+ trainer_devices = torch.cuda.device_count()
191
+ trainer_strategy = "ddp" if trainer_devices > 1 else "auto"
192
+ trainer_num_nodes = cfg.trainer.num_nodes
193
+
194
+ trainer = Trainer(
195
+ max_epochs=-1,
196
+ accelerator="gpu",
197
+ logger=logger,
198
+ devices=trainer_devices,
199
+ strategy=trainer_strategy,
200
+ callbacks=callbacks,
201
+ val_check_interval=cfg.trainer.val_check_interval,
202
+ enable_progress_bar=cfg.mode == "test",
203
+ gradient_clip_val=cfg.trainer.gradient_clip_val,
204
+ accumulate_grad_batches=cfg.trainer.accumulate_grad_batches,
205
+ precision=cfg.trainer.precision,
206
+ max_steps=cfg.trainer.max_steps,
207
+ num_sanity_val_steps=cfg.trainer.num_sanity_val_steps,
208
+ num_nodes=trainer_num_nodes,
209
+ plugins=LightningEnvironment() if cfg.use_plugins else None,
210
+ )
211
+ torch.manual_seed(cfg_dict.seed + trainer.global_rank)
212
+
213
+ encoder = get_encoder(cfg.model.encoder)
214
+
215
+ model_wrapper = ModelWrapper(
216
+ cfg.optimizer,
217
+ cfg.test,
218
+ cfg.train,
219
+ encoder,
220
+ get_decoder(cfg.model.decoder, cfg.dataset),
221
+ get_losses(cfg.loss),
222
+ step_tracker,
223
+ eval_data_cfg=(
224
+ None if eval_cfg is None else eval_cfg.dataset
225
+ ),
226
+ )
227
+ data_module = DataModule(
228
+ cfg.dataset,
229
+ cfg.data_loader,
230
+ step_tracker,
231
+ global_rank=trainer.global_rank,
232
+ )
233
+
234
+ if cfg.mode == "train":
235
+ print("train:", len(data_module.train_dataloader()))
236
+ print("val:", len(data_module.val_dataloader()))
237
+ print("test:", len(data_module.test_dataloader()))
238
+
239
+ strict_load = not cfg.checkpointing.no_strict_load
240
+
241
+ if cfg.mode == "train":
242
+ # only load monodepth
243
+ if cfg.checkpointing.pretrained_monodepth is not None:
244
+ strict_load = False
245
+ pretrained_model = torch.load(cfg.checkpointing.pretrained_monodepth, map_location='cpu')
246
+ if 'state_dict' in pretrained_model:
247
+ pretrained_model = pretrained_model['state_dict']
248
+
249
+ model_wrapper.encoder.depth_predictor.load_state_dict(pretrained_model, strict=strict_load)
250
+ print(
251
+ cyan(
252
+ f"Loaded pretrained monodepth: {cfg.checkpointing.pretrained_monodepth}"
253
+ )
254
+ )
255
+
256
+ # load pretrained mvdepth
257
+ if cfg.checkpointing.pretrained_mvdepth is not None:
258
+ pretrained_model = torch.load(cfg.checkpointing.pretrained_mvdepth, map_location='cpu')['model']
259
+
260
+ model_wrapper.encoder.depth_predictor.load_state_dict(pretrained_model, strict=False)
261
+ print(
262
+ cyan(
263
+ f"Loaded pretrained mvdepth: {cfg.checkpointing.pretrained_mvdepth}"
264
+ )
265
+ )
266
+
267
+ # load full model
268
+ if cfg.checkpointing.pretrained_model is not None:
269
+ pretrained_model = torch.load(cfg.checkpointing.pretrained_model, map_location='cpu')
270
+ if 'state_dict' in pretrained_model:
271
+ pretrained_model = pretrained_model['state_dict']
272
+
273
+ load_state_dict_with_shape_filter(
274
+ model_wrapper,
275
+ pretrained_model,
276
+ strict=strict_load,
277
+ label="pretrained_model",
278
+ )
279
+ print(
280
+ cyan(
281
+ f"Loaded pretrained weights: {cfg.checkpointing.pretrained_model}"
282
+ )
283
+ )
284
+
285
+ # load pretrained depth
286
+ if cfg.checkpointing.pretrained_depth is not None:
287
+ pretrained_model = torch.load(cfg.checkpointing.pretrained_depth, map_location='cpu')['model']
288
+
289
+ strict_load = True
290
+ model_wrapper.encoder.depth_predictor.load_state_dict(pretrained_model, strict=strict_load)
291
+ print(
292
+ cyan(
293
+ f"Loaded pretrained depth: {cfg.checkpointing.pretrained_depth}"
294
+ )
295
+ )
296
+
297
+ trainer.fit(model_wrapper, datamodule=data_module, ckpt_path=checkpoint_path)
298
+ else:
299
+ # load full model
300
+ if cfg.checkpointing.pretrained_model is not None:
301
+ pretrained_model = torch.load(cfg.checkpointing.pretrained_model, map_location='cpu')
302
+ if 'state_dict' in pretrained_model:
303
+ pretrained_model = pretrained_model['state_dict']
304
+
305
+ load_state_dict_with_shape_filter(
306
+ model_wrapper,
307
+ pretrained_model,
308
+ strict=strict_load,
309
+ label="pretrained_model",
310
+ )
311
+ print(
312
+ cyan(
313
+ f"Loaded pretrained weights: {cfg.checkpointing.pretrained_model}"
314
+ )
315
+ )
316
+
317
+ # load pretrained depth model only
318
+ if cfg.checkpointing.pretrained_depth is not None:
319
+ pretrained_model = torch.load(cfg.checkpointing.pretrained_depth, map_location='cpu')['model']
320
+
321
+ strict_load = True
322
+ model_wrapper.encoder.depth_predictor.load_state_dict(pretrained_model, strict=strict_load)
323
+ print(
324
+ cyan(
325
+ f"Loaded pretrained depth: {cfg.checkpointing.pretrained_depth}"
326
+ )
327
+ )
328
+
329
+ trainer.test(
330
+ model_wrapper,
331
+ datamodule=data_module,
332
+ ckpt_path=checkpoint_path,
333
+ )
334
+
335
+
336
+ if __name__ == "__main__":
337
+ torch.set_float32_matmul_precision('high')
338
+
339
+ train()