From a54c04881176a5de03030e7d04930fcf73612ae9 Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:01:11 +0530 Subject: [PATCH] viewer: add an animated orbit/trajectory GIF mode (closes #88) 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. --- scripts/view_scene_3d.py | 223 +++++++++++++++++++++++--- tests/test_view_scene_3d_animation.py | 121 ++++++++++++++ 2 files changed, 324 insertions(+), 20 deletions(-) create mode 100644 tests/test_view_scene_3d_animation.py diff --git a/scripts/view_scene_3d.py b/scripts/view_scene_3d.py index 476aad3..237006d 100644 --- a/scripts/view_scene_3d.py +++ b/scripts/view_scene_3d.py @@ -16,12 +16,22 @@ height}`` and ``entities[*].frames[*].points[*].xyz_gt``); defaults to the bundled MTMC golden fixture. -``matplotlib`` is imported lazily inside :func:`main` (it is not a package -dependency), mirroring the lazy-import pattern in ``scripts/record_multiview.py``. -Run:: +``--animate`` exports an animated GIF instead: ``--animate-mode orbit`` (the +default) sweeps the viewpoint right round the scene so coverage and frustum +overlap can be read from any angle, and ``--animate-mode trajectory`` holds the +viewpoint still and walks the object along its path. The static-PNG default is +untouched. + +``matplotlib`` (and ``PIL`` for the GIF) is imported lazily inside the render +functions (neither is a package dependency), mirroring the lazy-import pattern in +``scripts/record_multiview.py``. GIFs are written with Pillow, as there too, so no +ffmpeg/imageio encoder is required. Run:: uv run --with matplotlib python scripts/view_scene_3d.py uv run --with matplotlib python scripts/view_scene_3d.py --manifest PATH --out PATH + 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.gif """ from __future__ import annotations @@ -43,6 +53,20 @@ # How far in front of each camera to draw the frustum apex (world units). _FRUSTUM_DEPTH = 1.2 +#: Animation modes for ``--animate``. +ORBIT = "orbit" +TRAJECTORY = "trajectory" + +#: Animation defaults. 36 frames is a 10-degree azimuth step — smooth enough to +#: read, small enough that the GIF stays reviewable in a PR. The figure is drawn +#: smaller and at a lower DPI than the static PNG so a 36-frame GIF does not +#: dwarf the repo's other assets. +ANIMATE_FRAMES = 36 +ANIMATE_MS = 120 +ANIMATE_DPI = 80 +ANIMATE_FIGSIZE = (7.0, 5.5) +_DEFAULT_ANIMATE_OUT = _ROOT / "docs" / "assets" / "scene_3d.gif" + def _camera_centre( rotation: NDArray[np.float64], translation: NDArray[np.float64] @@ -101,16 +125,19 @@ def _trajectories(manifest: dict[str, Any]) -> list[tuple[str, NDArray[np.float6 return out -def render(manifest: dict[str, Any], out_path: Path) -> Path: - """Render the 3D scene view to ``out_path`` (PNG) and return the path.""" - import matplotlib - - matplotlib.use("Agg") # headless-safe: no display needed - import matplotlib.pyplot as plt +def _draw_scene( + ax: Any, manifest: dict[str, Any], *, upto: int | None = None +) -> NDArray[np.float64]: + """Draw cameras, trajectories and the ground plane onto ``ax``. - fig = plt.figure(figsize=(9, 7)) - ax = fig.add_subplot(111, projection="3d") + Returns every scene point, for the caller's bounds/aspect computation. + ``upto`` truncates each trajectory to its first ``upto`` samples — the + animated trajectory mode, where the polyline grows frame by frame and the + leading sample is marked. The returned points always span the *full* path + regardless, so the view does not drift between frames. ``None`` (the default, + and the static path) draws every sample with no marker. + """ all_points: list[NDArray[np.float64]] = [] # Cameras: centre marker + a wireframe frustum to the four image corners. @@ -139,16 +166,29 @@ def render(manifest: dict[str, Any], out_path: Path) -> Path: # Trajectories: one polyline per entity point. for label, coords in _trajectories(manifest): - ax.plot( - coords[:, 0], - coords[:, 1], - coords[:, 2], + all_points.append(coords) # bounds always span the whole path + shown = coords if upto is None else coords[: max(upto, 1)] + line = ax.plot( + shown[:, 0], + shown[:, 1], + shown[:, 2], marker="o", markersize=3, linewidth=1.5, label=f"trajectory: {label}", ) - all_points.append(coords) + if upto is not None: + # Mark where the object currently is, so motion is legible even + # when the trailing polyline is short. + head = shown[-1] + ax.scatter( + *head, + s=70, + color=line[0].get_color(), + edgecolor="black", + linewidth=0.6, + depthshade=False, + ) if not all_points: raise ValueError("manifest has no cameras or trajectories to draw") @@ -168,12 +208,29 @@ def render(manifest: dict[str, Any], out_path: Path) -> Path: ax.set_ylabel("y") ax.set_zlabel("z (up)") ax.set_title("Scene: camera locations and object trajectory") - ax.legend(loc="upper left", fontsize=8) + # Only when something is labelled: a camera-only manifest has no trajectory + # to list, and matplotlib warns ("No artists with labels found") rather than + # quietly drawing an empty box. + if ax.get_legend_handles_labels()[1]: + ax.legend(loc="upper left", fontsize=8) # Equal aspect over the combined bounds so directions are not skewed. span = pts.max(axis=0) - pts.min(axis=0) span = np.where(span > 0, span, 1.0) ax.set_box_aspect(tuple(span)) + return pts + + +def render(manifest: dict[str, Any], out_path: Path) -> Path: + """Render the 3D scene view to ``out_path`` (PNG) and return the path.""" + import matplotlib + + matplotlib.use("Agg") # headless-safe: no display needed + import matplotlib.pyplot as plt + + fig = plt.figure(figsize=(9, 7)) + ax = fig.add_subplot(111, projection="3d") + _draw_scene(ax, manifest) out_path.parent.mkdir(parents=True, exist_ok=True) fig.savefig(out_path, dpi=130, bbox_inches="tight") @@ -181,6 +238,93 @@ def render(manifest: dict[str, Any], out_path: Path) -> Path: return out_path +def _trajectory_length(manifest: dict[str, Any]) -> int: + """Longest trajectory sample count, for pacing the trajectory animation.""" + return max((len(coords) for _, coords in _trajectories(manifest)), default=0) + + +def render_animation( + manifest: dict[str, Any], + out_path: Path, + *, + mode: str = ORBIT, + frames: int = ANIMATE_FRAMES, + frame_ms: int = ANIMATE_MS, +) -> Path: + """Render an animated GIF of the scene to ``out_path`` and return the path. + + ``mode`` is :data:`ORBIT` (azimuth sweep right round the scene, geometry + fixed) or :data:`TRAJECTORY` (fixed viewpoint, object walking its path). + + GIF is written with Pillow, mirroring the animation path in + ``scripts/record_multiview.py`` — no ``imageio``/ffmpeg encoder needed, so it + works on a bare headless box. + """ + import io + + import matplotlib + + matplotlib.use("Agg") # headless-safe: no display needed + import matplotlib.pyplot as plt + from PIL import Image + + if mode not in (ORBIT, TRAJECTORY): + raise ValueError(f"unknown animation mode {mode!r}; expected {ORBIT} or {TRAJECTORY}") + if frames < 1: + raise ValueError(f"frames must be >= 1 (got {frames})") + + samples = _trajectory_length(manifest) + if mode == TRAJECTORY and samples == 0: + raise ValueError("manifest has no trajectory to animate; use --animate-mode orbit") + + def snapshot(fig: Any) -> Image.Image: + buf = io.BytesIO() + # Deliberately NOT bbox_inches="tight" here, unlike the static PNG: a + # tight box is recomputed per frame, and as the view rotates the artists' + # extent changes, so frames come out at different pixel sizes (354..436 + # px wide for the bundled fixture). A GIF pastes every frame onto the + # first frame's canvas, so that shows up as jitter and clipped edges. + # The full canvas is a fixed figsize x dpi for every frame. + fig.savefig(buf, format="png", dpi=ANIMATE_DPI) + buf.seek(0) + return Image.open(buf).convert("RGB") + + images: list[Image.Image] = [] + if mode == ORBIT: + # The geometry never changes, so draw once and only move the camera. + fig = plt.figure(figsize=ANIMATE_FIGSIZE) + ax = fig.add_subplot(111, projection="3d") + _draw_scene(ax, manifest) + elev = ax.elev + for index in range(frames): + ax.view_init(elev=elev, azim=index * 360.0 / frames) + images.append(snapshot(fig)) + plt.close(fig) + else: + # More frames than trajectory samples would re-render figures that are + # pixel-identical (Pillow collapses them on write anyway), so cap it and + # step one sample per frame at the limit. + frames = min(frames, samples) + for index in range(frames): + upto = max(1, round((index + 1) * samples / frames)) + fig = plt.figure(figsize=ANIMATE_FIGSIZE) + ax = fig.add_subplot(111, projection="3d") + _draw_scene(ax, manifest, upto=upto) + images.append(snapshot(fig)) + plt.close(fig) + + out_path.parent.mkdir(parents=True, exist_ok=True) + images[0].save( + out_path, + save_all=True, + append_images=images[1:], + duration=frame_ms, + loop=0, + optimize=True, + ) + return out_path + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -192,13 +336,52 @@ def main() -> None: parser.add_argument( "--out", type=Path, - default=_DEFAULT_OUT, - help="output PNG path (default: docs/assets/scene_3d.png)", + default=None, + help=( + "output path (default: docs/assets/scene_3d.png, " + "or docs/assets/scene_3d.gif with --animate)" + ), + ) + parser.add_argument( + "--animate", + action="store_true", + help="export an animated GIF instead of a static PNG", + ) + parser.add_argument( + "--animate-mode", + choices=(ORBIT, TRAJECTORY), + default=ORBIT, + help=( + f"{ORBIT}: sweep the viewpoint right round the scene; " + f"{TRAJECTORY}: fixed viewpoint, object walks its path " + f"(default: {ORBIT})" + ), + ) + parser.add_argument( + "--frames", + type=int, + default=ANIMATE_FRAMES, + help=f"animation frame count (default: {ANIMATE_FRAMES})", + ) + parser.add_argument( + "--frame-ms", + type=int, + default=ANIMATE_MS, + help=f"per-frame duration in milliseconds (default: {ANIMATE_MS})", ) args = parser.parse_args() manifest = _load_manifest(args.manifest) - out_path = render(manifest, args.out) + if args.animate: + out_path = render_animation( + manifest, + args.out or _DEFAULT_ANIMATE_OUT, + mode=args.animate_mode, + frames=args.frames, + frame_ms=args.frame_ms, + ) + else: + out_path = render(manifest, args.out or _DEFAULT_OUT) size = out_path.stat().st_size print(f"wrote {out_path} ({size} bytes)") diff --git a/tests/test_view_scene_3d_animation.py b/tests/test_view_scene_3d_animation.py new file mode 100644 index 0000000..268d6ff --- /dev/null +++ b/tests/test_view_scene_3d_animation.py @@ -0,0 +1,121 @@ +"""Headless smoke tests for the 3D viewer's animated export. + +Kept in its own module (rather than added to ``test_view_scene_3d.py``) so the +animation coverage is independent of the static-render tests. + +``matplotlib``/``Pillow`` are not package dependencies, so every test skips +cleanly when they are absent. Nothing here needs a display: the script selects +the Agg backend itself. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +import pytest + +_ROOT = Path(__file__).resolve().parent.parent +_SCRIPT = _ROOT / "scripts" / "view_scene_3d.py" +_MANIFEST = _ROOT / "tests" / "fixtures" / "manifest_golden" / "mtmc.json" + + +def _viewer() -> Any: + """Load the viewer script as a module (it is a script, not a package member).""" + pytest.importorskip("matplotlib") + pytest.importorskip("PIL") + spec = importlib.util.spec_from_file_location("view_scene_3d", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _frames(path: Path) -> list[tuple[int, int]]: + from PIL import Image, ImageSequence + + with Image.open(path) as img: + return [frame.size for frame in ImageSequence.Iterator(img)] + + +def test_orbit_animation_writes_a_non_empty_gif(tmp_path: Path) -> None: + viewer = _viewer() + out = tmp_path / "orbit.gif" + written = viewer.render_animation( + viewer._load_manifest(_MANIFEST), out, mode=viewer.ORBIT, frames=4 + ) + + assert written == out + assert out.stat().st_size > 0 + assert len(_frames(out)) > 1 # genuinely animated, not a single still + + +def test_orbit_frames_all_share_one_canvas_size(tmp_path: Path) -> None: + """A GIF pastes every frame onto the first frame's canvas. + + Saving frames with ``bbox_inches="tight"`` would size each one to its own + artist extent — which changes as the view rotates — and the animation would + jitter and clip. Pin that they are uniform. + """ + viewer = _viewer() + out = tmp_path / "orbit.gif" + viewer.render_animation(viewer._load_manifest(_MANIFEST), out, mode=viewer.ORBIT, frames=6) + + assert len(set(_frames(out))) == 1 + + +def test_trajectory_animation_writes_a_non_empty_gif(tmp_path: Path) -> None: + viewer = _viewer() + out = tmp_path / "walk.gif" + viewer.render_animation(viewer._load_manifest(_MANIFEST), out, mode=viewer.TRAJECTORY, frames=4) + + assert out.stat().st_size > 0 + assert len(_frames(out)) > 1 + + +def test_trajectory_frames_are_capped_at_the_sample_count(tmp_path: Path) -> None: + """Asking for more frames than trajectory samples must not render duplicates.""" + viewer = _viewer() + manifest = viewer._load_manifest(_MANIFEST) + samples = viewer._trajectory_length(manifest) + assert samples > 0 + + out = tmp_path / "walk.gif" + viewer.render_animation(manifest, out, mode=viewer.TRAJECTORY, frames=samples + 50) + + assert len(_frames(out)) <= samples + + +def test_static_png_path_is_unaffected(tmp_path: Path) -> None: + """The default export is still a single PNG.""" + viewer = _viewer() + out = tmp_path / "scene.png" + written = viewer.render(viewer._load_manifest(_MANIFEST), out) + + assert written == out + assert out.stat().st_size > 0 + assert out.read_bytes().startswith(b"\x89PNG") + + +def test_unknown_mode_and_bad_frame_count_are_rejected(tmp_path: Path) -> None: + viewer = _viewer() + manifest = viewer._load_manifest(_MANIFEST) + + with pytest.raises(ValueError, match="unknown animation mode"): + viewer.render_animation(manifest, tmp_path / "x.gif", mode="spin") + with pytest.raises(ValueError, match="frames must be >= 1"): + viewer.render_animation(manifest, tmp_path / "x.gif", frames=0) + + +def test_trajectory_mode_rejects_a_manifest_with_no_trajectory(tmp_path: Path) -> None: + """Orbit still works on a camera-only scene; walking a path does not.""" + viewer = _viewer() + manifest = viewer._load_manifest(_MANIFEST) + cameras_only = {"cameras": manifest["cameras"]} + + with pytest.raises(ValueError, match="no trajectory to animate"): + viewer.render_animation(cameras_only, tmp_path / "x.gif", mode=viewer.TRAJECTORY) + + out = viewer.render_animation(cameras_only, tmp_path / "orbit.gif", frames=2) + assert out.stat().st_size > 0