Skip to content

Commit b06a4a8

Browse files
authored
add gimbal extension and docs (#256)
* add gimbal extension and control interface * add docs for gimbal extension
1 parent cf34b60 commit b06a4a8

File tree

15 files changed

+452
-11
lines changed

15 files changed

+452
-11
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
# **Gimbal Extension**
3+
4+
## **Overview**
5+
The **Gimbal Extension** provides an easy way to integrate a controllable gimbal into an existing drone model within the scene. This extension is designed to facilitate the attachment and operation of a camera-equipped gimbal, allowing for real-time adjustments to pitch and yaw angles via ROS 2 messages.
6+
7+
8+
## **Installation and Activation**
9+
To enable the **Gimbal Extension**, follow these steps:
10+
11+
1. Open the **Extensions** window by navigating to:
12+
**Window****Extensions**
13+
2. Under the **THIRD PARTIES** section, go to the **User** tab.
14+
3. Locate the **Gimbal Extension** and turn it on.
15+
4. Once enabled, a new **Gimbal Extension** window should appear.
16+
17+
## **Adding a Gimbal to a Drone**
18+
To attach a gimbal to an existing UAV model:
19+
20+
1. Copy the **prim path** of the UAV to which you want to add the gimbal.
21+
2. In the **Gimbal Extension** window, paste the copied path into the **Robot Prim Path** text box.
22+
3. Set the **Robot Index** based on the `DOMAIN_ID` of the drone.
23+
- The `DOMAIN_ID` should match the identifier used for the robot to ensure proper communication.
24+
25+
For a step-by-step demonstration, refer to the video tutorial below:
26+
27+
<iframe src="https://drive.google.com/file/d/1pN0Pxe4nYQL1qs40oZTDqsOXsMrApLPM/preview" width="840" height="480" allow="autoplay" allowfullscreen="allowfullscreen"></iframe>
28+
29+
## **Gimbal Camera Image Topic**
30+
Once the gimbal is successfully added, the camera image feed from the gimbal will be published on the following ROS 2 topic: `/robot_<ID>/gimbal/rgb`.
31+
32+
## **Controlling the Gimbal**
33+
The gimbal pitch and yaw angles can be controled by the ros2 messages `/robot_<ID>/gimbal/desired_gimbal_pitch` and `/robot_<ID>/gimbal/desired_gimbal_yaw` of type `std_msgs/msg/Float64`, respectively.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ nav:
6666
- docs/robot/autonomy/0_interface/index.md
6767
- Sensors:
6868
- docs/robot/autonomy/1_sensors/index.md
69+
- docs/robot/autonomy/1_sensors/gimbal.md
6970
- Perception:
7071
- docs/robot/autonomy/2_perception/index.md
7172
- docs/robot/autonomy/2_perception/state_estimation.md

robot/ros_ws/src/autonomy/1_sensors/gimbal_stabilizer/gimbal_stabilizer/gimbal_stabilizer_node.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,38 @@
44
from rclpy.node import Node
55
from nav_msgs.msg import Odometry
66
from sensor_msgs.msg import JointState
7-
from transforms3d.euler import quat2euler
7+
# from transforms3d.euler import quat2euler
8+
from std_msgs.msg import Float64 # Assuming the desired yaw is published as a Float64
89

910
class GimbalStabilizerNode(Node):
1011
def __init__(self):
1112
super().__init__('gimbal_stabilizer')
1213

1314
# Publisher to send joint commands
14-
self.joint_pub = self.create_publisher(JointState, '/joint_command', 10)
15+
self.joint_pub = self.create_publisher(JointState, 'gimbal/joint_command', 10)
1516

1617
# Subscriber to receive drone odometry
17-
self.create_subscription(Odometry, '/robot_1/odometry_conversion/odometry', self.odometry_callback, 10)
18-
self.create_subscription(JointState, '/joint_states', self.joint_callback, 10)
18+
self.create_subscription(Odometry, 'odometry_conversion/odometry', self.odometry_callback, 10)
19+
self.create_subscription(JointState, 'gimbal/joint_states', self.joint_callback, 10)
20+
self.create_subscription(Float64, 'gimbal/desired_gimbal_yaw', self.yaw_callback, 10)
21+
self.create_subscription(Float64, 'gimbal/desired_gimbal_pitch', self.pitch_callback, 10)
1922

2023
# Initialize joint state message
2124
self.joint_command = JointState()
2225
self.joint_command.name = ["yaw_joint","roll_joint", "pitch_joint"]
2326
self.joint_command.position = [0.0, 0.0, 0.0]
27+
self.desired_yaw = 0.0
28+
self.desired_pitch = 0.0
29+
30+
def yaw_callback(self, msg):
31+
self.desired_yaw = msg.data
32+
# self.get_logger().info(f"Received desired yaw angle: {self.desired_yaw}")
33+
34+
def pitch_callback(self, msg):
35+
self.desired_pitch = msg.data
2436

2537
def joint_callback(self, msg):
38+
self.got_joint_states = True
2639
# Inverse the drone angles to stabilize the gimbal
2740
# self.joint_command.position[0] = -roll # roll joint
2841
# self.joint_command.position[1] = -pitch # pitch joint
@@ -33,7 +46,7 @@ def joint_callback(self, msg):
3346
# self.joint_command.position[0] = -20.0/180*3.14 # yaw joint
3447
# self.joint_command.position[1] = 10.0/180*3.14 # roll joint
3548
# self.joint_command.position[2] = 20.0/180*3.14 # pitch joint
36-
self.joint_command.velocity = [float('nan'), float('nan'), float('nan')]
49+
# self.joint_command.velocity = [float('nan'), float('nan'), float('nan')]
3750
# self.joint_command.velocity = [-1.0, -1.0, -1.0]
3851

3952
# Publish the joint command
@@ -50,18 +63,18 @@ def odometry_callback(self, msg):
5063
]
5164

