Course log · Side questSplat Lab · 2026-09-26 → 09-30MiniMax-H3 · Blender · Spirula Studio · COLMAP

One cat image,
one 3D splat

A image of a white cat in a purple hat goes in. A video model with a camera LoRA walks a camera all the way round it, and that video becomes a 3D Gaussian splat you can turn in your browser. Two numbers that were both about 45° nearly sank it: an orbit LoRA that always filmed from the height of its reference pictures, and a reconstruction whose last camera ended up 44.5° away from its first.

Sections
  1. One image, one orbit
  2. The 45° problem
  3. A height ladder
  4. A 44.5° seam
  5. Closing the loop
  6. The splat
  7. The pipeline
  8. Honest limits
The orbit we splatted: 124 generated frames from one image, 1024×768 at 24 fps.
1 → 124
Image in, orbit frames out
0/4 → 3/4
Cross-height tests passed, old LoRA → step 1500
44.5°
First vs last solved camera, for near-identical frames
316.6° → 360°
Camera sweep, before → after loop closure
24.7 → 25.1 dB
PSNR on 15 held-out frames
256,651
Gaussians, 7,000 steps, 39 s
1

One image, one orbit

Turning a single still into the 360° video a splat needs, with a LoRA trained only on Blender renders.

Lesson 1.1

A camera move is a learnable effect

MiniMax-H3's reference-to-video mode (Ref2VA) takes up to a few reference pictures and a caption and writes a video with sound. ORB360 is a rank-32 LoRA for it that teaches one camera move: a clockwise orbit at a constant distance that ends exactly where it began, with the subject frozen. Every training clip was rendered in Blender, so the camera's true position is known for every frame. That's what makes the orbit measurable later.

A fluffy white cat in a purple felt hat sitting on a snowy rock, pine trees and mountains at sunset behind it
The input: one image, used as Picture 1. Frame 0 and frame 123 should both look like it.
First and last generated frames side by side, almost identical
Frames 1 and 124 of the generated orbit. They're nearly identical (foreground PSNR 30.8 dB, mask overlap 0.994). Hold on to that.

The orbit LoRA never saw a real-looking image or a real scene in training, yet it kept the ridge, the pines and the sky all the way round. It did one thing the caption didn't ask for: this orbit is at the image's own height, roughly eye level. Getting the camera to go higher or lower than the image is the next two sections.

Reproduce with a coding agentgenerate the orbit
# musubi-tuner (kohya-ss) v0.3.5+, MiniMax-H3 Ref2VA weights, LoRA from huggingface.co/MATLOWAI/MiniMax-H3-ORB360-CardSpin
python src/musubi_tuner/minimax_h3_generate_video.py --task ref2va \
  --dit minimax_h3_ref2va_bf16.safetensors --prune_adaln \
  --video_vae minimax_h3_video_vae_fp16.safetensors --audio_vae minimax_h3_audio_vae_fp32.safetensors \
  --text_encoder qwen3vl_32b_minimax_h3_int8_convrot.safetensors --text_encoder_attn_mode sdpa \
  --lora_weight minimax_h3_orb360_step1500.safetensors --lora_multiplier 1.0 --lora_runtime_attach \
  --video_size 768 1024 --video_length 124 --infer_steps 20 --attn_mode sdpa --seed 20260926 \
  --prompt "$(cat prompts/orbit_realscene_caption.txt)" --ref photo.png --save_path out/
Agent prompt
Generate one 124-frame, 24 fps orbit with the ORB360 step-1500 LoRA from a single reference image, using the model card's real-scene orbit caption unchanged. Keep the raw MP4, the exact command, the seed and the LoRA hash next to it. Before using it for 3D, check by eye that it completes one turn, shows the back, never reverses, and that the last frame matches the first.
2

The 45° problem

The first orbit LoRA had a perfect-looking test record. It was copying the pictures.

Lesson 2.1

Show it pictures from above, ask for eye level

