Franka Fr3v2 Viewplanning¶
Franka FR3 v2 view planning — wrist camera viewcone + PoE FK.
Same kinematics and MuJoCo Menagerie visualisation as
franka_fr3v2_pick_place.py, but the task matches 7_dof_arm_vp.py:
move the end-effector while keeping several workspace points inside a
wrist-mounted camera field of view (continuous viewcone inequality).
Kinematics¶
Space-frame Product of Exponentials (PoE) with screw axes from
franka_fr3_v2/fr3v2.xml (see pick-place example for derivation).
Task¶
- Terminal EE position at a goal (1 cm tolerance).
- Viewcone: each viewpoint target must satisfy ||A_cone @ p_sensor|| - c^T @ p_sensor <= 0 with p_sensor = R_sb @ R_ee^T @ (p_target - p_ee).
Requires¶
pip install openscvx[lie]
(optional) pip install mujoco trimesh
File: examples/arm/franka_fr3v2_viewplanning.py
import os
import sys
from pathlib import Path
import jax.numpy as jnp
import numpy as np
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 openscvx.plotting import plot_controls, plot_scp_iterations
# =============================================================================
# FR3 v2 Kinematic Parameters (same as franka_fr3v2_pick_place.py)
# =============================================================================
N_JOINTS = 7
d1 = 0.333
d3 = 0.316
a4 = 0.0825
d5 = 0.384
d7 = 0.088
d_ee = 0.107
z1 = d1
z35 = d1 + d3
z567 = d1 + d3 + d5
angle_min = np.array([-2.7437, -1.7837, -2.9007, -3.0421, -2.8065, 0.5445, -3.0159])
angle_max = np.array([2.7437, 1.7837, 2.9007, -0.1518, 2.8065, 4.5169, 3.0159])
torque_max = np.array([87.0, 87.0, 87.0, 87.0, 12.0, 12.0, 12.0])
inertia = np.array([0.10, 0.08, 0.07, 0.06, 0.03, 0.02, 0.01])
q_home = np.array([0.0, 0.0, 0.0, -np.pi / 2, 0.0, np.pi / 2, -np.pi / 4])
screw_axes = np.array(
[
[0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[-z1, 0.0, 0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[z35, 0.0, -a4, 0.0, -1.0, 0.0],
[0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[z567, 0.0, 0.0, 0.0, -1.0, 0.0],
[0.0, d7, 0.0, 0.0, 0.0, -1.0],
]
)
T_home = np.array(
[
[1.0, 0.0, 0.0, d7],
[0.0, -1.0, 0.0, 0.0],
[0.0, 0.0, -1.0, z567 - d_ee],
[0.0, 0.0, 0.0, 1.0],
]
)
# =============================================================================
# Wrist camera (viewcone) — same convention as 7_dof_arm_vp.py
# =============================================================================
alpha_x = 6.0
alpha_y = 6.0
A_cone = np.diag(
[
1 / np.tan(np.pi / alpha_x),
1 / np.tan(np.pi / alpha_y),
0,
]
)
c = jnp.array([0, 0, 1])
norm_type = 2
R_sb = jnp.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]])
vp_targets = np.array(
[
[0.25, 0.00, 0.10],
[0.35, 0.00, 0.10],
[0.30, 0.05, 0.05],
]
)
# =============================================================================
# Discretisation
# =============================================================================
n = 9
total_time = 5.0
# =============================================================================
# States and controls
# =============================================================================
angle = ox.State("angle", shape=(N_JOINTS,))
angle.max = angle_max
angle.min = angle_min
angle.initial = np.clip(q_home, angle_min, angle_max)
angle.final = [("free", 0.0)] * N_JOINTS
velocity = ox.State("velocity", shape=(N_JOINTS,))
velocity.max = np.full(N_JOINTS, 2.5)
velocity.min = -velocity.max
velocity.initial = np.zeros(N_JOINTS)
velocity.final = np.zeros(N_JOINTS)
states = [angle, velocity]
torque = ox.Control("torque", shape=(N_JOINTS,))
torque.max = torque_max
torque.min = -torque_max
controls = [torque]
# =============================================================================
# Forward kinematics (PoE)
# =============================================================================
T_chain = ox.Constant(np.eye(4))
joint_transforms = {}
for j in range(N_JOINTS):
xi = ox.Constant(screw_axes[j])
T_chain = T_chain @ ox.lie.SE3Exp(xi * angle[j])
joint_transforms[f"T_j{j + 1}"] = T_chain
T_ee = T_chain @ ox.Constant(T_home)
p_ee = ox.Concat(T_ee[0, 3], T_ee[1, 3], T_ee[2, 3])
R_ee = T_ee[:3, :3]
# =============================================================================
# Dynamics
# =============================================================================
I_inv = ox.Constant(1.0 / inertia)
dynamics = {
"angle": velocity,
"velocity": I_inv * torque,
}
# =============================================================================
# Task: start / goal EE positions (same style as 7_dof_arm_vp)
# =============================================================================
home_ee_pos = np.array([0.3, 0.25, 0.25])
goal_ee_pos = np.array([0.3, -0.25, 0.25])
# =============================================================================
# Constraints
# =============================================================================
target = ox.Parameter("target", shape=(3,), value=goal_ee_pos)
constraints = []
for state in states:
constraints.extend([ox.ctcs(state <= state.max), ox.ctcs(state.min <= state)])
constraints.append(ox.ctcs(p_ee[2] >= 0.0))
ee_tolerance = 0.01
constraints.append((ox.linalg.Norm(p_ee - target, ord=2) <= ee_tolerance).at([n - 1]))
def g_vp(p_target):
p_s_s = R_sb @ R_ee.T @ (p_target - p_ee)
return ox.linalg.Norm(A_cone @ p_s_s, ord=norm_type) - (c.T @ p_s_s)
constraints.append(
ox.ctcs(
ox.Vmap(
lambda pose: g_vp(pose),
batch=vp_targets,
)
<= 0.0
)
)
# =============================================================================
# Initial guess (IK) — point camera boresight toward viewpoint centroid
# =============================================================================
mean_vp = np.mean(vp_targets, axis=0)
boresight_body = np.array(R_sb).T @ np.array(c)
def look_at_quat(ee_pos, target_pos):
"""Quaternion (wxyz) aligning body boresight with (target_pos - ee_pos)."""
d = target_pos - ee_pos
d = d / np.linalg.norm(d)
q_xyz = np.cross(boresight_body, d)
q_w = np.sqrt(np.dot(boresight_body, boresight_body) * np.dot(d, d)) + np.dot(boresight_body, d)
q = np.hstack(([q_w], q_xyz))
return q / np.linalg.norm(q)
q_home_orient = look_at_quat(home_ee_pos, mean_vp)
q_goal_orient = look_at_quat(goal_ee_pos, mean_vp)
angle.guess = ox.init.ik_interpolation(
keyframes=[
(home_ee_pos, q_home_orient),
(goal_ee_pos, q_goal_orient),
],
nodes=[0, n - 1],
screw_axes=screw_axes,
T_home=T_home,
angles_init=angle.initial,
angles_min=angle_min,
angles_max=angle_max,
)
angle.initial = np.clip(angle.guess[0], angle_min, angle_max)
velocity.guess = np.zeros((n, N_JOINTS))
torque.guess = np.zeros((n, N_JOINTS))
# =============================================================================
# Problem
# =============================================================================
time = ox.Time(
initial=0.0,
final=ox.Minimize(total_time),
min=0.0,
max=total_time,
)
problem = ox.Problem(
dynamics=dynamics,
states=states,
controls=controls,
time=time,
constraints=constraints,
N=n,
algorithm={
"lam_vb": 1e1,
"lam_vc": 1e2,
"autotuner": ox.AugmentedLagrangian(eta_lambda=1e0),
},
algebraic_prop={
"ee_position": p_ee,
**{name: T for name, T in joint_transforms.items()},
},
)
problem.settings.prp.dt = 0.01
# =============================================================================
# Main
# =============================================================================
if __name__ == "__main__":
print("Franka FR3 v2 View Planning — PoE FK + wrist camera viewcone")
print("=" * 60)
print(f"Nodes: {n} | View targets: {len(vp_targets)}")
print(f"Start EE (nominal): {home_ee_pos}")
print(f"Goal EE target: {target.value}")
print()
problem.initialize()
problem.solve()
results = problem.post_process()
ee_pos_traj = results.trajectory["ee_position"]
final_q = results.trajectory["angle"][-1]
final_ee = ee_pos_traj[-1]
tgt = target.value
err = np.linalg.norm(final_ee - tgt)
print()
print("Results:")
print(f" Final joint angles [deg]: {np.round(np.rad2deg(final_q), 1)}")
print(f" Final EE: [{final_ee[0]:.3f}, {final_ee[1]:.3f}, {final_ee[2]:.3f}]")
print(f" Goal: [{tgt[0]:.3f}, {tgt[1]:.3f}, {tgt[2]:.3f}]")
print(f" Position error: {err:.4f} m")
plot_scp_iterations(results).show()
plot_controls(results).show()
# =========================================================================
# Viser 3D
# =========================================================================
import jaxlie
from openscvx.plotting.viser import (
add_animated_trail,
add_animation_controls,
add_ghost_trajectory,
add_position_marker,
add_target_markers,
add_viewcone,
compute_velocity_colors,
create_server,
)
angle_traj = np.asarray(results.trajectory["angle"])
n_frames = len(angle_traj)
ee_pos = np.asarray(ee_pos_traj)
T_home_np = np.asarray(T_home)
ee_quats = np.zeros((n_frames, 4))
T_j7_traj = np.asarray(results.trajectory["T_j7"])
for t in range(n_frames):
T_ee_t = T_j7_traj[t] @ T_home_np
ee_quats[t] = jaxlie.SO3.from_matrix(T_ee_t[:3, :3]).wxyz
_joint_zero_pos = np.array(
[
[0.0, 0.0, z1],
[0.0, 0.0, z1],
[0.0, 0.0, z35],
[a4, 0.0, z35],
[0.0, 0.0, z567],
[0.0, 0.0, z567],
[d7, 0.0, z567],
]
)
keypoints = np.zeros((n_frames, N_JOINTS + 1, 3))
for t_idx in range(n_frames):
for k in range(N_JOINTS):
T_k = np.asarray(results.trajectory[f"T_j{k + 1}"])[t_idx]
q0 = np.append(_joint_zero_pos[k], 1.0)
keypoints[t_idx, k] = (T_k @ q0)[:3]
T_7 = np.asarray(results.trajectory["T_j7"])[t_idx]
keypoints[t_idx, N_JOINTS] = (T_7 @ T_home_np)[:3, 3]
_use_cad_mesh = False
_link_meshes_local: dict = {}
_link_body_ids: dict = {}
_link_world_T: dict = {}
try:
import mujoco
import trimesh # type: ignore
from openscvx.integrations.menagerie import get_xml_path
xml_path = str(get_xml_path("franka_fr3_v2", prefer_mjx=False))
mj_model_vis = mujoco.MjModel.from_xml_path(xml_path)
mj_model_vis.opt.disableflags |= mujoco.mjtDisableBit.mjDSBL_CONTACT
mj_data_vis = mujoco.MjData(mj_model_vis)
asset_dir = Path(xml_path).parent / "assets"
link_visual_files = {
"fr3v2_link0": [
"link0_0.obj",
"link0_1.obj",
"link0_2.obj",
"link0_3.obj",
"link0_4.obj",
"link0_5.obj",
],
"fr3v2_link1": ["link1_3.obj"],
"fr3v2_link2": ["link2_3.obj"],
"fr3v2_link3": ["link3_3.obj"],
"fr3v2_link4": ["link4_3.obj"],
"fr3v2_link5": ["link5_3.obj", "link5_4.obj"],
"fr3v2_link6": [
"link6_0.obj",
"link6_1.obj",
"link6_2.obj",
"link6_3.obj",
"link6_4.obj",
"link6_5.obj",
"link6_6.obj",
"link6_7.obj",
"link6_8.obj",
"link6_9.obj",
],
"fr3v2_link7": [
"link7_0.obj",
"link7_1.obj",
"link7_2.obj",
"link7_3.obj",
"link7_4.obj",
"link7_5.obj",
"link7_6.obj",
],
}
for link_name, files in link_visual_files.items():
all_verts, all_faces, offset = [], [], 0
for fname in files:
obj_path = asset_dir / fname
if not obj_path.exists():
continue
tm = trimesh.load(str(obj_path), force="mesh", process=False)
all_verts.append(np.asarray(tm.vertices, dtype=np.float32))
all_faces.append(np.asarray(tm.faces, dtype=np.uint32) + offset)
offset += len(tm.vertices)
if not all_verts:
continue
_link_meshes_local[link_name] = (np.vstack(all_verts), np.vstack(all_faces))
_link_body_ids[link_name] = mujoco.mj_name2id(
mj_model_vis, mujoco.mjtObj.mjOBJ_BODY, link_name
)
for name in _link_meshes_local:
_link_world_T[name] = np.zeros((n_frames, 4, 4))
for t_idx in range(n_frames):
mj_data_vis.qpos[:N_JOINTS] = angle_traj[t_idx]
mujoco.mj_kinematics(mj_model_vis, mj_data_vis)
for name, body_id in _link_body_ids.items():
T = np.eye(4)
T[:3, :3] = mj_data_vis.xmat[body_id].copy().reshape(3, 3)
T[:3, 3] = mj_data_vis.xpos[body_id].copy()
_link_world_T[name][t_idx] = T
_use_cad_mesh = len(_link_meshes_local) > 0
if _use_cad_mesh:
print(
f"[viser] Loaded {len(_link_meshes_local)} CAD link meshes from menagerie assets."
)
except Exception as exc:
print(f"[viser] CAD mesh unavailable ({type(exc).__name__}: {exc}); using line segments.")
server = create_server(ee_pos, show_grid=False)
server.scene.add_grid("/grid", width=1.5, height=1.5, cell_size=0.2)
server.scene.add_frame("/origin", axes_length=0.08, axes_radius=0.003)
tgt_arr = np.asarray(target.value).reshape(1, 3)
add_target_markers(server, tgt_arr, radius=0.015, colors=[(255, 50, 50)])
add_target_markers(
server,
vp_targets,
radius=0.01,
colors=[(50, 255, 50)] * len(vp_targets),
)
ee_colors = compute_velocity_colors(np.asarray(results.trajectory.get("velocity")))
add_ghost_trajectory(server, ee_pos, ee_colors, point_size=0.005)
_, update_trail = add_animated_trail(server, ee_pos, ee_colors, point_size=0.008)
_, update_marker = add_position_marker(server, ee_pos, radius=0.012)
_, update_viewcone = add_viewcone(
server,
ee_pos,
ee_quats,
half_angle_x=np.pi / alpha_x,
half_angle_y=np.pi / alpha_y,
scale=0.15,
norm_type=norm_type,
R_sb=np.array(R_sb),
color=(80, 180, 200),
opacity=0.3,
)
update_robot = None
if _use_cad_mesh:
_link_color = {
"fr3v2_link0": (120, 120, 120),
"fr3v2_link1": (215, 215, 218),
"fr3v2_link2": (215, 215, 218),
"fr3v2_link3": (215, 215, 218),
"fr3v2_link4": (215, 215, 218),
"fr3v2_link5": (210, 210, 215),
"fr3v2_link6": (200, 205, 215),
"fr3v2_link7": (190, 195, 210),
}
_link_handles = {}
for link_name, (verts_local, faces) in _link_meshes_local.items():
T0 = _link_world_T[link_name][0]
verts_world = (T0[:3, :3] @ verts_local.T).T + T0[:3, 3]
handle = server.scene.add_mesh_simple(
f"/robot/{link_name}",
vertices=verts_world.astype(np.float32),
faces=faces,
color=_link_color.get(link_name, (210, 210, 215)),
opacity=1.0,
)
_link_handles[link_name] = (handle, verts_local)
def update_robot(frame_idx: int) -> None:
for link_name, (handle, verts_local) in _link_handles.items():
T = _link_world_T[link_name][frame_idx]
verts_world = (T[:3, :3] @ verts_local.T).T + T[:3, 3]
handle.vertices = verts_world.astype(np.float32)
else:
n_segs = N_JOINTS + 1
seg_col = np.full((n_segs, 2, 3), [200, 200, 200], dtype=np.uint8)
def _build_segments(frame_idx: int) -> np.ndarray:
pts = np.zeros((n_segs, 2, 3), dtype=np.float32)
pts[0] = [np.zeros(3), keypoints[frame_idx, 0]]
for k in range(N_JOINTS - 1):
pts[k + 1] = [keypoints[frame_idx, k], keypoints[frame_idx, k + 1]]
pts[N_JOINTS] = [
keypoints[frame_idx, N_JOINTS - 1],
keypoints[frame_idx, N_JOINTS],
]
return pts
arm_handle = server.scene.add_line_segments(
"/arm_links",
points=_build_segments(0),
colors=seg_col,
line_width=5.0,
)
def update_robot(frame_idx: int) -> None:
arm_handle.points = _build_segments(frame_idx)
traj_time = np.asarray(results.trajectory["time"])
callbacks = [update_trail, update_marker, update_viewcone]
if update_robot is not None:
callbacks.append(update_robot)
add_animation_controls(server, traj_time, callbacks, loop=True)
server.sleep_forever()