Openscvx Logo¶
6-DOF quadrotor tracing out the OpenSCvx logo
This example demonstrates trajectory planning for a quadrotor tracing out the OpenSCvx logo. The problem includes:
- 6-DOF rigid body dynamics (position, velocity, attitude quaternion, angular velocity)
- Six sequential hoop (gate) constraints along the path (same model as
drone_racing.py) - BYOF dynamics for the angle between the body boresight and the moving logo target
File: examples/drone/openscvx_logo.py
import os
import sys
import jax.numpy as jnp
import numpy as np
import numpy.linalg as la
current_dir = os.path.dirname(os.path.abspath(__file__))
grandparent_dir = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(grandparent_dir)
import openscvx as ox
from examples.plotting_viser import (
create_animated_plotting_server,
create_snapshot_plotting_server,
)
from openscvx import ByofSpec, Free, Minimize, Problem
from openscvx.utils import rot
# Prompt the user to instal svgpathtools if not already installed via pip
try:
import svgpathtools # noqa: F401
except ImportError:
print("svgpathtools not found. Please install it with: pip install svgpathtools")
sys.exit(1)
from examples.drone.logo_utils._quadrotor_mesh import make_quadrotor_mesh
from examples.drone.logo_utils._svg_path_utils import get_svg_path_function
# -----------------------------------------------------------------------------
# SVG path utilities
# -----------------------------------------------------------------------------
# Single continuous path (openscvx_logo_single.svg); path 0 is the logo outline.
_OPENCVX_LOGO_PATH_INDICES = [0]
svg_path_function = get_svg_path_function(
os.path.join(current_dir, "logo_utils", "openscvx_logo_single.svg"),
path_indices=_OPENCVX_LOGO_PATH_INDICES,
preserve_aspect_ratio=True,
)
# -----------------------------------------------------------------------------
# JAX helpers (numeric versions of spatial utilities)
# -----------------------------------------------------------------------------
def qdcm(q: jnp.ndarray) -> jnp.ndarray:
"""Quaternion to DCM using [w, x, y, z] convention."""
q_norm = jnp.linalg.norm(q)
q_norm = jnp.where(q_norm > 1e-12, q_norm, 1e-12)
w, qx, qy, qz = q / q_norm
return jnp.array(
[
[1 - 2 * (qy**2 + qz**2), 2 * (qx * qy - qz * w), 2 * (qx * qz + qy * w)],
[2 * (qx * qy + qz * w), 1 - 2 * (qx**2 + qz**2), 2 * (qy * qz - qx * w)],
[2 * (qx * qz - qy * w), 2 * (qy * qz + qx * w), 1 - 2 * (qx**2 + qy**2)],
]
)
def SSMP(w: jnp.ndarray) -> jnp.ndarray:
"""4x4 skew-symmetric matrix for quaternion dynamics."""
wx, wy, wz = w[0], w[1], w[2]
return jnp.array(
[
[0, -wx, -wy, -wz],
[wx, 0, wz, -wy],
[wy, -wz, 0, wx],
[wz, wy, -wx, 0],
]
)
def SSM(w: jnp.ndarray) -> jnp.ndarray:
"""3x3 skew-symmetric matrix for cross products."""
wx, wy, wz = w[0], w[1], w[2]
return jnp.array([[0, -wz, wy], [wz, 0, -wx], [-wy, wx, 0]])
# -----------------------------------------------------------------------------
# Problem setup
# -----------------------------------------------------------------------------
n = 500 # Number of Nodes
total_time = 25.0 # Total time for the simulation (wider logo than ACL)
# Fixed initial and final positions
initial_pos = np.array([-5.0, 0.0, 5.0])
final_pos = np.array([-2.0, 0.0, 5.0])
# State components (like other drone examples)
position = ox.State("position", shape=(3,))
position.max = np.array([10.0, 10.0, 10.0])
position.min = np.array([-10.0, -10.0, -5.0])
position.initial = [Free(initial_pos[0]), Free(initial_pos[1]), Free(initial_pos[2])]
position.final = [Free(final_pos[0]), Free(final_pos[1]), Free(final_pos[2])]
velocity = ox.State("velocity", shape=(3,))
velocity.max = np.array([20.0, 20.0, 20.0])
velocity.min = np.array([-20.0, -20.0, -20.0])
velocity.initial = [0.0, 0.0, 0.0]
velocity.final = [0.0, 0.0, 0.0]
attitude = ox.State("attitude", shape=(4,)) # Quaternion [w, x, y, z]
attitude.max = np.array([1.0, 1.0, 1.0, 1.0])
attitude.min = np.array([-1.0, -1.0, -1.0, -1.0])
attitude.initial = [Free(1.0), Free(0), Free(0), Free(0)]
attitude.final = [Free(1.0), Free(0), Free(0), Free(0)]
angular_velocity = ox.State("angular_velocity", shape=(3,))
angular_velocity.max = np.array([10.0, 10.0, 10.0])
angular_velocity.min = np.array([-10.0, -10.0, -10.0])
angular_velocity.initial = np.array([0.0, 0.0, 0.0])
angular_velocity.final = [Free(0), Free(0), Free(0)]
angle_metric = ox.State("angle_metric", shape=(1,)) # Viewing angle (minimize at final)
angle_metric.max = np.array([0.3])
angle_metric.min = np.array([0.0])
angle_metric.initial = np.array([0.0])
angle_metric.final = [Minimize(0.0)]
time_state = ox.Time(
initial=0.0,
final=total_time,
min=0.0,
max=total_time,
time_dilation_min=0.001 * total_time,
time_dilation_max=3.0 * total_time,
)
# Control components
thrust_force = ox.Control("thrust_force", shape=(3,))
thrust_force.max = np.array([0.0, 0.0, 4.179446268 * 9.81])
thrust_force.min = np.array([0.0, 0.0, 0.0])
thrust_force.guess = np.repeat(np.array([[0.0, 0.0, 10.0]]), n, axis=0)
torque = ox.Control("torque", shape=(3,))
torque.max = np.array([10.0, 10.0, 1.0])
torque.min = np.array([-10.0, -10.0, -1.0])
torque.guess = np.zeros((n, 3))
# Offset from the SVG path for the drone to follow
path_offset = np.array([0.0, 0.0, 3.0]) # 3 meters above the path
# Boresight vector in body frame (x-axis of the drone)
boresight_body = jnp.array([1.0, 0.0, 0.0])
def get_kp_pose(t_normalized):
"""
Get the target position along the SVG path at normalized time t, applying rotations about y and
x axes.
Args:
t_normalized: Normalized time in [0, 1] (scalar or array)
Returns:
Target position [x, y, z]
"""
original_pos = jnp.array(svg_path_function(t_normalized))
scale_factor = 0.5
scaled_pos = original_pos * scale_factor
theta_y = jnp.pi / 2
cos_y = jnp.cos(theta_y)
sin_y = jnp.sin(theta_y)
R_y = jnp.array([[cos_y, 0, sin_y], [0, 1, 0], [-sin_y, 0, cos_y]])
theta_x = jnp.pi / 2
cos_x = jnp.cos(theta_x)
sin_x = jnp.sin(theta_x)
R_x = jnp.array([[1, 0, 0], [0, cos_x, -sin_x], [0, sin_x, cos_x]])
rotated_pos = R_x @ (R_y @ scaled_pos)
return rotated_pos + path_offset
# -----------------------------------------------------------------------------
# Hoops (same gate model as drone_racing.py)
# -----------------------------------------------------------------------------
hoop_radii = np.array([0.5, 1e-4, 0.5])
_rot_z_90 = np.array(
[
[np.cos(np.pi / 2), -np.sin(np.pi / 2), 0.0],
[np.sin(np.pi / 2), np.cos(np.pi / 2), 0.0],
[0.0, 0.0, 1.0],
]
)
_hoop_rot = _rot_z_90 @ rot
A_hoop = _hoop_rot @ np.diag(1.0 / hoop_radii) @ _hoop_rot.T
def _hoop_vertices(center: np.ndarray, radii: np.ndarray) -> list[np.ndarray]:
"""Gate corner vertices using the same orientation as ``A_hoop``."""
return [
center + _hoop_rot @ np.array([radii[0], 0.0, radii[2]]),
center + _hoop_rot @ np.array([-radii[0], 0.0, radii[2]]),
center + _hoop_rot @ np.array([-radii[0], 0.0, -radii[2]]),
center + _hoop_rot @ np.array([radii[0], 0.0, -radii[2]]),
]
# COM offset from the moving logo target; hoops are placed along the mission timeline
_hoop_com_offset = np.array([-2.0, 0.0, 1.2])
_hoop_t_norms = [0.07, 0.25, 0.43, 0.57, 0.75, 0.93]
hoop_nodes = [int(round(t * (n - 1))) for t in _hoop_t_norms]
hoop_centers = [
np.asarray(get_kp_pose(t), dtype=np.float64) + _hoop_com_offset for t in _hoop_t_norms
]
hoop_centers[0] = 0.6 * initial_pos + 0.4 * hoop_centers[0]
hoop_centers[-1] = 0.5 * hoop_centers[-1] + 0.5 * final_pos
hoop_center_params = [
ox.Parameter(f"hoop_{i}_center", shape=(3,), value=center)
for i, center in enumerate(hoop_centers)
]
hoop_vertices = [_hoop_vertices(center, hoop_radii) for center in hoop_centers]
# -----------------------------------------------------------------------------
# BYOF: angle_metric derivative (uses get_kp_pose, not easily symbolic)
# -----------------------------------------------------------------------------
def angle_to_target_components(pos: jnp.ndarray, att: jnp.ndarray, t_val: float) -> jnp.ndarray:
"""Compute angle between boresight and vector to target from position, attitude, and time."""
t_normalized = t_val / total_time
target_pos = get_kp_pose(t_normalized)
to_target_inertial = target_pos - pos
boresight_inertial = qdcm(att) @ boresight_body
to_target_norm = jnp.linalg.norm(to_target_inertial)
boresight_norm = jnp.linalg.norm(boresight_inertial)
lhs = jnp.dot(to_target_inertial, boresight_inertial) / (to_target_norm * boresight_norm)
angle = jnp.arccos(lhs)
return angle
def dynamics_angle_metric(x, u, node, params):
"""BYOF dynamics for angle_metric: derivative = time-weighted angle to target."""
pos = x[position.slice]
att = x[attitude.slice]
t_val = x[time_state.slice][0]
angle = angle_to_target_components(pos, att, t_val)
return jnp.array([angle])
# -----------------------------------------------------------------------------
# Symbolic dynamics (drone: same as drone_racing / cinema_vp)
# -----------------------------------------------------------------------------
m = 1.0
g_const = -9.18
J_b = 1e-5 * jnp.array([1.0, 1.0, 1.0])
J_b_inv = 1.0 / J_b
J_b_diag = ox.linalg.Diag(J_b)
# Normalize quaternion for dynamics
q_norm = ox.linalg.Norm(attitude)
attitude_normalized = attitude / q_norm
dynamics = {
"position": velocity,
"velocity": (1.0 / m) * ox.spatial.QDCM(attitude_normalized) @ thrust_force
+ ox.Constant(np.array([0, 0, g_const], dtype=np.float64)),
"attitude": 0.5 * ox.spatial.SSMP(angular_velocity) @ attitude_normalized,
"angular_velocity": ox.linalg.Diag(J_b_inv)
@ (torque - ox.spatial.SSM(angular_velocity) @ J_b_diag @ angular_velocity),
"time": 1.0,
}
def angle_to_target(x_):
"""Compute the angle between boresight vector and vector to target (for analysis/plotting)."""
pos = x_[:3]
att = x_[6:10]
t_val = x_[14]
return angle_to_target_components(pos, att, t_val)
# -----------------------------------------------------------------------------
# States, controls, constraints, initial guess
# -----------------------------------------------------------------------------
states = [position, velocity, attitude, angular_velocity, angle_metric, time_state]
controls = [thrust_force, torque]
constraints = []
for state in states:
constraints.extend([ox.ctcs(state <= state.max), ox.ctcs(state.min <= state)])
for node, hoop_center_param in zip(hoop_nodes, hoop_center_params):
constraints.append(
(
ox.linalg.Norm(
A_hoop @ position - A_hoop @ hoop_center_param,
ord="inf",
)
<= 1.0
)
.convex()
.at([node])
)
byof: ByofSpec = {
"dynamics": {
"angle_metric": dynamics_angle_metric,
}
}
# Initial guess: keyframed COM path through hoops, then LoS attitudes toward logo target
position_bar = ox.init.linspace(
keyframes=[initial_pos] + hoop_centers + [final_pos],
nodes=[0] + hoop_nodes + [n - 1],
)
velocity_bar = np.zeros((n, 3))
angular_velocity_bar = np.zeros((n, 3))
angle_metric_bar = np.zeros((n, 1))
time_bar = np.linspace(0, total_time, n).reshape(-1, 1)
b = np.array([1, 0, 0])
attitude_bar = np.tile([1.0, 0.0, 0.0, 0.0], (n, 1))
for k in range(n):
t_normalized = k / (n - 1)
target_pos = np.asarray(get_kp_pose(t_normalized))
a = target_pos - position_bar[k]
if np.linalg.norm(a) > 1e-6:
q_xyz = np.cross(b, a)
q_w = np.sqrt(la.norm(a) ** 2 + la.norm(b) ** 2) + np.dot(a, b)
q_no_norm = np.hstack((q_w, q_xyz))
q = q_no_norm / la.norm(q_no_norm)
attitude_bar[k] = q
position.guess = position_bar
velocity.guess = velocity_bar
attitude.guess = attitude_bar
angular_velocity.guess = angular_velocity_bar
angle_metric.guess = angle_metric_bar
time_state.guess = time_bar
# -----------------------------------------------------------------------------
# Problem
# -----------------------------------------------------------------------------
problem = Problem(
dynamics=dynamics,
states=states,
controls=controls,
time=time_state,
constraints=constraints,
N=n,
byof=byof,
algorithm={
"autotuner": {"type": "RampProximalWeight", "ramp_factor": 1.06, "lam_prox_max": 1e3},
"lam_prox": 1e-3,
"lam_vc": 1e1,
"lam_cost": 6e-3,
},
float_dtype="float64",
discretizer=ox.DiscretizeLinearizeVectorize(diffrax_kwargs={"atol": 1e-4}),
# solver={
# "cvx_solver": "Mosek",
# "solver_args": {"canon_backend": "COO", "enforce_dpp": True}
# },
)
plotting_dict = {
"svg_path_function": svg_path_function,
"path_offset": path_offset,
"total_time": total_time,
"initial_pos": initial_pos,
"final_pos": final_pos,
"boresight_body": boresight_body,
"moving_subject": True,
"get_kp_pose": get_kp_pose,
"angle_to_target": angle_to_target,
"extend_boresight": True,
"relative_vector": True,
"logo_trace_color": (0, 255, 255),
"vertices": hoop_vertices,
"gate_centers": hoop_centers,
"A_gate": A_hoop,
"A_gate_c_params": [A_hoop @ center for center in hoop_centers],
}
if __name__ == "__main__":
problem.initialize()
results = problem.solve()
results = problem.post_process()
results.update_plotting_data(**plotting_dict)
# Define the plane the logo actually lies in (from three points on the logo)
p0 = np.asarray(get_kp_pose(0.0))
p1 = np.asarray(get_kp_pose(0.33))
p2 = np.asarray(get_kp_pose(0.67))
v1 = p1 - p0
v2 = p2 - p0
plane_normal = np.cross(v1, v2)
nrm = np.linalg.norm(plane_normal)
if nrm < 1e-10:
plane_normal = np.array([1.0, 0.0, 0.0]) # fallback
else:
plane_normal = plane_normal / nrm
plane_point = p0
def ray_plane_intersection(ray_origin, ray_direction, p_plane, n_plane):
denom = np.dot(ray_direction, n_plane)
if abs(denom) < 1e-10:
return None
t = np.dot(p_plane - ray_origin, n_plane) / denom
if t < 0:
return None
return ray_origin + t * ray_direction
pos_traj = np.asarray(results.trajectory["position"])
tt = np.asarray(results.trajectory["time"]).reshape(-1)
total_time_f = float(total_time)
target_traj = np.stack(
[np.asarray(get_kp_pose(float(t) / total_time_f)) for t in tt],
axis=0,
)
traced_path = []
for i in range(len(pos_traj)):
rel = target_traj[i] - pos_traj[i]
nrm = np.linalg.norm(rel)
rel_dir = rel / nrm
pt = ray_plane_intersection(pos_traj[i], rel_dir, plane_point, plane_normal)
traced_path.append(pt if pt is not None else target_traj[i])
results["traced_path_on_plane"] = np.array(traced_path, dtype=np.float64)
results["logo_plane_point"] = plane_point
results["logo_plane_normal"] = plane_normal
vehicle_mesh = make_quadrotor_mesh()
vehicle_mesh_color = (200, 200, 210)
server = create_animated_plotting_server(
results,
thrust_key="thrust_force",
show_grid=True,
dark_mode=True,
vehicle_mesh=vehicle_mesh,
vehicle_mesh_color=vehicle_mesh_color,
)
create_snapshot_plotting_server(
results,
initial_n_snapshots=5,
show_grid=True,
show_targets=False,
logo_trace_point_size=0.06,
ghost_opacity=1.0,
vehicle_mesh=vehicle_mesh,
vehicle_mesh_color=vehicle_mesh_color,
dark_mode=True,
)
server.sleep_forever()