For a splat you want views from several heights, including from below. So the test gave the first LoRA (750 steps on four 512×512 orbits) three pictures of a stone cat statue rendered from 45° above, and a caption asking for an orbit at 5°, near eye level. The statue is a free Poly Haven scan that was never in training, and Blender rendered the true 5° orbit to compare against.

Three renders of a stone cat statue seen from 45 degrees above, at 0, 120 and 240 degrees around it
The reference pictures, all from 45° above, at 0°, 120° and 240° round the statue.
Old LoRA: 45°Step 1500: ~0–10°Same pictures, same caption, same seed. Left, the Blender truth at 5°. Middle, the old LoRA: a clean single turn, looking down from 45° on all 124 frames. Right, the height-ladder LoRA from section 3.

It wasn't a one-off. In all four tests where the pictures came from 45° and the caption asked for something else (5° on three held-out objects, −30° on one), the old LoRA orbited at 45°. Its orbits were otherwise excellent: one turn, tight pacing, the right start view. It had been trained on four orbits, each with pictures taken from that same orbit, so "film from wherever the pictures were taken" had always been the right answer.

What we learned

  • The old LoRA also passed the "orbit from 30° below" test, because its pictures were taken from 30° below. A test where copying the input gives the right answer can't tell copying from following the caption.
  • Only cross-height tests (pictures from one height, orbit asked at another) measure whether the camera follows the caption.
3

A height ladder

Training camera height on purpose, including from underneath through a floor that isn't there.

Lesson 3.1

Render every height, and say it in the caption

The fix was data. 23 objects (Poly Haven CC0 props and a few stylised character models) were rendered in Blender as 345 one-turn orbits at three lengths, each at the largest size that fit in 96 GB: 124 frames at 800×800, 73 at 1024×1024 and 22 at 1600×1600. Camera heights ran from 45° below the subject's centre to 55° above. While planning it, the question came up: what about negative angles, looking up through a transparent floor? Think getting angles for splat. So every orbit below the centre was rendered with the floor switched off.

  • Each clip appears in three training rows with different reference layouts (front only, front and back, a turnaround, thirds), and one of the three uses pictures rendered from a different height than the orbit.
  • Captions give the height in words and numbers ("a low camera below the subject, looking up, 30 degrees below the subject's centre") and label every picture with its time, frame and angle.
  • 960 rows, 1,500 steps of rank-32 LoRA training, about 62 s per step (about 26 hours) on one 96 GB GPU.

Every checkpoint was scored on the same held-out tests. A tracker matched each generated frame to Blender renders of the true orbit at a grid of heights, which gives a height and an angle per frame, good to about ±5–7° in height. One seed per test.

Pictures from 45°, orbit asked at…Old 512²Step 250Step 500Step 1250Step 1500
5°, stone cat statue45°0°, 2 turns0°, 2 turns0°, 2 turns0–10°, 1 turn
5°, vintage film camera45°20°10°5°5°
5°, stylised character45°45°5°5°0–5°
−30°, vintage film camera (the splat case)45°45°10°10°Fails20°

Median measured height per clip. Step 1500 is the first checkpoint that put the statue at the right height and went round once: every earlier ladder checkpoint got the height right and then orbited twice.

2 turnsStep 1250: right height, but 726° of rotation in a clip meant to hold 360°.
UnsolvedPictures from 45° above, orbit asked at 30° below. Blender looks up at the camera's underside; step 1500 drifts back toward the pictures' height and tumbles.

Two more failures matter for splats. Changing height during one clip (45° above down to 30° below) made the object tumble instead of the camera moving. And on a real (non-Blender) image, height prompts did nothing yet: the cat asked for an orbit 35° above and 20° below came out at the image's own height both times, on two seeds each. Of the 345 training clips, only 109 had straight-on cross-height pictures with a higher orbit, and none had straight-on pictures with an orbit from below.

Two rows of frames from the cat orbit prompted high and prompted low; both rows are at the same eye-level height
No effectStep 1500 on the cat image, seed 20260926. Top: prompted 35° above. Bottom: prompted 20° below. Practically the same video.

