multimodalart HF Staff commited on
Commit
4f87ab3
·
verified ·
1 Parent(s): fd5f43b

Lift4D stages 1+2 on ZeroGPU: splat viewer + orbit video

Browse files
.gitattributes CHANGED
@@ -42,3 +42,6 @@ static/orbits/rhino.mp4 filter=lfs diff=lfs merge=lfs -text
42
  static/orbits/robot.mp4 filter=lfs diff=lfs merge=lfs -text
43
  static/orbits/yarn.mp4 filter=lfs diff=lfs merge=lfs -text
44
  static/videos/rhino_sam3d_orbit.mp4 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
42
  static/orbits/robot.mp4 filter=lfs diff=lfs merge=lfs -text
43
  static/orbits/yarn.mp4 filter=lfs diff=lfs merge=lfs -text
44
  static/videos/rhino_sam3d_orbit.mp4 filter=lfs diff=lfs merge=lfs -text
45
+ examples/horse_running.mp4 filter=lfs diff=lfs merge=lfs -text
46
+ examples/poodle_running.mp4 filter=lfs diff=lfs merge=lfs -text
47
+ examples/squirrel_eating.mp4 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,45 +1,61 @@
1
  ---
2
- title: Lift4D 4D Reconstruction
3
- emoji: ⚡️
4
  colorFrom: gray
5
  colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.22.0
8
- app_file: app.py
9
  python_version: "3.12"
10
- short_description: Interactive 4D Gaussian-splat gallery for Lift4D
11
  pinned: false
 
 
 
12
  ---
13
 
