Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ python benchmarks/qp_solver_comparison.py

#### Multi-robot 3D coordination

Cinematic 3D simulation rendering with Manim. Multi-robot reach-avoid in 3D, rendered via CBFKit's Manim backend. Shows the visualization stack scales from quick matplotlib plots to publication-quality 3D animations.
Cinematic 3D simulation rendering with Manim. Multi-robot reach-avoid in 3D, rendered via CBFKit's Manim backend. Shows the visualization stack scales from quick matplotlib plots to publication-quality 3D animations. The same backend also renders 2D `CBFAnimator` scenes: pass `backend="manim"` (or `"manim-<low|medium|high|production>"`) to any `CBFAnimator` and `save("out.mp4")` produces a publication-quality video (`.gif` also supported).

<p align="center"><img src="https://raw.githubusercontent.com/bardhh/cbfkit/main/media/showcase/multi_robot_3d.gif" width="70%" alt="Manim 3D render of multi-robot reach-avoid"></p>

Expand Down
116 changes: 116 additions & 0 deletions examples/unicycle/reach_goal/manim_2d_animation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Tutorial: render a CBF-filtered unicycle reach-avoid run with the Manim 2D backend.

Runs the README quick-start simulation (unicycle drives to a goal while a CBF
safety filter keeps it clear of an obstacle), then renders the trajectory with
``CBFAnimator(backend="manim-medium")``. This is the script that produced
``media/showcase/manim_2d_animator.gif``.

Requires the ``manim`` extra (``pip install cbfkit[manim]``) plus ffmpeg, and
on macOS the cairo/pango libraries (``brew install ffmpeg cairo pango``).
"""

import os

import jax.numpy as jnp
from jax import jit

from cbfkit.certificates import concatenate_certificates, rectify_relative_degree
from cbfkit.certificates.barrier_functions import ellipsoidal_barrier_factory
from cbfkit.certificates.conditions.barrier_conditions.zeroing_barriers import linear_class_k
from cbfkit.controllers.cbf_clf import vanilla_cbf_clf_qp_controller
from cbfkit.estimators import naive
from cbfkit.integration import runge_kutta_4
from cbfkit.sensors import perfect
from cbfkit.simulation import simulator
from cbfkit.systems.unicycle.models.olfatisaber2002approximate.dynamics import (
approx_unicycle_dynamics,
)
from cbfkit.utils.animators.deps import _HAS_MANIM

# Simulation Parameters
initial_state = jnp.array([0.0, 0.0, 0.0])
actuation_limits = jnp.array([5.0, jnp.pi])
goal = jnp.array([4.0, 0.0])
obstacle_center = jnp.array([2.0, 0.5, 0.0])
obstacle_radii = jnp.array([0.5, 0.5])
dt = 1e-2
num_steps = 500 if not os.getenv("CBFKIT_TEST_MODE") else 50

# Dynamics
dynamics = approx_unicycle_dynamics(lam=1.0) # state: [x, y, theta]


# Nominal controller - drives toward the goal, ignorant of the obstacle
@jit
def nominal_controller(t, state, key, data):
x, y, th = state
heading = jnp.arctan2(goal[1] - y, goal[0] - x)
return (
jnp.array(
[
jnp.linalg.norm(jnp.array([x - goal[0], y - goal[1]])), # speed
jnp.arctan2(jnp.sin(heading - th), jnp.cos(heading - th)), # steering
]
),
{},
)


# CBF barrier around the obstacle
cbf_factory, _, _ = ellipsoidal_barrier_factory(
system_position_indices=(0, 1),
obstacle_position_indices=(0, 1),
ellipsoid_axis_indices=(0, 1),
)
barrier = rectify_relative_degree(
function=cbf_factory(obstacle_center, obstacle_radii),
system_dynamics=dynamics,
state_dim=3,
form="exponential",
)(certificate_conditions=linear_class_k(10.0))

# Safety-filtered controller
controller = vanilla_cbf_clf_qp_controller(
control_limits=actuation_limits,
dynamics_func=dynamics,
barriers=concatenate_certificates(barrier),
)

results = simulator.execute(
x0=initial_state,
dt=dt,
num_steps=num_steps,
dynamics=dynamics,
integrator=runge_kutta_4,
nominal_controller=nominal_controller,
controller=controller,
sensor=perfect,
estimator=naive,
)
print(f"Final position: ({results.states[-1, 0]:.2f}, {results.states[-1, 1]:.2f})")

# Render with the Manim 2D backend (skipped in test mode / without manim)
if os.getenv("CBFKIT_TEST_MODE"):
print("CBFKIT_TEST_MODE set: skipping Manim render.")
elif not _HAS_MANIM:
print("Manim not found. Rendering disabled. Install 'cbfkit[manim]' to enable it.")
else:
import numpy as np

from cbfkit.utils.animator import CBFAnimator

anim = CBFAnimator(
np.asarray(results.states),
dt=dt,
backend="manim-medium", # or "manim" / "manim-low" for quicker renders
title="CBF Safety Filter: Unicycle Reach-Avoid",
aspect="equal",
x_lim=(-0.7, 4.7),
y_lim=(-1.2, 1.8),
)
anim.add_goal(np.asarray(goal), radius=0.25, color="g", label="Goal")
anim.add_obstacle(np.asarray(obstacle_center[:2]), radius=float(obstacle_radii[0]), alpha=0.3)
anim.add_agent(x_idx=0, y_idx=1, body_radius=0.12, body_color="tab:blue", trail=True)
anim.show_time()
out = anim.save("manim_2d_unicycle_reach_avoid.mp4")
print(f"Animation saved to {out}")
Binary file added media/showcase/manim_2d_animator.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ ignore_missing_imports = True
[mypy-matplotlib.*]
ignore_missing_imports = True

[mypy-manim.*]
ignore_missing_imports = True

[mypy-scipy.*]
ignore_missing_imports = True

Expand Down
8 changes: 5 additions & 3 deletions src/cbfkit/utils/animators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
* ``"matplotlib"`` — generates MP4 / GIF animations via ``FuncAnimation``.
* ``"plotly"`` (default) — generates interactive HTML files with play/pause
controls and a timeline slider. Requires ``pip install cbfkit[plotly]``.
* ``"manim"`` — high-quality 3D animations (MP4) via Manim. Currently only
available for 3D multi-robot scenes via :func:`visualize_3d_multi_robot`.
Requires ``pip install cbfkit[manim]``.
* ``"manim"`` / ``"manim-<quality>"`` — high-quality MP4 / GIF animations via
Manim, for both :class:`CBFAnimator` 2D scenes and 3D multi-robot scenes
(:func:`visualize_3d_multi_robot`). Requires ``pip install cbfkit[manim]``.
"""

