feat(viewer): animated orbit / trajectory GIF mode for the 3D scene view (closes #88) - #106
Open
dchaudhari7177 wants to merge 1 commit into
Open
Conversation
The 3D scene viewer rendered a single static PNG, so coverage could only be judged from one fixed angle. --animate exports a GIF instead: --animate-mode orbit sweep the viewpoint right round the scene --animate-mode trajectory fixed viewpoint, object walks its path The static-PNG default is untouched, and verified so: the bundled fixture renders byte-identical (md5 f151c33e...) before and after the refactor. render() is split into _draw_scene() (cameras, trajectories, ground, axes) plus a thin save step, so both paths share one drawing implementation instead of duplicating it. Trajectory bounds are always computed from the full path even when the polyline is truncated, so the view does not drift between frames. GIFs are written with Pillow, matching the animation path in record_multiview.py, so no imageio/ffmpeg encoder is needed - matplotlib and PIL stay lazily imported inside the render functions. Two things found while testing: - Animation frames must NOT use bbox_inches="tight". The tight box is recomputed per frame, and as the view rotates the artists' extent changes, so frames came out 354-436 px wide for the bundled fixture. A GIF pastes every frame onto the first frame's canvas, so that showed as jitter and clipped edges. Animation frames now use the full fixed canvas; the static PNG keeps its tight box. A test pins that all frames share one size. - Trajectory mode caps frames at the trajectory sample count, since asking for more re-renders figures that are pixel-identical. Also only calls ax.legend() when something is labelled: a camera-only manifest has no trajectory to list, and matplotlib warned instead of drawing nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #88.
The 3D scene viewer rendered a single static PNG, so coverage could only be judged from one fixed angle.
--animateexports a GIF instead:uv run --with matplotlib python scripts/view_scene_3d.py --animate --out scene.gif uv run --with matplotlib python scripts/view_scene_3d.py --animate \ --animate-mode trajectory --out walk.giforbit(default) — sweeps the viewpoint right round the scene, geometry fixed, so frustum overlap reads from any angle.trajectory— fixed viewpoint, object walks its path as a growing polyline with the leading sample marked.--framesand--frame-mstune it. Verified headless (Agg) on this machine: 36-frame orbit GIF ≈ 750 KB in ~8 s; 15-frame trajectory GIF ≈ 82 KB.Static path is provably unchanged
Rather than assert this, I checked it: the bundled MTMC fixture renders byte-identical before and after the refactor (
md5 f151c33e3b15ce78989ecd16d6272727, 93,434 bytes), re-verified afterruff format.render()is split into_draw_scene()(cameras, trajectories, ground, axes) plus a thin save step, so both paths share one drawing implementation instead of duplicating it. Trajectory bounds always span the full path even when the drawn polyline is truncated, so the view doesn't drift between frames.GIFs use Pillow, matching
record_multiview.py, so no imageio/ffmpeg encoder is required (worth noting:imageioisn't installed in this repo's venv, so the Pillow path is also the only one that works out of the box). matplotlib and PIL stay lazily imported inside the render functions.Two bugs found while testing
bbox_inches="tight". The tight box is recomputed per frame, and as the view rotates the artists' extent changes — frames came out 354–436 px wide for the bundled fixture. A GIF pastes every frame onto the first frame's canvas, so this showed up as jitter and clipped edges. Animation frames now render to the full fixed canvas (560×440); the static PNG keeps its tight box.test_orbit_frames_all_share_one_canvas_sizepins it.Also:
ax.legend()is only called when something is labelled — a camera-only manifest has no trajectory to list, and matplotlib emittedNo artists with labels foundrather than drawing nothing.Tests
tests/test_view_scene_3d_animation.py— 7 headless tests: non-empty multi-frame GIF for both modes, uniform frame size, the frame cap, the static PNG path still emitting a PNG, rejected mode/frame-count, and a camera-only manifest (orbit works, trajectory raises a clear error). All skip cleanly without matplotlib/Pillow.Kept in its own module rather than added to
tests/test_view_scene_3d.pydeliberately — see below.Note on overlap with #105
My open PR #105 (issue #89, coverage colouring) also edits
scripts/view_scene_3d.py, changingrender()'s signature and the camera-drawing loop, and addstests/test_view_scene_3d.py. These two will conflict textually. Both are offmainand independent, and I put the animation tests in a separate module so at least the test files don't collide. Happy to rebase whichever you'd like to land second — just say which.Local results
Full-suite note:
tests/test_overlay.py::test_importing_package_does_not_import_pillowfails, but it fails identically on pristineupstream/main(its"PIL" not in sys.modulescheck is order-dependent and something earlier in the run imports Pillow). Passes when that file is run alone. Left alone as out of scope — though notesteerbench's equivalent check moved to a subprocess for exactly this reason, if you want the same fix here.🤖 Generated with Claude Code