Spaces:
Running on Zero
Running on Zero
CodecSplat demo
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +42 -0
- LICENSE +21 -0
- README.md +33 -38
- app.py +402 -193
- colmap_io.py +414 -0
- colmap_sfm.py +4 -98
- examples/counter/counter_00.jpg +3 -0
- examples/counter/counter_01.jpg +3 -0
- examples/counter/counter_02.jpg +3 -0
- examples/counter/counter_03.jpg +3 -0
- examples/counter/counter_04.jpg +3 -0
- examples/counter/counter_05.jpg +3 -0
- examples/counter/counter_06.jpg +3 -0
- examples/counter/counter_07.jpg +3 -0
- examples/counter/counter_08.jpg +3 -0
- examples/counter/counter_09.jpg +3 -0
- examples/counter/counter_10.jpg +3 -0
- examples/counter/counter_11.jpg +3 -0
- examples/counter/counter_12.jpg +3 -0
- examples/counter/counter_13.jpg +3 -0
- examples/garden/garden_00.jpg +3 -0
- examples/garden/garden_01.jpg +3 -0
- examples/garden/garden_02.jpg +3 -0
- examples/garden/garden_03.jpg +3 -0
- examples/garden/garden_04.jpg +3 -0
- examples/garden/garden_05.jpg +3 -0
- examples/garden/garden_06.jpg +3 -0
- examples/garden/garden_07.jpg +3 -0
- examples/garden/garden_08.jpg +3 -0
- examples/garden/garden_09.jpg +3 -0
- examples/garden/garden_10.jpg +3 -0
- examples/garden/garden_11.jpg +3 -0
- examples/garden/garden_12.jpg +3 -0
- examples/garden/garden_13.jpg +3 -0
- examples/kitchen/kitchen_00.jpg +3 -0
- examples/kitchen/kitchen_01.jpg +3 -0
- examples/kitchen/kitchen_02.jpg +3 -0
- examples/kitchen/kitchen_03.jpg +3 -0
- examples/kitchen/kitchen_04.jpg +3 -0
- examples/kitchen/kitchen_05.jpg +3 -0
- examples/kitchen/kitchen_06.jpg +3 -0
- examples/kitchen/kitchen_07.jpg +3 -0
- examples/kitchen/kitchen_08.jpg +3 -0
- examples/kitchen/kitchen_09.jpg +3 -0
- examples/kitchen/kitchen_10.jpg +3 -0
- examples/kitchen/kitchen_11.jpg +3 -0
- examples/kitchen/kitchen_12.jpg +3 -0
- examples/kitchen/kitchen_13.jpg +3 -0
- requirements.txt +16 -13
- 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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
*
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 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
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
os.environ
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
CUDA
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
|
|
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
# ---------------------------------------------------------------------------
|
| 53 |
-
#
|
| 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
|
| 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 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
print("[setup] downloading checkpoint...", flush=True)
|
| 74 |
-
|
| 75 |
print("[setup] building CodecSplat...", flush=True)
|
| 76 |
-
ENCODER =
|
| 77 |
-
|
|
|
|
| 78 |
print("[setup] model ready", flush=True)
|
| 79 |
-
_nvidia_fds("after model build")
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
return (t.clamp(0, 1).permute(1, 2, 0).float().cpu().numpy() * 255).round().astype(np.uint8)
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
-
#
|
| 88 |
# ---------------------------------------------------------------------------
|
| 89 |
-
@spaces.GPU(duration=
|
| 90 |
-
def
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
h, w = batch["context"]["image"].shape[-2:]
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
torch.cuda.synchronize()
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
"
|
| 125 |
-
"
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 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 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 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:
|
| 165 |
-
lmb:
|
| 166 |
-
matcher: COLMAP matching
|
| 167 |
|
| 168 |
Returns:
|
| 169 |
-
Interactive
|
| 170 |
-
|
| 171 |
"""
|
| 172 |
if not images:
|
| 173 |
-
raise gr.Error("Please upload at least 3 overlapping
|
| 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
|
|
|
|
| 188 |
try:
|
| 189 |
-
sfm = run_sfm(paths, workdir, max_image_size=
|
| 190 |
log=lambda m: print(m, flush=True))
|
| 191 |
-
except
|
| 192 |
raise gr.Error(str(exc)) from exc
|
| 193 |
-
t_sfm = time.time() -
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 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"
|
| 220 |
-
f"{
|
| 221 |
-
f"(
|
| 222 |
-
f"Held-out
|
| 223 |
-
f"
|
| 224 |
-
f"
|
| 225 |
-
f"total {time.time() - t_start:.1f}s"
|
| 226 |
)
|
| 227 |
-
return viewer_ply,
|
| 228 |
|
| 229 |
|
| 230 |
# ---------------------------------------------------------------------------
|
| 231 |
-
#
|
| 232 |
# ---------------------------------------------------------------------------
|
| 233 |
-
|
| 234 |
-
EXAMPLES = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
#
|
|
|
|
|
|
|
| 238 |
|
| 239 |
-
|
| 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 |
-
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 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
|
| 259 |
file_count="multiple", file_types=["image"], type="filepath",
|
| 260 |
)
|
| 261 |
-
lmb = gr.Slider(16, 1024, value=1024, step=1, label="Rate λ",
|
| 262 |
-
info="
|
| 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 |
-
"
|
| 270 |
-
"
|
| 271 |
-
"
|
| 272 |
"**Viewer**: drag to orbit, right-drag to pan, scroll to zoom."
|
| 273 |
)
|
| 274 |
with gr.Column(scale=2):
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
outputs = [
|
| 286 |
run_btn.click(reconstruct, inputs=[image_files, lmb, matcher], outputs=outputs,
|
| 287 |
api_name="reconstruct")
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 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
|
| 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,
|
| 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
|
| 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
|
| 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
|
examples/counter/counter_01.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_02.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_03.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_04.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_05.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_06.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_07.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_08.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_09.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_10.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_11.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_12.jpg
ADDED
|
Git LFS Details
|
examples/counter/counter_13.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_00.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_01.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_02.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_03.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_04.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_05.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_06.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_07.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_08.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_09.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_10.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_11.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_12.jpg
ADDED
|
Git LFS Details
|
examples/garden/garden_13.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_00.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_01.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_02.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_03.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_04.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_05.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_06.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_07.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_08.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_09.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_10.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_11.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_12.jpg
ADDED
|
Git LFS Details
|
examples/kitchen/kitchen_13.jpg
ADDED
|
Git LFS Details
|
requirements.txt
CHANGED
|
@@ -1,24 +1,27 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 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 |
-
|
| 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()
|