5265
# Convert quaternion to Euler angles (roll, pitch, yaw)
53-
roll, pitch, yaw = quat2euler(quaternion, axes='sxyz')
66+
# roll, pitch, yaw = quat2euler(quaternion, axes='sxyz')
5467

5568
# Inverse the drone angles to stabilize the gimbal
5669
# self.joint_command.position[0] = -roll # roll joint
5770
# self.joint_command.position[1] = -pitch # pitch joint
5871
# self.joint_command.position[2] = -yaw # yaw joint
5972

60-
self.joint_command.position[0] = -0.0/180*3.14 # yaw joint
61-
self.joint_command.position[1] = -roll # roll joint
62-
self.joint_command.position[2] = 0.0/180*3.14 # pitch joint
73+
self.joint_command.position[0] = -self.desired_yaw/180*3.14 # yaw joint
74+
self.joint_command.position[1] = -0.0/180*3.14 # roll joint
75+
self.joint_command.position[2] = self.desired_pitch/180*3.14 # pitch joint
6376
self.joint_command.velocity = [float('nan'), float('nan'), float('nan')]
64-
self.joint_command.velocity = [-1.0, -1.0, -1.0]
77+
# self.joint_command.velocity = [-1.0, -1.0, -1.0]
6578

6679
# Publish the joint command
6780
self.joint_pub.publish(self.joint_command)
@@ -78,4 +91,4 @@ def main():
7891
rclpy.shutdown()
7992

8093
if __name__ == '__main__':
81-
main()
94+
main()