14
- # Lift4D — 4D Reconstruction In-the-Wild
15
-
16
- Interactive gallery for [**Lift4D**](https://github.com/yehonathanlitman/Lift4D)
17
- (Litman et al., CMU), which recovers complete, temporally-consistent **4D
18
- Gaussian-splat assets** from single in-the-wild videos.
19
-
20
- Each scene is shown in **two viewers**:
21
-
22
- - **🟢 Gaussian-splat viewer** — the authors' own client-side WebGL 4D splat
23
- player, rendering the genuine per-scene deformable Gaussians (canonical splats
24
- + per-frame deformation nodes). Fully interactive: drag to orbit, scroll to
25
- zoom, scrub the timestep.
26
- - **🎞️ Orbit-video viewer** — a turntable render of the deformed 4D Gaussians
27
- (4 azimuths), baked offline from the same reconstruction (mirroring the
28
- pipeline's own `save_orbit_video`). The `rhino` scene additionally exposes the
29
- authors' official multi-stage orbit videos.
30
-
31
- The featured scene is **`goat`** — the in-the-wild custom clip documented in the
32
- repo README's custom-dataset path (`data/custom/goat`, mask `goat_1`).
33
-
34
- ## Why a gallery, not live inference?
35
-
36
- Lift4D's full pipeline is a three-stage optimization — SAM 3 segmentation →
37
- Causal SAM3D per-frame 3D → Lift4D SC-GS training with Zero123 SDS guidance,
38
- thousands of iterations, tested on an A100 40GB — that needs gated weights and
39
- takes minutes-to-hours per asset. That cannot run inside a per-request GPU
40
- budget, so this Space serves the authors' **actual released 4D reconstructions**
41
- instead of running the pipeline live.
42
-
43
- Assets and viewer code are the authors' own (project page `lift4d.github.io`);
44
- the orbit videos are baked from the released splat params with a faithful port
45
- of the viewer's two-skinning deformation math.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Lift4D
3
+ emoji: 🐐
4
  colorFrom: gray
5
  colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.22.0
 
8
  python_version: "3.12"
9
+ app_file: app.py
10
  pinned: false
11
+ license: apache-2.0
12
+ short_description: Text-prompted 4D reconstruction from a casual video
13
+ startup_duration_timeout: 1h
14
  ---
15
 
16
+ # Lift4D
17
+
18
+ Front half of [**Lift4D**](https://github.com/yehonathanlitman/Lift4D) (CMU),
19
+ run end to end on ZeroGPU. Give it a monocular clip and a text prompt naming
20
+ the object, and it produces a **posed 3D Gaussian splat per video frame**
21
+ plus a rendered **orbit video** of the reconstructed sequence.
22
+
23
+ ## What runs
24
+
25
+ The Space follows the *custom dataset* workflow from the Lift4D README, and
26
+ materialises exactly that on-disk layout for each request:
27
+
28
+ ```
29
+ data/custom/<clip>/frames/frame_%06d.jpg
30
+ data/custom/<clip>/masks/frame_%06d_<prompt>_1.png
31
+ sam3d_output/custom_<clip>_<prompt>_1/<frame>/result.ply
32
+ sam3d_output/custom_<clip>_<prompt>_1/<frame>/obj_transform.json
33
+ ```
34
+
35
+ | Stage | Upstream entry point | Here |
36
+ |---|---|---|
37
+ | 1 | `segment_video.py` | frames extracted with OpenCV, SAM 3 text-prompt segmentation, masks written as `<stem>_<prompt>_1.png` |
38
+ | 2 | `sam3d/run_inference.py --dataset custom` | resolved through the repo's own `lift4d_datasets` registry, then `InferencePipelinePointMap.run_video_inference(...)` with bi-directional SDEdit warm-starting |
39
+ | 3 | `lift4d_scgs/train_lift4d_scgs.py` | **not run** — 10k–30k SDS optimisation steps, hours on an A100 |
40
+
41
+ ## Deviations from the reference implementation
42
+
43
+ * **Stage 3 is out of scope.** The Lift4D SC-GS optimisation (Zero123 SDS,
44
+ 10k + 20k iterations) cannot fit in a ZeroGPU call. What you see is the
45
+ per-frame causal reconstruction that stage 3 consumes.
46
+ * **SAM 3 segmentation is per-frame** (`transformers` `Sam3Model` concept
47
+ segmentation with greedy IoU matching between frames) instead of
48
+ `segment_video.py`'s SAM 3 video tracker. Same result for the
49
+ single-object clips this demo targets.
50
+ * **Depth Anything 3 pointmap alignment** and **silhouette pose refinement**
51
+ — both optional flags of `run_inference.py` — are off.
52
+ * Decoding is `["gaussian"]` only; mesh post-processing, texture baking and
53
+ layout optimisation are disabled (they need `nvdiffrast` / FlexiCubes).
54
+ * The orbit video is rendered with the upstream Inria 3DGS rasterizer
55
+ instead of `gsplat`, which has no sm_120 kernels. The object→camera
56
+ placement is taken verbatim from `run_inference.py:render_gs_frame`.
57
+
58
+ ## Notes
59
+
60
+ Both weight repos are gated: this Space needs an `HF_TOKEN` secret with
61
+ access to `facebook/sam-3d-objects` and `facebook/sam3`.
app.py CHANGED
@@ -1,182 +1,308 @@
1
- """
2
- Lift4D — interactive 4D reconstruction gallery.
3
-
4
- Lift4D (Litman et al., CMU) recovers complete 4D asset reconstructions from
5
- in-the-wild monocular videos through a 3-stage pipeline:
6
-
7
- video ─▶ 1. Segment (SAM 3) ─▶ 2. Causal SAM3D (per-frame 3D)
8
- ─▶ 3. Lift4D SC-GS training (fuse into a deformable Gaussian model) ─▶ 4D asset
9
-
10
- The full pipeline is a multi-stage optimization (SAM3 + SAM3D + SC-GS with SDS
11
- guidance, thousands of iterations, tested on an A100 40GB) that takes minutes-to-
12
- hours per asset and needs gated weights — it cannot run inside a per-request GPU
13
- budget. This Space instead showcases the authors' *actual* released 4D
14
- reconstructions in two dedicated viewers:
15
-
16
- • Gaussian-splat viewer — the authors' own client-side WebGL 4D splat player
17
- rendering the genuine per-scene deformable Gaussians (canonical splats +
18
- per-frame deformation nodes), fully interactive (orbit / zoom / scrub time).
19
- • Orbit-video viewer — turntable renders of the deformed 4D Gaussians, one
20
- orbit per scene (mirroring the pipeline's own `save_orbit_video`), plus the
21
- authors' official multi-stage orbit videos for the DAVIS `rhino` scene.
22
-
23
- The featured scene is `goat` — the in-the-wild custom clip documented in the
24
- repo README's custom-dataset path (data/custom/goat, mask goat_1).
25
-
26
- Serving: no GPU is used. Static assets (viewer HTML/JS, per-scene splat params,
27
- orbit + official videos) live under ./static and are served through Gradio 6's
28
- native static file endpoint (/gradio_api/file=...); the WebGL viewer is embedded
29
- in an iframe and fetches its assets relative to its own served URL.
30
  """
31
  import os
32
- import gradio as gr
33
-
34
- HERE = os.path.dirname(os.path.abspath(__file__))
35
- STATIC = os.path.join(HERE, "static")
36
-
37
- # Expose the static asset tree to Gradio's file endpoint.
38
- gr.set_static_paths(paths=[STATIC])
39
-
40
- FILE = "/gradio_api/file=" # Gradio static-serve prefix
41
- VIEWER_URL = FILE + "static/viewer4d.html"
42
- PARAMS_URL = FILE + "static/params"
43
-
44
- # Scene catalogue: (name, dataset tag). goat first — the README custom-dataset path.
45
- SCENES = [
46
- ("goat", "custom (README path)"),
47
- ("rhino", "DAVIS"),
48
- ("horsejump-low", "DAVIS"),
49
- ("ball", "in-the-wild"),
50
- ("bicycle", "in-the-wild"),
51
- ("bird", "in-the-wild"),
52
- ("robot", "in-the-wild"),
53
- ("yarn", "in-the-wild"),
54
- ]
55
- SCENE_NAMES = [s for s, _ in SCENES]
56
- SCENE_LABELS = {s: f"{s} · {tag}" for s, tag in SCENES}
57
- DEFAULT_SCENE = "goat"
58
-
59
- # Authors' official multi-stage orbit videos (DAVIS rhino) — bundled locally.
60
- RHINO_OFFICIAL = [
61
- ("Complete 4D reconstruction", "static/videos/rhino_complete.mp4"),
62
- ("Per-frame SAM3D orbit", "static/videos/rhino_sam3d_orbit.mp4"),
63
- ("Canonical Gaussians", "static/videos/rhino_canonical.mp4"),
64
- ("Deformation nodes", "static/videos/rhino_nodes.mp4"),
65
- ("Fine appearance nodes (delta)", "static/videos/rhino_delta.mp4"),
66
- ]
67
-
68
-
69
- def _viewer_iframe(scene: str) -> str:
70
- """HTML embed of the client-side WebGL 4D Gaussian-splat player for a scene."""
71
- src = f"{VIEWER_URL}?embed=1&v=3&scene={scene}&params={PARAMS_URL}"
72
- return (
73
- f'<iframe src="{src}" title="Lift4D 4D Gaussian-splat viewer" '
74
- 'style="width:100%;height:560px;border:0;border-radius:12px;'
75
- 'background:#0d0f12;" allow="fullscreen"></iframe>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  )
 
77
 
78
 
79
- def _orbit_path(scene: str):
80
- p = os.path.join(HERE, "static", "orbits", f"{scene}.mp4")
81
- return p if os.path.exists(p) else None
82
-
83
-
84
- def load_scene(scene: str):
85
- """Load a scene into both viewers (Gaussian-splat player + orbit video).
86
-
87
- Args:
88
- scene: scene name, one of the catalogue keys (e.g. "goat", "rhino").
89
-
90
- Returns:
91
- A tuple of (splat_viewer_html, orbit_video_path).
92
- """
93
- if scene not in SCENE_NAMES:
94
- scene = DEFAULT_SCENE
95
- return _viewer_iframe(scene), _orbit_path(scene)
96
 
97
 
98
  CSS = """
99
- #col-container { max-width: 1180px; margin: 0 auto; }
100
- .dark .gradio-container { color: var(--body-text-color); }
101
  """
102
 
103
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Lift4D 4D Reconstruction") as demo:
104
- with gr.Column(elem_id="col-container"):
105
- gr.Markdown(
106
- """
107
- # ⚡️ Lift4D — 4D Reconstruction In-the-Wild
108
- **Harmonizing Single-View 3D Estimation for 4D Reconstruction In-the-Wild** ·
109
- Litman, Ma, Shah, Ugrinovic, Kitani, De la Torre, Tulsiani (CMU)
110
-
111
- Lift4D recovers a complete, temporally-consistent **4D Gaussian-splat asset**
112
- from a single in-the-wild video: segment (SAM 3) → per-frame 3D (Causal SAM3D)
113
- → fuse into canonical Gaussians + learned deformation nodes (SC-GS + SDS).
114
-
115
- Pick a scene to explore its released reconstruction in **two viewers**: an
116
- interactive **Gaussian-splat player** and a rendered **orbit video**.
117
-
118
- [Project page](https://lift4d.github.io) ·
119
- [arXiv](https://arxiv.org/abs/2606.23688) ·
120
- [Code](https://github.com/yehonathanlitman/Lift4D)
121
-
122
- > ℹ️ The full pipeline (gated SAM3/SAM3D/Zero123 weights, thousands of SC-GS
123
- > optimization iterations on an A100) can't run per-request, so this Space
124
- > serves the authors' **actual released 4D reconstructions**, not live inference.
125
- """
126
- )
127
-
128
- scene = gr.Radio(
129
- choices=[(SCENE_LABELS[s], s) for s in SCENE_NAMES],
130
- value=DEFAULT_SCENE,
131
- label="Scene",
132
- info="goat is the in-the-wild custom-dataset clip from the repo README.",
133
- )
134
-
135
- with gr.Row(equal_height=False):
136
- with gr.Column():
137
- gr.Markdown(
138
- "### 🟢 Gaussian-splat viewer\n"
139
- "Interactive 4D splats — drag to orbit, scroll to zoom, scrub time."
140
- )
141
- splat_viewer = gr.HTML(_viewer_iframe(DEFAULT_SCENE))
142
- with gr.Column():
143
- gr.Markdown(
144
- "### 🎞️ Orbit-video viewer\n"
145
- "Turntable render of the deformed 4D Gaussians (4 azimuths)."
146
- )
147
- orbit_video = gr.Video(
148
- value=_orbit_path(DEFAULT_SCENE),
149
- label="Orbit render",
150
- autoplay=True,
151
- loop=True,
152
- height=360,
153
- )
154
-
155
- with gr.Accordion("Official multi-stage orbit videos (DAVIS rhino)", open=False):
156
- gr.Markdown(
157
- "The authors' released renders showing each pipeline stage of the "
158
- "`rhino` reconstruction."
159
- )
160
  with gr.Row():
161
- for _label, _path in RHINO_OFFICIAL:
162
- gr.Video(value=_path, label=_label, autoplay=False, loop=True, height=220)
163
-
164
- gr.Examples(
165
- examples=[[s] for s in SCENE_NAMES],
166
- inputs=[scene],
167
- outputs=[splat_viewer, orbit_video],
168
- fn=load_scene,
169
- cache_examples=False,
170
- run_on_click=True,
171
- label="Scenes",
172
- )
173
-
174
- scene.change(
175
- fn=load_scene,
176
- inputs=[scene],
177
- outputs=[splat_viewer, orbit_video],
178
- api_name="load_scene",
179
- )
180
-
181
- if __name__ == "__main__":
182
- demo.launch(mcp_server=True)
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lift4D — text-prompted 4D reconstruction from a monocular video.
2
+
3
+ Runs the first two stages of the Lift4D pipeline
4
+ (https://github.com/yehonathanlitman/Lift4D) through the *custom dataset*
5
+ workflow documented in the repo README:
6
+
7
+ stage 1 segment_video.py SAM 3 text prompt -> per-frame masks
8
+ stage 2 sam3d/run_inference.py Causal SAM 3D Objects -> per-frame 3DGS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
  import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+
18
+ os.environ.setdefault("CUDA_HOME", "/usr/local/cuda")
19
+ os.environ.setdefault("CONDA_PREFIX", "/usr/local")
20
+ os.environ["LIDRA_SKIP_INIT"] = "true"
21
+ os.environ["ATTN_BACKEND"] = "sdpa"
22
+ os.environ["SPARSE_ATTN_BACKEND"] = "sdpa"
23
+ os.environ["SPARSE_BACKEND"] = "spconv"
24
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
25
+
26
+ import spaces # noqa: E402 (must precede torch)
27
+ import gradio as gr # noqa: E402
28
+ import numpy as np # noqa: E402
29
+ from huggingface_hub import login, snapshot_download # noqa: E402
30
+
31
+ if os.environ.get("HF_TOKEN"):
32
+ login(token=os.environ["HF_TOKEN"])
33
+
34
+ APP_ROOT = Path(__file__).parent
35
+ sys.path.insert(0, str(APP_ROOT / "kaolin_stub"))
36
+
37
+ LIFT4D_COMMIT = "aa295d66d1baabc6f9954b619f8b1bdb557bd862"
38
+ WORK = Path("/tmp/lift4d")
39
+ DATA_ROOT = WORK / "data"
40
+ SAM3D_OUT = WORK / "sam3d_output"
41
+ for d in (DATA_ROOT / "custom", SAM3D_OUT):
42
+ d.mkdir(parents=True, exist_ok=True)
43
+ os.environ["LIFT4D_DATA_ROOT"] = str(DATA_ROOT)
44
+ os.environ["LIFT4D_SAM3D_OUT"] = str(SAM3D_OUT)
45
+
46
+
47
+ def _pip(*args):
48
+ r = subprocess.run([sys.executable, "-m", "pip", "install", "--no-cache-dir", *args],
49
+ capture_output=True, text=True, timeout=1800)
50
+ print(f" pip {'OK' if r.returncode == 0 else 'FAIL'}: {args[-1][:80]}", flush=True)
51
+ if r.returncode != 0:
52
+ print(r.stderr[-1500:], flush=True)
53
+ return r.returncode == 0
54
+
55
+
56
+ print("=== runtime installs (need torch present) ===", flush=True)
57
+ # utils3d pinned to the commit MoGe expects; MoGe supplies the geometry helpers
58
+ # sam3d_objects.pipeline.utils.pointmap imports directly.
59
+ _pip("--no-deps", "git+https://github.com/EasternJournalist/utils3d.git@3913c65d81e05e47b9f367250cf8c0f7462a0900")
60
+ _pip("--no-deps", "git+https://github.com/microsoft/MoGe.git@a8c37341bc0325ca99b9d57981cc3bb2bd3e255b")
61
+ # gaussian_render.py imports gsplat at module scope; only its python side is
62
+ # touched (rendering goes through the Inria rasterizer / pytorch3d).
63
+ _pip("--no-deps", "gsplat")
64
+
65
+ LIFT4D_DIR = APP_ROOT / "Lift4D"
66
+ if not LIFT4D_DIR.exists():
67
+ print("Cloning Lift4D...", flush=True)
68
+ subprocess.run(["git", "clone", "https://github.com/yehonathanlitman/Lift4D.git",
69
+ str(LIFT4D_DIR)], check=True)
70
+ subprocess.run(["git", "checkout", "-q", LIFT4D_COMMIT], cwd=str(LIFT4D_DIR), check=True)
71
+
72
+ SAM3D_DIR = LIFT4D_DIR / "sam3d"
73
+ sys.path.insert(0, str(LIFT4D_DIR)) # lift4d_datasets
74
+ sys.path.insert(0, str(SAM3D_DIR)) # sam3d_objects
75
+ sys.path.insert(0, str(APP_ROOT))
76
+
77
+ print("Downloading SAM 3D Objects checkpoints (gated: facebook/sam-3d-objects)...", flush=True)
78
+ CKPT = Path(snapshot_download("facebook/sam-3d-objects", token=os.environ.get("HF_TOKEN")))
79
+ hf_ckpt = CKPT / "checkpoints"
80
+ local_ckpt = SAM3D_DIR / "checkpoints" / "hf"
81
+ if hf_ckpt.exists() and not local_ckpt.exists():
82
+ local_ckpt.parent.mkdir(parents=True, exist_ok=True)
83
+ local_ckpt.symlink_to(hf_ckpt)
84
+ CONFIG_PATH = str(local_ckpt / "pipeline.yaml")
85
+
86
+ print("Downloading SAM 3 (gated: facebook/sam3)...", flush=True)
87
+ try:
88
+ snapshot_download("facebook/sam3", token=os.environ.get("HF_TOKEN"),
89
+ allow_patterns=["*.json", "*.txt", "*.safetensors", "*.model", "*.bin"])
90
+ except Exception as exc: # pragma: no cover
91
+ print(f" sam3 prefetch failed ({exc}); will download on first run", flush=True)
92
+
93
+ print("=== startup complete ===", flush=True)
94
+
95
+ # Model construction needs a real GPU (the SAM3D constructors run device-mixing
96
+ # tensor ops that ZeroGPU's startup CUDA shim rejects), so both models are
97
+ # built inside @spaces.GPU and cached in the worker.
98
+ _SAM3 = None
99
+ _PIPE = None
100
+
101
+
102
+ def _get_sam3():
103
+ global _SAM3
104
+ if _SAM3 is None:
105
+ from transformers import Sam3Model, Sam3Processor
106
+ model = Sam3Model.from_pretrained("facebook/sam3").to("cuda").eval()
107
+ processor = Sam3Processor.from_pretrained("facebook/sam3")
108
+ _SAM3 = (model, processor)
109
+ return _SAM3
110
+
111
+
112
+ def _get_pipeline():
113
+ global _PIPE
114
+ if _PIPE is None:
115
+ import lift4d_stage12
116
+ _PIPE = lift4d_stage12.build_pipeline(CONFIG_PATH)
117
+ if getattr(_PIPE, "rendering_engine", "pytorch3d") != "pytorch3d":
118
+ _PIPE.rendering_engine = "pytorch3d"
119
+ return _PIPE
120
+
121
+
122
+ MAX_FRAMES = 16
123
+
124
+
125
+ def _estimate(*args, **kwargs):
126
+ n = 8
127
+ if len(args) > 2:
128
+ try:
129
+ n = int(args[2])
130
+ except (TypeError, ValueError):
131
+ pass
132
+ return int(min(420, 150 + 30 * n))
133
+
134
+
135
+ @spaces.GPU(duration=_estimate)
136
+ def reconstruct(video, prompt="", num_frames=8, consistency=0.2, seed=42,
137
+ stage1_steps=50, stage2_steps=25, orbit_steps=48,
138
+ progress=gr.Progress(track_tqdm=True)):
139
+ import torch
140
+ import lift4d_datasets as ds_registry
141
+ import lift4d_stage12 as stage12
142
+ import orbit_render
143
+ from PIL import Image
144
+
145
+ if video is None:
146
+ raise gr.Error("Please upload a video.")
147
+ if not (prompt or "").strip():
148
+ raise gr.Error("Please give a text prompt naming the object to reconstruct.")
149
+
150
+ t0 = time.time()
151
+ uid = f"clip_{uuid.uuid4().hex[:8]}"
152
+ video_dir = DATA_ROOT / "custom" / uid
153
+ frames_dir = video_dir / "frames"
154
+ masks_dir = video_dir / "masks"
155
+
156
+ # ---- stage 1: frames + SAM 3 masks in the README's custom layout --------
157
+ progress(0.02, desc="Extracting frames")
158
+ stems = stage12.extract_frames(video, frames_dir, int(num_frames))
159
+
160
+ model, processor = _get_sam3()
161
+ mask_name, masks = stage12.segment_frames(
162
+ frames_dir, masks_dir, stems, prompt, model, processor,
163
+ progress_cb=lambda f, d: progress(0.02 + 0.13 * f, desc=d))
164
+
165
+ preview_dir = video_dir / "preview"
166
+ preview_dir.mkdir(exist_ok=True)
167
+ gallery = []
168
+ for stem, m in zip(stems, masks):
169
+ rgb = np.asarray(Image.open(frames_dir / f"{stem}.jpg").convert("RGB"))
170
+ cut = (rgb * m[..., None] + 255 * (~m[..., None])).astype(np.uint8)
171
+ p = preview_dir / f"{stem}.jpg"
172
+ Image.fromarray(cut).save(p, quality=90)
173
+ gallery.append(str(p))
174
+
175
+ # ---- stage 2: causal SAM 3D Objects reconstruction ----------------------
176
+ progress(0.18, desc="Loading SAM 3D Objects")
177
+ pipe = _get_pipeline()
178
+ spec = ds_registry.resolve("custom", uid, mask_name)
179
+
180
+ progress(0.22, desc=f"Reconstructing {len(stems)} frames (causal SAM 3D)")
181
+ saved, frame_images, out_base = stage12.run_stage2(
182
+ pipe, spec, stems, mask_name,
183
+ seed=int(seed), stage1_steps=int(stage1_steps), stage2_steps=int(stage2_steps),
184
+ initial_frame_index=0, consistency_strength=float(consistency))
185
+ t_recon = time.time() - t0
186
+
187
+ # ---- placement, splat export, orbit render ------------------------------
188
+ progress(0.85, desc="Exporting Gaussian splats")
189
+ cam_frames, ply_paths = [], []
190
+ centers = []
191
+ for rec in saved:
192
+ gs = rec["gs"]
193
+ if gs is None:
194
+ continue
195
+ packed = orbit_render.to_camera_space(
196
+ gs, rec["scale"], rec["translation"], rec["rotation"], device="cuda")
197
+ cam_frames.append(packed)
198
+ centers.append(packed[0].median(dim=0).values)
199
+
200
+ if not cam_frames:
201
+ raise gr.Error("SAM 3D Objects returned no Gaussians for this clip.")
202
+
203
+ center = torch.stack(centers).mean(dim=0).cpu().numpy()
204
+ splat_dir = out_base / "splats"
205
+ splat_dir.mkdir(exist_ok=True)
206
+ for rec, packed in zip(saved, cam_frames):
207
+ p = splat_dir / f"{rec['label']}.ply"
208
+ orbit_render.write_viewer_ply(p, *packed, center=center)
209
+ ply_paths.append(str(p))
210
+
211
+ progress(0.92, desc="Rendering orbit video")
212
+ imgs = orbit_render.render_orbit(cam_frames, size=512,
213
+ orbit_steps=int(orbit_steps), device="cuda")
214
+ orbit_path = out_base / "orbit.mp4"
215
+ orbit_render.save_video(imgs, orbit_path, fps=12)
216
+
217
+ total = time.time() - t0
218
+ status = (
219
+ f"**Done in {total:.0f}s** — {len(saved)} frames reconstructed "
220
+ f"({t_recon:.0f}s in SAM 3D).\n\n"
221
+ f"Custom dataset: `data/custom/{uid}/frames` + "
222
+ f"`data/custom/{uid}/masks/<frame>_{mask_name}.png` → "
223
+ f"stage-1 output tag `{spec.tag}`."
224
  )
225
+ return str(orbit_path), ply_paths[0], gallery, status, str(splat_dir)
226
 
227
 
228
+ def show_frame(splat_dir, idx):
229
+ """Swap the Gaussian-splat viewer to another reconstructed frame (CPU)."""
230
+ if not splat_dir:
231
+ return gr.update()
232
+ plys = sorted(Path(splat_dir).glob("*.ply"))
233
+ if not plys:
234
+ return gr.update()
235
+ i = max(0, min(int(idx), len(plys) - 1))
236
+ return gr.update(value=str(plys[i]))
 
 
 
 
 
 
 
 
237
 
238
 
239
  CSS = """
240
+ .dark .gradio-container { background: #0b0b0d; }
241
+ #viewers .wrap { min-height: 420px; }
242
  """
243
 
244
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Lift4D") as demo:
245
+ gr.Markdown(
246
+ "# Lift4D — 4D reconstruction from a casual video\n"
247
+ "Name an object in your clip and Lift4D's front half runs: **SAM 3** "
248
+ "segments it in every sampled frame, then **causal SAM 3D Objects** "
249
+ "reconstructs a posed Gaussian splat per frame with SDEdit temporal "
250
+ "warm-starting. Two viewers below: the per-frame **Gaussian splats** "
251
+ "and a rendered **orbit video** of the reconstructed sequence."
252
+ )
253
+
254
+ with gr.Row():
255
+ with gr.Column(scale=1):
256
+ video = gr.Video(label="Input video", height=280)
257
+ prompt = gr.Textbox(label="Object prompt", placeholder="e.g. goat",
258
+ value="")
259
+ num_frames = gr.Slider(3, MAX_FRAMES, value=8, step=1,
260
+ label="Frames to reconstruct",
261
+ info="Sampled evenly across the clip. More frames = smoother 4D, longer run.")
262
+ run = gr.Button("Reconstruct", variant="primary")
263
+ with gr.Accordion("Advanced", open=False):
264
+ consistency = gr.Slider(0.0, 1.0, value=0.2, step=0.05,
265
+ label="Temporal consistency (SDEdit strength)",
266
+ info="Higher preserves more structure between frames; lower allows more deformation.")
267
+ seed = gr.Number(value=42, precision=0, label="Seed")
268
+ stage1_steps = gr.Slider(10, 50, value=50, step=5,
269
+ label="Sparse-structure steps")
270
+ stage2_steps = gr.Slider(10, 50, value=25, step=5,
271
+ label="Structured-latent steps")
272
+ orbit_steps = gr.Slider(16, 96, value=48, step=8,
273
+ label="Orbit video frames")
274
+
275
+ with gr.Column(scale=2, elem_id="viewers"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  with gr.Row():
277
+ splat = gr.Model3D(label="Gaussian splats (per frame)",
278
+ height=400, clear_color=[0.05, 0.05, 0.06, 1.0])
279
+ orbit = gr.Video(label="Orbit video", height=400,
280
+ autoplay=True, loop=True)
281
+ frame_idx = gr.Slider(0, MAX_FRAMES - 1, value=0, step=1,
282
+ label="Splat viewer: frame")
283
+ status = gr.Markdown()
284
+ gallery = gr.Gallery(label="SAM 3 segmentation (stage 1)", columns=8,
285
+ height=130, object_fit="contain")
286
+ splat_dir = gr.Textbox(visible=False)
287
+
288
+ inputs = [video, prompt, num_frames, consistency, seed,
289
+ stage1_steps, stage2_steps, orbit_steps]
290
+ outputs = [orbit, splat, gallery, status, splat_dir]
291
+
292
+ run.click(reconstruct, inputs=inputs, outputs=outputs)
293
+ frame_idx.change(show_frame, inputs=[splat_dir, frame_idx], outputs=[splat])
294
+
295
+ gr.Examples(
296
+ examples=[
297
+ ["examples/horse_running.mp4", "horse"],
298
+ ["examples/poodle_running.mp4", "dog"],
299
+ ["examples/squirrel_eating.mp4", "squirrel"],
300
+ ],
301
+ inputs=[video, prompt],
302
+ outputs=outputs,
303
+ fn=reconstruct,
304
+ cache_examples=True,
305
+ cache_mode="lazy",
306
+ )
307
+
308
+ demo.queue(max_size=8).launch(mcp_server=True, show_error=True)
examples/horse_running.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a2ad29b7a01bb89d0a7255d5eccb389e049a4889fb39a05d6e5212c6f77dbd68
3
+ size 4755442
examples/poodle_running.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:586b3e8f9b2a775d51f199d132dabbe3d106968601e49bd066c41786898d23c3
3
+ size 4173175
examples/squirrel_eating.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d75addbe53e3780b202dd733a9fc0113b79d5cd10e7b33d42ca29c0ed46d2fb
3
+ size 2120607
kaolin_stub/kaolin/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Kaolin stub — flexicubes only needs utils.testing.check_tensor."""
kaolin_stub/kaolin/utils/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """kaolin.utils stub."""
2
+ from . import testing
kaolin_stub/kaolin/utils/testing.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """kaolin.utils.testing stub."""
2
+ def check_tensor(tensor, shape=None, dtype=None, device=None, throw=True):
3
+ """No-op tensor check."""
4
+ return True
lift4d_stage12.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lift4D stages 1 + 2, ported to a single ZeroGPU process.
2
+
3
+ This is a faithful port of the two CLI entry points documented in the Lift4D
4
+ README's *custom dataset* workflow:
5
+
6
+ stage 1 segment_video.py frames + text prompt -> per-frame masks
7
+ stage 2 sam3d/run_inference.py masks -> per-frame Gaussian reconstruction
8
+
9
+ Both stages write into (and read from) exactly the on-disk layout the README
10
+ specifies, via the repo's own ``lift4d_datasets`` registry:
11
+
12
+ $LIFT4D_DATA_ROOT/custom/<video>/frames/frame_%06d.jpg
13
+ $LIFT4D_DATA_ROOT/custom/<video>/masks/frame_%06d_<prompt>_<inst>.png
14
+ $LIFT4D_SAM3D_OUT/custom_<video>_<prompt>_<inst>/<frame_stem>/result.ply
15
+ $LIFT4D_SAM3D_OUT/custom_<video>_<prompt>_<inst>/<frame_stem>/obj_transform.json
16
+
17
+ Deviations from the upstream CLIs are listed in the Space README.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ from pathlib import Path
25
+ from typing import List, Optional, Tuple
26
+
27
+ import numpy as np
28
+
29
+
30
+ # --------------------------------------------------------------------------
31
+ # stage 1 -- SAM 3 text-prompted segmentation
32
+ # --------------------------------------------------------------------------
33
+
34
+ def clean_prompt(prompt: str) -> str:
35
+ """Match segment_video.py: spaces become underscores."""
36
+ cleaned = re.sub(r"\s+", "_", prompt.strip())
37
+ cleaned = re.sub(r"[^0-9A-Za-z_\-]", "", cleaned)
38
+ return cleaned or "object"
39
+
40
+
41
+ def extract_frames(video_path: str, frames_dir: Path, num_frames: int,
42
+ max_side: int = 720) -> List[str]:
43
+ """Evenly sample ``num_frames`` frames and write frame_%06d.jpg.
44
+
45
+ Mirrors segment_video.py's extract_frames_from_video (cv2, JPEG q95) but
46
+ samples evenly across the whole clip instead of taking a prefix, so a
47
+ short demo run still covers the full motion.
48
+ """
49
+ import cv2
50
+
51
+ frames_dir.mkdir(parents=True, exist_ok=True)
52
+ cap = cv2.VideoCapture(str(video_path))
53
+ if not cap.isOpened():
54
+ raise RuntimeError(f"Could not open video: {video_path}")
55
+
56
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
57
+ if total <= 0: # some containers do not report a frame count
58
+ total = 0
59
+ while cap.grab():
60
+ total += 1
61
+ cap.release()
62
+ cap = cv2.VideoCapture(str(video_path))
63
+ if total <= 0:
64
+ raise RuntimeError("Video appears to contain no frames.")
65
+
66
+ num_frames = max(2, min(int(num_frames), total))
67
+ wanted = sorted({int(round(i * (total - 1) / (num_frames - 1)))
68
+ for i in range(num_frames)})
69
+
70
+ stems: List[str] = []
71
+ idx = 0
72
+ want_ptr = 0
73
+ while want_ptr < len(wanted):
74
+ ok, frame = cap.read()
75
+ if not ok:
76
+ break
77
+ if idx == wanted[want_ptr]:
78
+ h, w = frame.shape[:2]
79
+ scale = max_side / float(max(h, w))
80
+ if scale < 1.0:
81
+ frame = cv2.resize(frame, (int(round(w * scale)), int(round(h * scale))),
82
+ interpolation=cv2.INTER_AREA)
83
+ stem = f"frame_{len(stems):06d}"
84
+ cv2.imwrite(str(frames_dir / f"{stem}.jpg"), frame,
85
+ [cv2.IMWRITE_JPEG_QUALITY, 95])
86
+ stems.append(stem)
87
+ want_ptr += 1
88
+ idx += 1
89
+ cap.release()
90
+
91
+ if len(stems) < 2:
92
+ raise RuntimeError("Could not decode at least two frames from the video.")
93
+ return stems
94
+
95
+
96
+ def _iou(a: np.ndarray, b: np.ndarray) -> float:
97
+ inter = np.logical_and(a, b).sum()
98
+ union = np.logical_or(a, b).sum()
99
+ return float(inter) / float(union) if union else 0.0
100
+
101
+
102
+ def segment_frames(frames_dir: Path, masks_dir: Path, stems: List[str],
103
+ prompt: str, model, processor, threshold: float = 0.4,
104
+ progress_cb=None) -> Tuple[str, List[np.ndarray]]:
105
+ """Stage 1: write `<stem>_<prompt>_1.png` masks for every frame.
106
+
107
+ segment_video.py runs SAM 3's video tracker to propagate the first frame's
108
+ instance. Here we run SAM 3 concept segmentation per frame (the API
109
+ exposed by `transformers`) and keep temporal identity by greedily matching
110
+ each frame's masks against the previous frame's mask by IoU -- the same
111
+ single-instance result for the single-object clips this demo targets.
112
+ """
113
+ import torch
114
+ from PIL import Image
115
+
116
+ masks_dir.mkdir(parents=True, exist_ok=True)
117
+ name = clean_prompt(prompt)
118
+ prev: Optional[np.ndarray] = None
119
+ masks: List[np.ndarray] = []
120
+
121
+ for i, stem in enumerate(stems):
122
+ if progress_cb is not None:
123
+ progress_cb(i / max(1, len(stems)), f"SAM 3 segmentation {i + 1}/{len(stems)}")
124
+ image = Image.open(frames_dir / f"{stem}.jpg").convert("RGB")
125
+ inputs = processor(images=image, text=prompt, return_tensors="pt").to(model.device)
126
+ with torch.inference_mode():
127
+ outputs = model(**inputs)
128
+ results = processor.post_process_instance_segmentation(
129
+ outputs,
130
+ threshold=threshold,
131
+ mask_threshold=0.5,
132
+ target_sizes=inputs.get("original_sizes").tolist(),
133
+ )[0]
134
+ cand = [np.asarray(m, dtype=bool) for m in
135
+ np.asarray(results["masks"].cpu().numpy() if hasattr(results["masks"], "cpu")
136
+ else results["masks"])] if len(results["masks"]) else []
137
+ scores = [float(s) for s in torch.as_tensor(results["scores"]).cpu().numpy()] if cand else []
138
+
139
+ chosen: Optional[np.ndarray] = None
140
+ if cand:
141
+ if prev is None:
142
+ chosen = cand[int(np.argmax(scores))]
143
+ else:
144
+ ious = [_iou(prev, m) for m in cand]
145
+ if max(ious) > 0.05:
146
+ chosen = cand[int(np.argmax(ious))]
147
+ else:
148
+ chosen = cand[int(np.argmax(scores))]
149
+ if chosen is None:
150
+ if prev is None:
151
+ raise RuntimeError(
152
+ f"SAM 3 found no '{prompt}' in the first sampled frame. "
153
+ "Try a different prompt or a different clip."
154
+ )
155
+ chosen = prev # keep the last good mask rather than dropping the frame
156
+
157
+ prev = chosen
158
+ masks.append(chosen)
159
+ Image.fromarray((chosen * 255).astype(np.uint8)).save(
160
+ masks_dir / f"{stem}_{name}_1.png")
161
+
162
+ return f"{name}_1", masks
163
+
164
+
165
+ # --------------------------------------------------------------------------
166
+ # stage 2 -- Causal SAM 3D Objects per-frame reconstruction
167
+ # --------------------------------------------------------------------------
168
+
169
+ def build_pipeline(config_path: str):
170
+ """Instantiate SAM3D's InferencePipelinePointMap.
171
+
172
+ Equivalent to Lift4D's ``sam3d/notebook/inference.py:Inference.__init__``
173
+ (same three config overrides) minus its module-level kaolin.visualize /
174
+ plotly / matplotlib imports, which are only used by notebook widgets.
175
+ """
176
+ from omegaconf import OmegaConf
177
+ from hydra.utils import instantiate
178
+
179
+ import sam3d_objects # noqa: F401 (guarded by LIDRA_SKIP_INIT)
180
+ # The attention modules snapshot ATTN_BACKEND/SPARSE_ATTN_BACKEND from the
181
+ # environment at import time, and inference_pipeline.set_attention_backend()
182
+ # rewrites them at *its* import time. Import the attention modules first so
183
+ # they stay on sdpa.
184
+ import sam3d_objects.model.backbone.tdfy_dit.modules.attention # noqa: F401
185
+ import sam3d_objects.model.backbone.tdfy_dit.modules.sparse # noqa: F401
186
+ from sam3d_objects.pipeline.inference_pipeline_pointmap import ( # noqa: F401
187
+ InferencePipelinePointMap,
188
+ )
189
+
190
+ config = OmegaConf.load(config_path)
191
+ config.rendering_engine = "pytorch3d" # disable nvdiffrast
192
+ config.compile_model = False
193
+ config.workspace_dir = os.path.dirname(config_path)
194
+ return instantiate(config)
195
+
196
+
197
+ def run_stage2(pipeline, spec, stems: List[str], mask_name: str,
198
+ seed: int = 42, stage1_steps: int = 50, stage2_steps: int = 25,
199
+ initial_frame_index: int = 0, consistency_strength: float = 0.2,
200
+ output_dir: Optional[str] = None):
201
+ """Port of ``run_video_inference_cli`` for ``--dataset custom``.
202
+
203
+ Same frame/mask resolution (MASK_SUFFIX -> ``<stem>_<mask_name>``), same
204
+ ``run_video_inference`` call, same per-frame ``result.ply`` /
205
+ ``obj_transform.json`` outputs under the registry's output dir.
206
+ DA3 pointmap alignment and silhouette pose refinement (both optional in
207
+ the CLI) are off.
208
+ """
209
+ from sam3d_objects.pipeline.video_utils import load_video_frames_and_masks
210
+
211
+ mask_names = [f"{s}_{mask_name}" for s in stems]
212
+ frame_images, frame_masks = load_video_frames_and_masks(
213
+ frames_dir=Path(spec.frames_dir),
214
+ masks_dir=Path(spec.masks_dir),
215
+ image_names=stems,
216
+ mask_names=mask_names,
217
+ )
218
+
219
+ output_base = Path(output_dir or spec.sam3d_output_dir())
220
+ output_base.mkdir(parents=True, exist_ok=True)
221
+
222
+ results = pipeline.run_video_inference(
223
+ frame_images=frame_images,
224
+ frame_masks=frame_masks,
225
+ seed=seed,
226
+ stage1_inference_steps=stage1_steps,
227
+ stage2_inference_steps=stage2_steps,
228
+ decode_formats=["gaussian"],
229
+ with_mesh_postprocess=False,
230
+ with_texture_baking=False,
231
+ use_vertex_color=True,
232
+ initial_frame_index=initial_frame_index,
233
+ consistency_strength=consistency_strength,
234
+ pointmaps=None,
235
+ pointmap_intrinsics=None,
236
+ )
237
+
238
+ def frame_focal(result):
239
+ fi = result["frame_idx"]
240
+ gt_h, gt_w = frame_images[fi].shape[:2]
241
+ intr = result.get("intrinsics", None)
242
+ if intr is not None:
243
+ f_px = float(np.asarray(intr.detach().cpu()).reshape(3, 3)[1, 1] * gt_h)
244
+ if f_px > 0:
245
+ return f_px
246
+ return max(gt_h, gt_w) * 1.2
247
+
248
+ saved = []
249
+ for result in results:
250
+ fi = result["frame_idx"]
251
+ frame_label = stems[fi] if 0 <= fi < len(stems) else f"frame_{fi:04d}"
252
+ frame_dir = output_base / frame_label
253
+ frame_dir.mkdir(parents=True, exist_ok=True)
254
+
255
+ gs = result.get("gs")
256
+ if gs is None:
257
+ gaussians = result.get("gaussian")
258
+ if isinstance(gaussians, list) and gaussians:
259
+ gs = gaussians[0]
260
+ if gs is not None:
261
+ gs.save_ply(str(frame_dir / "result.ply"))
262
+
263
+ obj_transforms = {}
264
+ for key in ("scale", "translation", "rotation"):
265
+ if key in result:
266
+ obj_transforms[key] = result[key].cpu().numpy().tolist()
267
+ obj_transforms["focal"] = frame_focal(result)
268
+ with open(frame_dir / "obj_transform.json", "w") as fh:
269
+ json.dump(obj_transforms, fh, indent=4)
270
+
271
+ saved.append({
272
+ "frame_idx": fi,
273
+ "label": frame_label,
274
+ "dir": str(frame_dir),
275
+ "gs": gs,
276
+ "scale": result.get("scale"),
277
+ "translation": result.get("translation"),
278
+ "rotation": result.get("rotation"),
279
+ "focal": obj_transforms["focal"],
280
+ "size": frame_images[fi].shape[:2],
281
+ })
282
+
283
+ saved.sort(key=lambda r: r["frame_idx"])
284
+ return saved, frame_images, output_base
orbit_render.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camera-space placement, 3DGS PLY export and orbit rendering for Lift4D.
2
+
3
+ The object->camera convention is taken verbatim from Lift4D's
4
+ ``sam3d/run_inference.py:render_gs_frame``:
5
+
6
+ p_p3d = compose_transform(scale, quaternion_to_matrix(q_l2c), trans)
7
+ .transform_points(p_local) # PyTorch3D row-vector
8
+ p_cam = p_p3d @ diag(-1, -1, 1)
9
+
10
+ PyTorch3D's ``Transform3d.rotate(R)`` applies ``p_row @ R``, i.e. R^T in the
11
+ usual column-vector sense -- which is why run_inference composes the Gaussian
12
+ orientations with ``quaternion_invert(q_l2c)``. Written as one rigid map:
13
+
14
+ R_o2c = diag(-1,-1,1) @ R(q_l2c)^T
15
+ t_o2c = diag(-1,-1,1) @ t
16
+ p_cam = R_o2c @ (scale * p_local) + t_o2c
17
+
18
+ Camera space is the usual OpenCV/3DGS frame (+x right, +y down, +z forward).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import List
24
+
25
+ import numpy as np
26
+ import torch
27
+
28
+ SH_C0 = 0.28209479177387814
29
+
30
+
31
+ def _flip(device):
32
+ """diag(-1, -1, 1): PyTorch3D -> camera, as in run_inference.py."""
33
+ return torch.tensor([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]],
34
+ dtype=torch.float32, device=device)
35
+
36
+
37
+ def _mat_to_quat(R: torch.Tensor) -> torch.Tensor:
38
+ from pytorch3d.transforms import matrix_to_quaternion
39
+ return matrix_to_quaternion(R)
40
+
41
+
42
+ def to_camera_space(gs, scale_t, trans_t, rot_t, device="cuda"):
43
+ """Return (means, quats, scales, colors, opacities) in camera space."""
44
+ from pytorch3d.transforms import quaternion_to_matrix, quaternion_multiply
45
+
46
+ xyz = gs.get_xyz.to(device).float()
47
+ quats = gs.get_rotation.to(device).float()
48
+ scales = gs.get_scaling.to(device).float()
49
+ opac = gs.get_opacity.to(device).float().reshape(-1)
50
+ dc = gs._features_dc.to(device).float()
51
+ if dc.dim() == 3:
52
+ dc = dc.squeeze(1)
53
+ colors = torch.clamp(dc * SH_C0 + 0.5, 0.0, 1.0)
54
+
55
+ s = scale_t.to(device).float().reshape(-1)
56
+ if s.numel() == 1:
57
+ s = s.repeat(3)
58
+ t = trans_t.to(device).float().reshape(3)
59
+ q = rot_t.to(device).float().reshape(4)
60
+
61
+ M = _flip(device)
62
+ R_o2c = M @ quaternion_to_matrix(q).transpose(0, 1)
63
+ t_o2c = M @ t
64
+ q_o2c = _mat_to_quat(R_o2c)
65
+
66
+ means = (R_o2c @ (xyz * s).transpose(0, 1)).transpose(0, 1) + t_o2c
67
+ quats = quaternion_multiply(q_o2c.unsqueeze(0).expand(quats.shape[0], -1), quats)
68
+ scales = scales * s
69
+ return means, quats, scales, colors, opac
70
+
71
+
72
+ # --------------------------------------------------------------------------
73
+ # 3DGS PLY export for gr.Model3D
74
+ # --------------------------------------------------------------------------
75
+
76
+ # camera space (y down, z forward) -> viewer space (y up, z toward viewer)
77
+ _VIEW_FLIP = np.array([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]], dtype=np.float32)
78
+ _VIEW_QUAT = np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32) # wxyz, Rx(180 deg)
79
+
80
+
81
+ def _quat_mul_np(a, b):
82
+ aw, ax, ay, az = a
83
+ bw, bx, by, bz = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
84
+ return np.stack([
85
+ aw * bw - ax * bx - ay * by - az * bz,
86
+ aw * bx + ax * bw + ay * bz - az * by,
87
+ aw * by - ax * bz + ay * bw + az * bx,
88
+ aw * bz + ax * by - ay * bx + az * bw,
89
+ ], axis=-1)
90
+
91
+
92
+ def write_viewer_ply(path, means, quats, scales, colors, opacities, center=None):
93
+ """Write a standard 3DGS .ply that gr.Model3D / gsplat.js can display.
94
+
95
+ Gaussians come in camera space; they are rotated into the viewer's y-up
96
+ frame and recentred so the object appears upright and framed.
97
+ """
98
+ m = means.detach().cpu().numpy().astype(np.float32)
99
+ q = quats.detach().cpu().numpy().astype(np.float32)
100
+ s = scales.detach().cpu().numpy().astype(np.float32)
101
+ c = colors.detach().cpu().numpy().astype(np.float32)
102
+ o = opacities.detach().cpu().numpy().astype(np.float32).reshape(-1)
103
+
104
+ if center is None:
105
+ center = np.median(m, axis=0)
106
+ m = (m - center) @ _VIEW_FLIP.T
107
+ q = _quat_mul_np(_VIEW_QUAT, q)
108
+
109
+ eps = 1e-6
110
+ f_dc = (c - 0.5) / SH_C0
111
+ opacity = np.log(np.clip(o, eps, 1 - eps) / (1 - np.clip(o, eps, 1 - eps)))
112
+ scale_log = np.log(np.clip(s, eps, None))
113
+ normals = np.zeros_like(m)
114
+
115
+ data = np.concatenate([m, normals, f_dc, opacity[:, None], scale_log, q], axis=1).astype(np.float32)
116
+
117
+ header = (
118
+ "ply\nformat binary_little_endian 1.0\n"
119
+ f"element vertex {data.shape[0]}\n"
120
+ "property float x\nproperty float y\nproperty float z\n"
121
+ "property float nx\nproperty float ny\nproperty float nz\n"
122
+ "property float f_dc_0\nproperty float f_dc_1\nproperty float f_dc_2\n"
123
+ "property float opacity\n"
124
+ "property float scale_0\nproperty float scale_1\nproperty float scale_2\n"
125
+ "property float rot_0\nproperty float rot_1\nproperty float rot_2\nproperty float rot_3\n"
126
+ "end_header\n"
127
+ )
128
+ with open(path, "wb") as fh:
129
+ fh.write(header.encode("ascii"))
130
+ fh.write(data.tobytes())
131
+ return str(path)
132
+
133
+
134
+ # --------------------------------------------------------------------------
135
+ # orbit rendering with the Inria 3DGS rasterizer
136
+ # --------------------------------------------------------------------------
137
+
138
+ def _proj_matrix(znear, zfar, fovx, fovy, device):
139
+ tx, ty = math.tan(fovx / 2), math.tan(fovy / 2)
140
+ P = torch.zeros(4, 4, dtype=torch.float32, device=device)
141
+ P[0, 0] = 1.0 / tx
142
+ P[1, 1] = 1.0 / ty
143
+ P[2, 2] = zfar / (zfar - znear)
144
+ P[2, 3] = -(zfar * znear) / (zfar - znear)
145
+ P[3, 2] = 1.0
146
+ return P
147
+
148
+
149
+ def _look_at(cam_pos, target, device):
150
+ """World->camera rotation/translation in the OpenCV (y-down) convention."""
151
+ f = target - cam_pos
152
+ f = f / torch.norm(f).clamp_min(1e-8)
153
+ up_hint = torch.tensor([0.0, -1.0, 0.0], device=device)
154
+ if torch.abs(torch.dot(f, up_hint)) > 0.999:
155
+ up_hint = torch.tensor([0.0, 0.0, 1.0], device=device)
156
+ r = torch.cross(f, up_hint, dim=0)
157
+ r = r / torch.norm(r).clamp_min(1e-8)
158
+ d = torch.cross(f, r, dim=0)
159
+ R_wc = torch.stack([r, d, f], dim=0)
160
+ t_wc = -R_wc @ cam_pos
161
+ return R_wc, t_wc
162
+
163
+
164
+ def render_orbit(frames, size=512, orbit_steps=48, fov_deg=42.0,
165
+ elevation_deg=12.0, bg=(1.0, 1.0, 1.0), device="cuda"):
166
+ """Render a 360-degree orbit that also plays the reconstructed sequence.
167
+
168
+ ``frames`` is a list of (means, quats, scales, colors, opacities) tuples in
169
+ camera space, one per reconstructed video frame. Step ``t`` of the orbit
170
+ shows the frame at ``t / orbit_steps`` of the sequence, so the video shows
171
+ both the object's motion and every side of the geometry.
172
+ """
173
+ from diff_gaussian_rasterization import (GaussianRasterizationSettings,
174
+ GaussianRasterizer)
175
+ from pytorch3d.transforms import matrix_to_quaternion, quaternion_multiply
176
+
177
+ all_means = torch.cat([f[0] for f in frames], dim=0)
178
+ center = all_means.median(dim=0).values
179
+ rad = torch.quantile((all_means - center).norm(dim=1), 0.97).clamp_min(1e-3)
180
+
181
+ fov = math.radians(fov_deg)
182
+ dist = float(rad) / math.tan(fov / 2.0) * 1.25
183
+ el = math.radians(elevation_deg)
184
+ bg_t = torch.tensor(bg, dtype=torch.float32, device=device)
185
+ P = _proj_matrix(0.01, 100.0, fov, fov, device)
186
+ tan_half = math.tan(fov / 2.0)
187
+
188
+ out = []
189
+ n = len(frames)
190
+ with torch.no_grad():
191
+ for t in range(orbit_steps):
192
+ az = 2.0 * math.pi * t / orbit_steps
193
+ direction = torch.tensor([
194
+ math.sin(az) * math.cos(el),
195
+ -math.sin(el),
196
+ -math.cos(az) * math.cos(el),
197
+ ], dtype=torch.float32, device=device)
198
+ cam_pos = center + dist * direction
199
+ R_wc, t_wc = _look_at(cam_pos, center, device)
200
+
201
+ # Gaussians are moved into the orbit camera's frame here, so the
202
+ # rasterizer gets an identity view matrix and a pure camera->clip
203
+ # projection.
204
+ means, quats, scales, colors, opac = frames[min(int(t * n / orbit_steps), n - 1)]
205
+ q_wc = matrix_to_quaternion(R_wc)
206
+ quats_v = quaternion_multiply(q_wc.unsqueeze(0).expand(quats.shape[0], -1), quats)
207
+ means_v = (R_wc @ means.transpose(0, 1)).transpose(0, 1) + t_wc
208
+
209
+ settings = GaussianRasterizationSettings(
210
+ image_height=size, image_width=size,
211
+ tanfovx=tan_half, tanfovy=tan_half,
212
+ bg=bg_t, scale_modifier=1.0,
213
+ viewmatrix=torch.eye(4, dtype=torch.float32, device=device),
214
+ projmatrix=P.transpose(0, 1).contiguous(),
215
+ sh_degree=0,
216
+ campos=torch.zeros(3, dtype=torch.float32, device=device),
217
+ prefiltered=False, debug=False,
218
+ )
219
+ rasterizer = GaussianRasterizer(raster_settings=settings)
220
+ screen = torch.zeros_like(means_v)
221
+ image, _ = rasterizer(
222
+ means3D=means_v.contiguous(),
223
+ means2D=screen,
224
+ shs=None,
225
+ colors_precomp=colors.contiguous(),
226
+ opacities=opac.reshape(-1, 1).contiguous(),
227
+ scales=scales.contiguous(),
228
+ rotations=quats_v.contiguous(),
229
+ cov3D_precomp=None,
230
+ )
231
+ frame = (image.clamp(0, 1).permute(1, 2, 0) * 255).to(torch.uint8).cpu().numpy()
232
+ out.append(frame)
233
+ return out
234
+
235
+
236
+ def save_video(frames: List[np.ndarray], path, fps=12):
237
+ import imageio.v2 as imageio
238
+ imageio.mimwrite(str(path), frames, fps=fps, quality=8, macro_block_size=1,
239
+ codec="libx264", output_params=["-pix_fmt", "yuv420p"])
240
+ return str(path)
requirements.txt CHANGED
@@ -1,3 +1,43 @@
1
- # No extra runtime deps: the app only uses gradio (preinstalled by the SDK) and
2
- # the Python stdlib. Orbit videos are pre-baked offline (see render_orbits.py,
3
- # not shipped) so numpy/imageio are not needed at runtime.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torchvision
2
+
3
+ # --- prebuilt sm_120 (Blackwell) CUDA wheels, cell pt211-cu130-cp312 ---
4
+ # real pytorch3d (SAM3D's pose/transform math + its "pytorch3d" rendering engine)
5
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt211-cu130-cp312/pytorch3d-0.7.9-cp312-cp312-linux_x86_64.whl
6
+ # upstream Inria 3DGS rasterizer, used for the orbit video
7
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt211-cu130-cp312/diff_gaussian_rasterization-0.0.0-cp312-cp312-linux_x86_64.whl
8
+
9
+ # pytorch3d runtime deps
10
+ numpy
11
+ iopath
12
+ fvcore
13
+
14
+ # sparse backbone for SAM 3D Objects
15
+ spconv-cu124==2.3.8
16
+
17
+ hydra-core==1.3.2
18
+ omegaconf
19
+ loguru
20
+ tqdm
21
+ Pillow
22
+ opencv-python-headless
23
+ scipy
24
+ matplotlib
25
+ easydict
26
+ optree
27
+ lightning
28
+ safetensors
29
+ timm
30
+ astor
31
+ transformers
32
+ accelerate
33
+ einops
34
+ trimesh
35
+ plyfile
36
+ xatlas
37
+ igraph
38
+ open3d
39
+ pyvista
40
+ pymeshfix
41
+ seaborn
42
+ imageio
43
+ imageio-ffmpeg