Spaces:
Running on Zero
Running on Zero
Lift4D stages 1+2 on ZeroGPU: splat viewer + orbit video
Browse files- .gitattributes +3 -0
- README.md +52 -36
- app.py +297 -171
- examples/horse_running.mp4 +3 -0
- examples/poodle_running.mp4 +3 -0
- examples/squirrel_eating.mp4 +3 -0
- kaolin_stub/kaolin/__init__.py +1 -0
- kaolin_stub/kaolin/utils/__init__.py +2 -0
- kaolin_stub/kaolin/utils/testing.py +4 -0
- lift4d_stage12.py +284 -0
- orbit_render.py +240 -0
- requirements.txt +43 -3
.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
|
| 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 |
-
|
| 11 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
# Lift4D
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 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
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
("
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
(
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
)
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
-
def
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 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 |
-
|
| 100 |
-
|
| 101 |
"""
|
| 102 |
|
| 103 |
-
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Lift4D
|
| 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 |
-
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 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|