Splat upgrade (ideas.md #1) + MODELBEAST crossover
- GET /api/splat serves data/work/splat.ply; manifest reports has_splat - scene3d: lazy-load @mkkellogg/gaussian-splats-3d DropInViewer when a splat exists (rigs/anchors/paths overlay as before); point-cloud fallback intact - scripts/splat_via_modelbeast.sh: train the splat on the MODELBEAST fleet (colmap_poses -> brush_train via the mb queue) and drop it in place - docs/modelbeast-crossover.md: full crossover map (fleet compute, volumetric capture w/ CorridorKey + audio sync, per-moment splats) Verified in-browser: 32MB room splat rendered via /api/splat with overlays live.
This commit is contained in:
parent
931cf81a6c
commit
f12b6e0847
19
.claude/launch.json
Normal file
19
.claude/launch.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "festifun-api",
|
||||||
|
"runtimeExecutable": "uv",
|
||||||
|
"runtimeArgs": ["run", "python", "-m", "festival4d", "serve"],
|
||||||
|
"port": 8000,
|
||||||
|
"autoPort": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "festifun-web",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "frontend"],
|
||||||
|
"port": 5173,
|
||||||
|
"autoPort": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -146,6 +146,7 @@ def manifest() -> dict:
|
|||||||
"videos": [_video_dict(v) for v in videos],
|
"videos": [_video_dict(v) for v in videos],
|
||||||
"t_global_max": t_global_max,
|
"t_global_max": t_global_max,
|
||||||
"has_poses": db.has_poses(),
|
"has_poses": db.has_poses(),
|
||||||
|
"has_splat": config.SPLAT_PLY.exists(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -167,6 +168,23 @@ def pointcloud() -> FileResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/splat")
|
||||||
|
def splat() -> FileResponse:
|
||||||
|
"""Optional 3DGS splat of the scene (see docs/modelbeast-crossover.md).
|
||||||
|
|
||||||
|
Trained externally from the same COLMAP reconstruction (e.g. MODELBEAST's
|
||||||
|
brush_train) and dropped at ``data/work/splat.ply``. The viewer prefers it
|
||||||
|
over the sparse point cloud when present.
|
||||||
|
"""
|
||||||
|
if not config.SPLAT_PLY.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="no splat (train one — see docs/modelbeast-crossover.md)")
|
||||||
|
return FileResponse(
|
||||||
|
config.SPLAT_PLY,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename="splat.ply",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/anchors")
|
@app.get("/api/anchors")
|
||||||
def get_anchors() -> list[dict]:
|
def get_anchors() -> list[dict]:
|
||||||
return [_anchor_dict(a) for a in db.get_anchors()]
|
return [_anchor_dict(a) for a in db.get_anchors()]
|
||||||
|
|||||||
@ -40,6 +40,7 @@ COLMAP_DIR = WORK_DIR / "colmap" # COLMAP workspace
|
|||||||
DB_PATH = DATA_DIR / "project.db" # SQLite (gitignored)
|
DB_PATH = DATA_DIR / "project.db" # SQLite (gitignored)
|
||||||
|
|
||||||
POINTS_PLY = WORK_DIR / "points.ply" # reconstructed / synthetic point cloud
|
POINTS_PLY = WORK_DIR / "points.ply" # reconstructed / synthetic point cloud
|
||||||
|
SPLAT_PLY = WORK_DIR / "splat.ply" # optional 3DGS splat (trained externally, e.g. via MODELBEAST — see docs/modelbeast-crossover.md); viewer prefers it over the point cloud
|
||||||
SYNC_JSON = WORK_DIR / "sync.json" # exported sync solution (lane A)
|
SYNC_JSON = WORK_DIR / "sync.json" # exported sync solution (lane A)
|
||||||
GROUND_TRUTH_JSON = WORK_DIR / "ground_truth.json" # synthetic fixture ground truth
|
GROUND_TRUTH_JSON = WORK_DIR / "ground_truth.json" # synthetic fixture ground truth
|
||||||
|
|
||||||
|
|||||||
24
docs/modelbeast-crossover.md
Normal file
24
docs/modelbeast-crossover.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Festival 4D × MODELBEAST — crossover map
|
||||||
|
|
||||||
|
*2026-07-16. Both projects live on the same fleet (M3 Ultra 256GB primary, M1 Ultra 128GB, M4 Pro 24GB — see MODELBEAST's `CLUSTER.md`). They stay separate products — asset factory vs. experience viewer — but share infrastructure and pipelines.*
|
||||||
|
|
||||||
|
## Shipped: the splat upgrade (ideas.md #1) ✅
|
||||||
|
|
||||||
|
`docs/ideas.md`'s top item ("replace the sparse point cloud with 3D Gaussian Splatting") is now wired:
|
||||||
|
|
||||||
|
- **Backend:** `GET /api/splat` serves `data/work/splat.ply` when present; `/api/manifest` reports `has_splat`.
|
||||||
|
- **Viewer:** `scene3d.js` lazy-loads `@mkkellogg/gaussian-splats-3d` and renders the splat as a `DropInViewer` in the existing scene (camera rigs, anchors, and fly-paths overlay as before). Falls back to the point cloud when absent or on load error.
|
||||||
|
- **Training:** `scripts/splat_via_modelbeast.sh` sends this project's frames to the MODELBEAST queue (`colmap_poses` → `brush_train` on whichever GPU node is free) and drops the trained `.ply` into place. No local GPU blocking; the farm handles it.
|
||||||
|
|
||||||
|
## The bigger crossover map
|
||||||
|
|
||||||
|
1. **Fleet compute for every heavy step.** MODELBEAST's queue pools gpu (1 job/Mac) and cpu lanes across all three nodes. Festival 4D's COLMAP/splat/classification steps can submit there (`mb` CLI + token) instead of hogging the local machine.
|
||||||
|
2. **Poor-man's volumetric capture** (with CorridorKey): several phones film one subject on a green screen → *our GCC-PHAT audio sync* aligns them with no clapboard → CorridorKey (neural keyer, tuned on this fleet — see MODELBEAST's `CORRIDORKEY.md`) extracts true-edge mattes per synced frame → per-instant multi-view cutouts → 3D-gen or per-moment splats. DIY volumetric video from phones; every part exists in-house today.
|
||||||
|
3. **Per-moment splats = pragmatic "4D".** True temporal 4DGS has no credible Mac port yet (honest limit). But we already detect *moments*: pool all cameras' frames in a ±1s window around each moment → COLMAP → splat → one splat per moment → scrub between them on the timeline. Static splats, time-indexed — feels 4D where it counts. Caveat: needs decent multi-camera coverage; 3 fan phones is thin.
|
||||||
|
4. **Back-flow to MODELBEAST:** our audio-sync module is the missing piece for any multi-camera capture rig there; our moment detection could auto-pick hero frames for video→3D.
|
||||||
|
5. **GVM wildcard:** CorridorKey's ~80GB auto-matting module (untested on Metal, fits only the M3 256GB) would key *real concert footage* — performer/crowd separation without green screens — feeding use case 2 with actual festival material.
|
||||||
|
|
||||||
|
## Practical notes
|
||||||
|
- The `mb` CLI needs `MB_HOST` + `MB_TOKEN` (MODELBEAST Settings → Users). The M3 primary queue is at `100.89.131.57:8777` on the tailnet.
|
||||||
|
- Splat training cost: a ~40s 4K room clip → ~190 frames → COLMAP (~1 min, GLOMAP) → Brush 30k steps (~15 min on the M3). Concert scenes with crowds will be harder than rooms — expect floaters; SuperSplat can crop/clean.
|
||||||
|
- License note for use case 2: CorridorKey is CC BY-NC-SA (non-commercial).
|
||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "festival4d-frontend",
|
"name": "festival4d-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
|
||||||
"three": "^0.170.0"
|
"three": "^0.170.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -456,6 +457,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mkkellogg/gaussian-splats-3d": {
|
||||||
|
"version": "0.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz",
|
||||||
|
"integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.160.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
|
||||||
"three": "^0.170.0"
|
"three": "^0.170.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ async function boot() {
|
|||||||
state.videos = manifest.videos;
|
state.videos = manifest.videos;
|
||||||
state.tGlobalMax = manifest.t_global_max;
|
state.tGlobalMax = manifest.t_global_max;
|
||||||
state.hasPoses = manifest.has_poses;
|
state.hasPoses = manifest.has_poses;
|
||||||
|
state.hasSplat = !!manifest.has_splat;
|
||||||
for (const v of state.videos) state.enabled[v.id] = true;
|
for (const v of state.videos) state.enabled[v.id] = true;
|
||||||
state.audioSourceId = referenceVideoId();
|
state.audioSourceId = referenceVideoId();
|
||||||
|
|
||||||
|
|||||||
@ -235,6 +235,13 @@ export class Scene3D {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_loadPointCloud() {
|
_loadPointCloud() {
|
||||||
|
// Prefer a trained 3DGS splat when the backend has one (docs/modelbeast-crossover.md);
|
||||||
|
// fall back to the sparse COLMAP point cloud. The splat library is heavy, so it's
|
||||||
|
// lazy-imported only when actually needed.
|
||||||
|
if (state.hasSplat) {
|
||||||
|
this._loadSplat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loader = new PLYLoader();
|
const loader = new PLYLoader();
|
||||||
loader.load(
|
loader.load(
|
||||||
state.apiBase + "/api/pointcloud",
|
state.apiBase + "/api/pointcloud",
|
||||||
@ -258,6 +265,27 @@ export class Scene3D {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _loadSplat() {
|
||||||
|
try {
|
||||||
|
const GS = await import("@mkkellogg/gaussian-splats-3d");
|
||||||
|
// DropInViewer is a THREE.Object3D — it joins the existing scene and
|
||||||
|
// renders with our camera/controls, so rigs/anchors/paths overlay as usual.
|
||||||
|
const splatViewer = new GS.DropInViewer({ sharedMemoryForWorkers: false });
|
||||||
|
await splatViewer.addSplatScene(state.apiBase + "/api/splat", {
|
||||||
|
showLoadingUI: false,
|
||||||
|
progressiveLoad: true,
|
||||||
|
format: GS.SceneFormat.Ply,
|
||||||
|
});
|
||||||
|
this.splat = splatViewer;
|
||||||
|
this.scene.add(splatViewer);
|
||||||
|
emit("pointcloud-loaded", -1); // -1 = splat (no discrete point count)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[scene3d] splat load failed — falling back to point cloud", err);
|
||||||
|
state.hasSplat = false;
|
||||||
|
this._loadPointCloud();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Live pose (COLMAP convention) for a video at the current master time, or null.
|
// Live pose (COLMAP convention) for a video at the current master time, or null.
|
||||||
_livePose(videoId) {
|
_livePose(videoId) {
|
||||||
const poses = state.poses[videoId];
|
const poses = state.poses[videoId];
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export const state = {
|
|||||||
videos: [], // [{id, filename, url, duration_s, fps, width, height, offset_ms, drift_ppm}]
|
videos: [], // [{id, filename, url, duration_s, fps, width, height, offset_ms, drift_ppm}]
|
||||||
tGlobalMax: 0,
|
tGlobalMax: 0,
|
||||||
hasPoses: false,
|
hasPoses: false,
|
||||||
|
hasSplat: false, // optional 3DGS splat at /api/splat (preferred over the point cloud)
|
||||||
poses: {}, // id -> [{frame_idx, t_video_s, q:[w,x,y,z], t:[x,y,z], intrinsics, registered}]
|
poses: {}, // id -> [{frame_idx, t_video_s, q:[w,x,y,z], t:[x,y,z], intrinsics, registered}]
|
||||||
anchors: [], // [{id, label, x, y, z, color}]
|
anchors: [], // [{id, label, x, y, z, color}]
|
||||||
events: [], // [{id, t_global_s, duration_s, event_type, confidence, description, source}]
|
events: [], // [{id, t_global_s, duration_s, event_type, confidence, description, source}]
|
||||||
|
|||||||
40
scripts/splat_via_modelbeast.sh
Executable file
40
scripts/splat_via_modelbeast.sh
Executable file
@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Train a 3DGS splat of this project's scene on the MODELBEAST fleet, and drop
|
||||||
|
# it where the viewer looks for it (data/work/splat.ply).
|
||||||
|
#
|
||||||
|
# Uses the sampled SfM frames (data/work/frames) as input: uploads them to
|
||||||
|
# MODELBEAST, runs colmap_poses + brush_train on the render farm's queue, and
|
||||||
|
# downloads the trained splat back. Requires the `mb` CLI env:
|
||||||
|
# export MB_HOST=http://100.89.131.57:8777 # M3 primary
|
||||||
|
# export MB_TOKEN=... # from MODELBEAST Settings → Users
|
||||||
|
#
|
||||||
|
# (v1 re-runs COLMAP on the farm rather than shipping our local reconstruction —
|
||||||
|
# simpler and uses the farm's known-good chain; direct dataset handoff is a
|
||||||
|
# future optimization. See docs/modelbeast-crossover.md.)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
MB=${MB:-/Users/m3ultra/Documents/MODELBEAST/mb}
|
||||||
|
FRAMES_DIR=${FESTIVAL4D_DATA_DIR:-data}/work/frames
|
||||||
|
OUT=${FESTIVAL4D_DATA_DIR:-data}/work
|
||||||
|
|
||||||
|
[ -d "$FRAMES_DIR" ] || { echo "no frames at $FRAMES_DIR — run the reconstruct step first"; exit 1; }
|
||||||
|
command -v "$MB" >/dev/null || { echo "mb CLI not found (set MB=/path/to/mb)"; exit 1; }
|
||||||
|
|
||||||
|
echo "[splat] uploading frames..."
|
||||||
|
mapfile -t frames < <(find "$FRAMES_DIR" -name '*.jpg' -o -name '*.png' | sort)
|
||||||
|
echo "[splat] ${#frames[@]} frames"
|
||||||
|
ids=$("$MB" upload "${frames[@]}" | awk '{print $1}')
|
||||||
|
first=$(echo "$ids" | head -1)
|
||||||
|
|
||||||
|
echo "[splat] running colmap_poses on the farm..."
|
||||||
|
cj=$("$MB" run colmap_poses --asset "$first" $(echo "$ids" | tail -n +2 | sed 's/^/--asset /') | grep -oE '[0-9a-f]{10,}' | head -1)
|
||||||
|
"$MB" wait "$cj"
|
||||||
|
ds=$("$MB" assets | awk '/colmap_dataset/ {print $1; exit}')
|
||||||
|
echo "[splat] dataset: $ds — training splat (brush)..."
|
||||||
|
bj=$("$MB" run brush_train --asset "$ds" | grep -oE '[0-9a-f]{10,}' | head -1)
|
||||||
|
"$MB" wait "$bj" --download "$OUT/_splat_dl"
|
||||||
|
ply=$(find "$OUT/_splat_dl" -name '*.ply' | head -1)
|
||||||
|
[ -n "$ply" ] || { echo "no .ply came back"; exit 1; }
|
||||||
|
mv "$ply" "$OUT/splat.ply" && rm -rf "$OUT/_splat_dl"
|
||||||
|
echo "[splat] done → $OUT/splat.ply (restart/reload the app; the 3D view now renders the splat)"
|
||||||
Loading…
Reference in New Issue
Block a user