What we learned

  • Step 1500 follows the caption's height on studio renders from about 30° below to 45° above, and for gaps of about 40° between the pictures' height and the orbit's. A 75° gap still fails.
  • A held-out test on rendered objects is not a real-looking image. The cat that went to the splat was an ordinary eye-level orbit.
  • Count what the training pairs actually cover. The cross-height rule picked "the height farthest from the orbit", which almost never produced the case real photos need: a straight-on picture and an orbit from below.
4

A 44.5° seam

On a second machine, structure-from-motion found every frame and still got the orbit wrong.

Lesson 4.1

Registering every frame isn't the same as recovering the orbit

The 124 frames went to a desktop with two RTX 5090s and the stock tools: Spirula Studio for structure-from-motion and splat training, and COLMAP as a cross-check. A generated orbit has no camera data, so every camera pose has to be solved from the pixels.

  • Full frames, Spirula: 124 of 124 frames registered, yet the path covered only 94° and reversed direction 34 times. COLMAP's incremental solver on full frames kept collapsing.
  • Masked to the cat: Spirula's built-in SAM 2.1 tracker cut out the cat and hat. With separate horizontal and vertical focal lengths, the path jumped 107° between neighbouring frames.
  • Masked, one shared focal length: the smoothest solution. 124 of 124 frames, no reversals, the largest step between frames 4.52°, but only 316.6° of sweep.

The first and last frames are nearly the same picture, so their cameras should nearly coincide. Instead their solved orientations differ by 44.51°, their centres sit 0.67 units apart on an orbit of radius about 0.9, and the two frames share just 3 reconstructed 3D points out of about 430 tracked in each. Orbiting the finished splat by hand, Dylan saw it as the cat's pose jumping where the orbit wraps round.

Camera centres before loop closure 316.6° of the orbit recovered
OpenSolved camera centres, seen from above the orbit plane. The ring stops short; the dashed line is the seam between frame 1 (circle) and frame 124 (cross).
Camera centres after loop closure 360° · first = last (imposed)
ClosedAfter loop closure (section 5): the same 124 cameras, now a full ring with frame 124 on top of frame 1.

Colour runs from frame 1 to frame 124.

Why the solver lost 44° is not established. The generated frames are probably not perfectly consistent with one rigid scene, so small errors pile up round the ring and nothing ties the end back to the start strongly enough. That is a hypothesis; it hasn't been tested.

5

Closing the loop

Telling the reconstruction what the video was asked to do: end where it started.

Lesson 5.1

Spread the error round the ring

A short script, close_camera_loop.py, builds a separate experimental camera model and leaves the original alone. It takes the rigid transform that would move the last camera onto the first, converts it to a twist with the SE(3) logarithm, and applies a growing fraction of it to each frame: none at frame 1, all of it at frame 124. COLMAP then re-optimises only the 3D points, with every camera and the focal length held fixed, and the splat is retrained from scratch on the corrected cameras.