from .animator import CBFAnimator
from .manim_backend import CBFAnimator2DScene
from .config import AnimationConfig, DEFAULT_CONFIG
from .deps import (
_HAS_MANIM,
Expand All @@ -29,6 +30,7 @@
__all__ = [
"AnimationConfig",
"CBFAnimator",
"CBFAnimator2DScene",
"DEFAULT_CONFIG",
"save_animation",
"_HAS_MATPLOTLIB",
Expand Down
42 changes: 30 additions & 12 deletions src/cbfkit/utils/animators/animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@

from .config import AnimationConfig
from .deps import _require_manim, _require_matplotlib, _require_plotly
from .manim_backend import _ManimMixin
from .matplotlib_backend import _MatplotlibMixin
from .plotly_backend import _PlotlyMixin


class CBFAnimator(_MatplotlibMixin, _PlotlyMixin):
class CBFAnimator(_MatplotlibMixin, _PlotlyMixin, _ManimMixin):
"""Declarative 2D trajectory animator for CBFKit simulations.

Build an animation by chaining ``add_*`` calls, then :meth:`save` or
Expand All @@ -31,7 +32,9 @@ class CBFAnimator(_MatplotlibMixin, _PlotlyMixin):
aspect : str or None
Axis aspect ratio (e.g. ``"equal"``). *None* keeps matplotlib default.
backend : str
``"plotly"`` (default) or ``"matplotlib"``.
``"plotly"`` (default), ``"matplotlib"``, or ``"manim"`` /
``"manim-<quality>"`` with quality one of ``low``, ``medium``,
``high``, ``production`` (requires ``pip install cbfkit[manim]``).
config : AnimationConfig, optional
Animation parameters. Uses :data:`DEFAULT_CONFIG` when *None*.
"""
Expand All @@ -54,15 +57,16 @@ def __init__(
)

self._backend = backend
self._manim_quality: Optional[str] = None
if backend == "matplotlib":
_require_matplotlib()
elif backend.startswith("manim"):
# The 2D Manim backend is unimplemented whether or not Manim is
# installed, so surface that before requiring the optional dependency.
raise NotImplementedError(
"Manim 2D backend not yet implemented. "
"Use visualize_3d_multi_robot(backend='manim') for 3D scenes."
)
# Validate the quality suffix before requiring the optional
# dependency so a typo'd backend string fails loudly either way.
from cbfkit.utils.visualization import _parse_manim_backend

self._manim_quality = _parse_manim_backend(backend)
_require_manim()
else:
_require_plotly()

Expand Down Expand Up @@ -396,45 +400,59 @@ def _compute_prediction(self, spec: dict, frame: int):
def build(self):
"""Create the figure and all static / dynamic artists.

Returns ``(fig, ax)`` for the matplotlib backend, or the Plotly
``Figure`` for the plotly backend.
Returns ``(fig, ax)`` for the matplotlib backend, the Plotly
``Figure`` for the plotly backend, or the configured
:class:`~cbfkit.utils.animators.manim_backend.CBFAnimator2DScene`
class for the manim backend.
"""
if self._backend == "plotly":
return self._build_plotly()
if self._backend.startswith("manim"):
return self._build_manim()
return self._build_matplotlib()

def animate(self):
"""Build (if needed) and create the animation object.

Returns a matplotlib ``FuncAnimation`` or a Plotly ``Figure``
(which already contains the animation frames).
Returns a matplotlib ``FuncAnimation``, a Plotly ``Figure``
(which already contains the animation frames), or the configured
Scene class for the manim backend (rendering happens in :meth:`save`).
"""
if self._backend == "plotly":
if self._fig is None:
self._build_plotly()
return self._fig
if self._backend.startswith("manim"):
return self._build_manim()
return self._animate_matplotlib()

def save(self, path: str, config: Optional[AnimationConfig] = None) -> str:
"""Animate (if needed) and save.

* matplotlib: saves MP4 (ffmpeg) or GIF (pillow fallback).
* plotly: saves an interactive ``.html`` file.
* manim: renders MP4 (or GIF if *path* ends in ``.gif``) at the
quality encoded in the backend string (``manim-<quality>``).

Returns the absolute path of the saved file.
"""
if self._backend == "plotly":
return self._save_plotly(path)
if self._backend.startswith("manim"):
return self._save_manim(path)
return self._save_matplotlib(path, config)

def show(self):
"""Animate (if needed) and display interactively.

* matplotlib: opens a matplotlib window.
* plotly: opens the default web browser.
* manim: renders and opens the video in the default player.
"""
if self._backend == "plotly":
return self._show_plotly()
if self._backend.startswith("manim"):
return self._show_manim()
return self._show_matplotlib()

# -- properties ---------------------------------------------------------
Expand Down
Loading