Skip to content
Merged
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
191 changes: 191 additions & 0 deletions benchmarks/animator_backend_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Benchmark: render one CBF scene through every ``CBFAnimator`` backend.

Runs the README quick-start CBF simulation (unicycle reach-avoid: the robot
drives to a goal while a CBF safety filter keeps it clear of an obstacle) once,
then renders the *same* declarative scene through each available backend and
reports wall-clock render time and output-file size. This is the reproducible
source of the backend-comparison table in the Manim-2D-backend PR.

The plotly row is skipped if plotly is not installed; the ``manim-*`` rows are
skipped (with a note) if the ``manim`` extra is absent. Set ``CBFKIT_TEST_MODE``
to shrink the simulation for a quick smoke run.

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

Run:

python benchmarks/animator_backend_comparison.py
"""

import os
import platform
import sys
import tempfile
import time

import jax.numpy as jnp
import numpy as np
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.animator import CBFAnimator
from cbfkit.utils.animators.deps import _HAS_MANIM

# Scene constants (identical to examples/unicycle/reach_goal/manim_2d_animation.py).
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


def run_simulation():
"""Run the quick-start CBF sim and return ``(states, num_frames)``."""
initial_state = jnp.array([0.0, 0.0, 0.0])
actuation_limits = jnp.array([5.0, jnp.pi])
num_steps = 500 if not os.getenv("CBFKIT_TEST_MODE") else 50

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

@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_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))

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,
)
states = np.asarray(results.states)
return states, states.shape[0]


def build_animator(states, backend):
"""Construct a CBFAnimator on ``states`` with the shared scene applied."""
anim = CBFAnimator(
states,
dt=DT,
backend=backend,
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()
return anim


def _fmt_size(num_bytes):
for unit in ("B", "KiB", "MiB", "GiB"):
if num_bytes < 1024 or unit == "GiB":
return f"{num_bytes:.0f} {unit}" if unit == "B" else f"{num_bytes:.1f} {unit}"
num_bytes /= 1024


def time_backend(states, backend):
"""Render ``states`` through ``backend``; return ``(seconds, bytes, fmt)``."""
ext = "html" if backend == "plotly" else "mp4"
with tempfile.TemporaryDirectory() as tmp:
out_path = os.path.join(tmp, f"bench_out.{ext}")
anim = build_animator(states, backend)
start = time.time()
saved = anim.save(out_path)
elapsed = time.time() - start
size = os.path.getsize(saved)
return elapsed, size, ext.upper()


def _plotly_available():
try:
import plotly # noqa: F401
except ImportError:
return False
return True


def main():
print("Running CBF simulation (unicycle reach-avoid)...")
states, num_frames = run_simulation()
print(f"Simulated {num_frames} frames.\n")

backends = []
if _plotly_available():
backends.append("plotly")
else:
print("plotly not installed — skipping the plotly row.")
backends.append("matplotlib")
if _HAS_MANIM:
backends += ["manim-low", "manim-medium", "manim-high"]
else:
print("manim extra not installed — skipping the manim-* rows.")

if os.getenv("CBFKIT_TEST_MODE"):
print("CBFKIT_TEST_MODE set: timing matplotlib only as a smoke check.")
backends = [b for b in backends if b == "matplotlib"]

header = f"{'Backend':<14} {'Render time':>12} {'Output size':>12} {'Format':<6}"
print("\n" + header)
print("-" * len(header))
rows = []
for backend in backends:
elapsed, size, fmt = time_backend(states, backend)
rows.append((backend, elapsed, size, fmt))
print(f"{backend:<14} {elapsed:>10.1f} s {_fmt_size(size):>12} {fmt:<6}")

print(
f"\nEnvironment: Python {platform.python_version()}, "
f"manim {'installed' if _HAS_MANIM else 'absent'}, {num_frames} frames."
)
return rows


if __name__ == "__main__":
sys.exit(0 if main() is not None else 1)
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
Loading