T_i' = exp( (i − 1)/123 · log( T_first · T_last⁻¹ ) ) · T_i        # camera-to-world, i = 1…124
Same 124 frames, full scene, 7,000 stepsOriginal camerasLoop-closed
Recovered sweep316.6°360° (imposed)
Largest step between frames4.52°4.97°
Direction reversals00
Mean reprojection error, 45,940 observations0.66 px0.91 px
Mean PSNR, 15 held-out frames24.71 dB25.14 dB
Held-out frame 1 (the image's view)21.98 dB24.69 dB
Gaussians / training time257,004 / 39.1 s256,651 / 39.3 s

The biggest gains are at the two ends of the orbit, where the seam was: frame 1 rose 2.7 dB and frame 121 rose 1.4 dB. Four of the 15 held-out frames got slightly worse (by 0.04–0.43 dB). The reprojection error went up, as expected: the points now have to agree with cameras that were moved, not solved.

Grid of 16 splat renders of the cat from held-out camera positions around the orbit
The loop-closed splat rendered at the 16 held-out evaluation cameras (one frame in eight was never trained on). Two of the 16 views show the same source frame, so 15 distinct frames are scored.

What we learned

  • Registration rate and reprojection error both looked fine while the orbit was missing 43°. Plot the camera path, and compare the first and last cameras when the first and last frames match.
  • The closure is imposed: the script makes frame 124 equal frame 1 by construction. The PSNR gain shows the splat fits the generated frames better, not that the geometry is correct. There is no ground-truth 3D for a generated cat.
Reproduce with a coding agentloop prior
Agent prompt
Given a COLMAP text model of a generated 360° orbit whose first and last frames show the same view, write a script that creates a NEW model directory (refuse to overwrite) with an experimental loop prior: compute C = T_first · inverse(T_last) on camera-to-world matrices, take its matrix logarithm, and replace each camera T_i by exp(alpha_i · log C) · T_i with alpha_i running from 0 at the first frame to 1 at the last. Check the result stays a rigid transform, the first camera doesn't move and the last equals the first. Then re-triangulate / bundle-adjust points only, with cameras and intrinsics fixed, and train the splat on both models with the same settings. Report the sweep, largest step, reprojection error before and after, and PSNR on the same held-out frames, and label the closure as imposed.
6

The splat

The loop-closed model, all 256,651 Gaussians, in your browser.

Drag to orbit, scroll or pinch to zoom. Needs WebGL 2. The model is a 4.6 MB file; the viewer libraries add about 1 MB compressed.

It starts from the image's viewpoint and turns slowly; touch it to take over. Along the orbit it looks like the video. Leave the orbit, go above it or below it, and the ghosting and blur show what a single eye-level ring can't pin down: there were no views from there to learn from.

No WebGL 2? The same model rendered round the training orbit, 124 views, captured from this page's viewer.

The file is the trained PLY (63.7 MB, spherical harmonics to degree 3) converted to Niantic's SPZ format with Spark's transcodeSpz: nothing removed, but positions, colours and rotations are quantised to 8–24 bits. The model is shown in its own reconstruction frame, turned so the orbit plane is level.

7

How the pipeline works

Two machines, one hand-off: an MP4 with its hashes, caption and generation settings.

Starting from a text idea instead of a picture? Make the reference image first with a general image model; we suggest GPT Image 2.5. The cat on this page was made by hand.

  1. Image + LoRAOne reference image; the ORB360 step-1500 LoRA on MiniMax-H3 Ref2VA; the model card's orbit caption.Video side · 96 GB GPU
  2. Orbit video124 frames, 1024×768, 24 fps, 20 sampling steps, one seed. Checked by eye: one turn, back visible, end matches start.Video side
  3. Frames and masksEvery frame extracted (no interpolation or clean-up). SAM 2.1 masks of the cat and hat, used for pose solving only.Splat side · RTX 5090
  4. Structure from motionSpirula's SfM on masked frames, one shared pinhole focal length; COLMAP 4.2.1 as a cross-check. 124/124 registered.Splat side
  5. Camera reviewPlot the path: sweep, steps, reversals, and first vs last camera. Here: 316.6° and a 44.5° seam.Splat side
  6. Loop closureSpread the first-to-last rigid error over the ring (SE(3) log/exp); refine 3D points only.Splat side
  7. Train the splatSpirula's stock 3DGS trainer, full frames, no masks, 7,000 steps, cap 500k Gaussians, every 8th frame held out. 39 s.Splat side
  8. ViewDesktop viewer at quarter turns, then SPZ for the web and Spark in the browser.Here
  • Keep the whole scene. A cut-out cat trained on masks gave a tidy foreground with ghosting at the hat and fur; training on full frames keeps the snow, trees and mountains and was preferred.
  • Fewer frames weren't better. 37 frames picked to line up with H3's video-VAE chunk boundaries gave 63,258 Gaussians against about 257,000, and on the one held-out frame both runs share it scored 17.89 dB against 21.98 dB. One shared frame supports no general conclusion.
  • Every stage writes its exact command and inputs, and the video's SHA-256 travels with it, so a later run can prove it used the same clip.
Reproduce with a coding agentsplat side
# Spirula Studio release 2026.9.24 (Vulkan) and the official COLMAP 4.2.1 CUDA container.
# pipeline.py and close_camera_loop.py are small local helpers, not published yet. Paths are illustrative.
python3 splat-pipeline/pipeline.py prepare runs/inputs/orbit.mp4 runs/orbit-spirula --backend spirula --fps 24
COLMAP_GPU=1 python3 splat-pipeline/pipeline.py prepare runs/inputs/orbit.mp4 runs/orbit-colmap --backend colmap --fps 24
python3 splat-pipeline/close_camera_loop.py runs/orbit-spirula/sparse-text runs/orbit-loop/sparse-text
# then COLMAP bundle adjustment on the new model with all cameras and intrinsics fixed (3D points only)
spirula train 3dgs --data runs/orbit-loop --colmap-recon-dir sparse/0 --num-iterations 7000 --cap-max 500000 \
  --eval-mode interval --eval-interval 8 --save-eval-images 1 --load-masks 0
Agent prompt
Reconstruct a generated 360° orbit video as a Gaussian splat. Extract every frame. Solve cameras with masked SfM (subject masks from SAM 2.1) using one shared pinhole focal length, and cross-check with COLMAP. Do not accept a reconstruction on registration rate alone: plot the camera centres, report the angular sweep, the largest step between neighbouring frames, any direction reversals, and the angle between the first and last cameras when the first and last frames match. If the loop doesn't close, create a separate loop-prior camera model, refine points only, and train both models with identical settings on full frames, holding out every eighth frame. Compare PSNR on the same held-out frames and say plainly that the closure was imposed.
8

Honest limits

What this page does and doesn't show.

  • One image, one seed, one splat. Every orbit test here is a single seed. The splat comes from one generated video. None of it is a benchmark.
  • The loop closure is assumed, not measured. It makes the ends meet because we told it to. A generated video has no true geometry to check it against.
  • The splat case is unsolved. Pictures from above with an orbit from below still drift back up, height changes inside one clip still tumble, and height prompts don't yet move the camera on a real (non-Blender) image. This cat was splatted from an ordinary eye-level ring, so its top and underside are guesses.
  • Image fit isn't 3D accuracy. 25.1 dB PSNR on held-out generated frames says the splat reproduces the video, including anything the video model got wrong.

Lesson 8.1

The 1.37× sharper cat that had stopped orbiting

A later run trained tilted rings and vertical loops on top of step 1500, and its automatic report scored the cat at step 750 as having 1.37× the fine detail of step 1500. Looking at the frames showed why: on that seed the camera stalled at the side view and never reached the back, so the clip was mostly the image's sharp front. A sharpness score rewards a camera that doesn't move.

Three rows of five frames: step 1500 goes round the cat; the step-750 seed that scored sharper keeps facing the camera; another step-750 seed goes round
False winFrames 1, 26, 51, 76 and 101. Top: step 1500. Middle: the step-750 clip that scored 1.37×, still facing front. Bottom: a second seed of the same checkpoint, which does go round.

Two more seeds showed the stall was that seed's bad luck: step 750 orbits on both. The lesson stands either way.

What we learned

  • A quality score on an orbit is meaningless without a check that the orbit happened. Gate sharpness on completion.
  • Look at the frames before believing a number that's better than expected.
  • Check one surprising result with a second seed before blaming the checkpoint.
Height prompts need cross-height trainingA LoRA trained only on pictures taken from its own orbit learns to copy the pictures' height.
Copying passes on-path testsOnly tests where the pictures and the orbit are at different heights show whether the caption is followed.
Registered isn't solvedFull-frame SfM registered every frame on a 94° path that reversed 34 times. Masking to the subject for poses fixed that; training still used full frames.
One focal lengthSeparate horizontal and vertical focal lengths let the solver bend the path; one shared focal length gave the smoothest orbit.
Check the seamWhen the first and last frames match, the first and last cameras should too. Here they were 44.5° apart.
Mask file namesThe standalone Spirula release writes zero-based, five-digit mask names; the frames were one-based and six-digit. Map them explicitly.