robot/ros_ws/src/autonomy/1_sensors/sensors_bringup/launch/sensors.launch.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@
44
<push-ros-namespace namespace="sensors"/>
55
<include file="$(find-pkg-share camera_param_server)/launch/camera_param_server.launch.xml"/>
66
</group>
7+
<node
8+
pkg="gimbal_stabilizer" exec="gimbal_stabilizer_node" name="gimbal_stabilizer" output="screen">
9+
</node>
710
</launch>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
3+
#
4+
# NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
5+
# property and proprietary rights in and to this material, related
6+
# documentation and any modifications thereto. Any use, reproduction,
7+
# disclosure or distribution of this material and related documentation
8+
# without an express license agreement from NVIDIA CORPORATION or
9+
# its affiliates is strictly prohibited.
10+
11+
from .extension import *
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import omni.ext
2+
import subprocess
3+
4+
import omni.ui as ui
5+
import omni.kit.window.filepicker as filepicker
6+
from omni.isaac.core import World
7+
from omni.isaac.core.utils.stage import add_reference_to_stage
8+
from pxr import Usd
9+
from omni.isaac.core import World
10+
# from omni.ext import get_extension_path
11+
import omni.usd
12+
from pxr import UsdGeom, Gf, UsdPhysics
13+
import omni.graph.core as og
14+
import time
15+
16+
class MyExtension(omni.ext.IExt):
17+
def on_startup(self):
18+
"""Called when the extension is loaded."""
19+
print("[GimbalExtension] Startup called") # Debug log
20+
21+
# self.world = World.get_instance()
22+
self.ui = GimbalUI()
23+
print("[GimbalExtension] UI initialized") # Debug log
24+
25+
def on_shutdown(self):
26+
"""Called when the extension is unloaded."""
27+
# self.world.cleanup()
28+
self.ui.window.destroy()
29+
30+
class GimbalUI:
31+
def __init__(self):
32+
self.window = ui.Window("Gimbal Extension UI", width=400, height=300)
33+
self.usd_file_path = ""
34+
self.robot_prim_path = ""
35+
self.robot_name = 1
36+
self.default_usd_path = "omniverse://airlab-storage.andrew.cmu.edu:8443/Library/Assets/Gimbal/gimbal_test.usd"
37+
self.default_robot_path = "/World/TEMPLATE_spirit_uav_robot/map_FLU/spirit_uav"
38+
39+
self.build_ui()
40+
41+
def build_ui(self):
42+
with self.window.frame:
43+
with ui.VStack(spacing=10):
44+
# USD File Input
45+
ui.Label("USD File Path", height=20)
46+
with ui.HStack(height=30):
47+
self.usd_input_field = ui.StringField(
48+
model=ui.SimpleStringModel(self.default_usd_path), height=20
49+
)
50+
ui.Button("Browse", clicked_fn=self.open_file_picker)
51+
52+
# Robot Prim Path Input
53+
ui.Label("Robot Prim Path (copy from Stage)", height=20)
54+
self.robot_prim_input_field = ui.StringField(
55+
model=ui.SimpleStringModel(self.default_robot_path), height=20
56+
)
57+
58+
# Robot Name Input
59+
ui.Label("Robot Index", height=20)
60+
self.robot_name_input_field = ui.StringField(
61+
model=ui.SimpleStringModel("2"), height=20
62+
)
63+
# Apply Button
64+
ui.Button("Apply and Add Gimbal", height=40, clicked_fn=self.on_apply)
65+
66+
def open_file_picker(self):
67+
"""Opens a file picker to select the USD file."""
68+
file_picker = filepicker.FilePickerDialog(
69+
title="Select Gimbal USD File",
70+
apply_button_label="Select",
71+
file_extension_filter="*.usd",
72+
apply_clicked_fn=self.on_file_selected
73+
)
74+
file_picker.show()
75+
76+
def on_file_selected(self, file_path):
77+
"""Callback for file selection."""
78+
self.usd_input_field.model.set_value(file_path)
79+
80+
def on_apply(self):
81+
"""Apply button callback to add the gimbal and configure the ActionGraph."""
82+
self.usd_file_path = self.usd_input_field.model.get_value_as_string()
83+
self.robot_prim_path = self.robot_prim_input_field.model.get_value_as_string()
84+
self.robot_name = self.robot_name_input_field.model.get_value_as_string()
85+
86+
# Validate input
87+
if not self.usd_file_path or not self.robot_prim_path or not self.robot_name:
88+
print("Please fill in all fields.")
89+
return
90+
91+
# Add the gimbal to the stage
92+
self.add_gimbal_to_stage()
93+
94+
def find_existing_op(self, xform_ops, op_type):
95+
for op in xform_ops:
96+
if op.GetOpName() == op_type:
97+
return op
98+
return None
99+
100+
def add_gimbal_to_stage(self):
101+
"""Adds the gimbal to the robot and configures the OmniGraph."""
102+
stage = omni.usd.get_context().get_stage()
103+
104+
# Add the USD file reference
105+
gimbal_prim_path = f"{self.robot_prim_path}/gimbal"
106+
add_reference_to_stage(self.usd_file_path, gimbal_prim_path)
107+
108+
109+
# Apply transformations (scale and translation)
110+
gimbal_prim = stage.GetPrimAtPath(gimbal_prim_path)
111+
if gimbal_prim.IsValid():
112+
gimbal_xform = UsdGeom.Xformable(gimbal_prim)
113+
xform_ops = gimbal_xform.GetOrderedXformOps()
114+
115+
# Check if a scale operation already exists
116+
scale_op = self.find_existing_op(xform_ops, "xformOp:scale")
117+
if not scale_op:
118+
scale_op = gimbal_xform.AddScaleOp()
119+
scale_value = Gf.Vec3d(0.01, 0.01, 0.01)
120+
scale_op.Set(scale_value)
121+
122+
# Check if a translate operation already exists
123+
translate_op = self.find_existing_op(xform_ops, "xformOp:translate")
124+
if not translate_op:
125+
translate_op = gimbal_xform.AddTranslateOp()
126+
translation_value = Gf.Vec3d(0.02, 0.015, 0.1)
127+
translate_op.Set(translation_value)
128+
129+
print(f"Gimbal added at {gimbal_prim_path} with scale {scale_value} and translation {translation_value}.")
130+
131+
# Add a fixed joint between the gimbal and the robot
132+
self.add_fixed_joint(stage, self.robot_prim_path, gimbal_prim_path)
133+
134+
# """Enables the ActionGraph within the gimbal and sets inputs."""
135+
action_graph_path = f"{gimbal_prim_path}/ActionGraph"
136+
action_graph_prim = stage.GetPrimAtPath(action_graph_path)
137+
138+
if action_graph_prim.IsValid():
139+
# Access the graph
140+
graph = og.Controller.graph(action_graph_path)
141+
graph_handle = og.get_graph_by_path(action_graph_path)
142+
143+
# Set the input value
144+
node_path = "/World/ActionGraph/MyNode" # Replace with the path to your node
145+
input_name = "myInput" # Replace with the name of the input
146+
value = 10 # Replace with the value you want to set
147+
148+
# Define the path to ros2_context node
149+
ros2_context_path = action_graph_path+"/ros2_context"
150+
self.list_node_attributes(ros2_context_path)
151+
152+
self.set_or_create_node_attribute(ros2_context_path, "inputs:domain_id", int(self.robot_name))
153+
self.set_or_create_node_attribute(action_graph_path+"/ros2_subscriber", "inputs:topicName", "robot_"+self.robot_name+"/gimbal/not_used")
154+
self.set_or_create_node_attribute(action_graph_path+"/ros2_subscribe_joint_state", "inputs:topicName", "robot_"+self.robot_name+"/gimbal/joint_command")
155+
self.set_or_create_node_attribute(action_graph_path+"/ros2_publish_joint_state", "inputs:topicName", "robot_"+self.robot_name+"/gimbal/joint_states")
156+
self.set_or_create_node_attribute(action_graph_path+"/ros2_camera_helper", "inputs:topicName", "robot_"+self.robot_name+"/gimbal/rgb")
157+
158+
# og.Controller.attribute(action_graph_path+"/ros2_context.inputs:domain_id").set(int(self.robot_name))
159+
# og.Controller.attribute(action_graph_path+"/ros2_subscriber.inputs:topicName").set("robot_"+self.robot_name+"/gimbal_yaw_control")
160+
# og.Controller.attribute(action_graph_path+"/ros2_subscribe_joint_state.inputs:topicName").set("robot_"+self.robot_name+"/joint_command")
161+
# og.Controller.attribute(action_graph_path+"/ros2_publish_joint_state.inputs:topicName").set("robot_"+self.robot_name+"/joint_states")
162+
163+
164+
def add_fixed_joint(self, stage, robot_prim_path, gimbal_prim_path):
165+
"""Adds a fixed joint between the robot and the gimbal."""
166+
joint_path = f"{gimbal_prim_path}/FixedJoint"
167+
joint_prim = stage.DefinePrim(joint_path, "PhysicsFixedJoint")
168+
169+
# Define the fixed joint's relationship to the robot and gimbal components
170+
joint = UsdPhysics.FixedJoint(joint_prim)
171+
if not joint:
172+
print(f"Failed to create fixed joint at {joint_path}")
173+
return
174+
175+
# Set joint relationships
176+
joint.CreateBody0Rel().SetTargets([f"{robot_prim_path}/base_link/meshes/Cone"])
177+
joint.CreateBody1Rel().SetTargets([f"{gimbal_prim_path}/yaw"])
178+
179+
print(f"Fixed joint created between {robot_prim_path} and {gimbal_prim_path}.")
180+
181+
def list_node_attributes(self, node_path):
182+
"""Lists all attributes of a given node in OmniGraph."""
183+
stage = omni.usd.get_context().get_stage()
184+
185+
if not stage:
186+
print("Error: USD stage not found.")
187+
return
188+
189+
node_prim = stage.GetPrimAtPath(node_path)
190+
191+
if not node_prim.IsValid():
192+
print(f"Error: Node not found at {node_path}")
193+
return
194+
195+
print(f"Attributes in node '{node_path}':")
196+
197+
for attr in node_prim.GetAttributes():
198+
print(f"- {attr.GetName()}")
199+
200+
def set_or_create_node_attribute(self, node_path, attribute_name, value):
201+
"""Sets an attribute value for a given node in OmniGraph, creating it if necessary."""
202+
stage = omni.usd.get_context().get_stage()
203+
node_prim = stage.GetPrimAtPath(node_path)
204+
205+
if not node_prim.IsValid():
206+
print(f"Error: Node not found at {node_path}")
207+
return
208+
209+
attr = node_prim.GetAttribute(attribute_name)
210+
211+
if not attr:
212+
print(f"Attribute {attribute_name} not found, creating it...")
213+
attr = node_prim.CreateAttribute(attribute_name, Sdf.ValueTypeNames.Int)
214+
215+
if attr:
216+
attr.Set(value)
217+
print(f"Set {attribute_name} to {value} on node {node_path}.")
218+
else:
219+
print(f"Failed to create or set attribute {attribute_name}.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
3+
#
4+
# NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
5+
# property and proprietary rights in and to this material, related
6+
# documentation and any modifications thereto. Any use, reproduction,
7+
# disclosure or distribution of this material and related documentation
8+
# without an express license agreement from NVIDIA CORPORATION or
9+
# its affiliates is strictly prohibited.
10+
11+
from .test_hello_world import *

0 commit comments

Comments
 (0)