Skip to content

Commit 6ce209d

Browse files
Twist message for all move command, added keyboard teleop for easy robot control in sim (#570)
* Switched to using Twist instead of vector3 for all cmd_vel * increased object tracker frame alignment time tolerance * added camera_info timestamp * added and tested keyboard teleop * passing tests * remove the pynput based keyboard twist * foxglove extension for map visualization * add keyboard control * CI code cleanup * add package json --------- Co-authored-by: Paul Nechifor <paul@nechifor.net> Co-authored-by: paul-nechifor <1262969+paul-nechifor@users.noreply.github.com> Former-commit-id: aa5005d
1 parent 0f69ea5 commit 6ce209d

49 files changed

Lines changed: 8531 additions & 2578 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bin/explore-cmd

Lines changed: 0 additions & 39 deletions
This file was deleted.

dimos/navigation/local_planner/holonomic_local_planner.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
import numpy as np
2424

25-
from dimos.msgs.geometry_msgs import Vector3
25+
from dimos.msgs.geometry_msgs import Twist, Vector3
2626
from dimos.navigation.local_planner import BaseLocalPlanner
2727
from dimos.utils.transform_utils import quaternion_to_euler, normalize_angle, get_distance
2828

@@ -73,12 +73,12 @@ def __init__(
7373
# Previous velocity for filtering (vx, vy, vtheta)
7474
self.v_prev = np.array([0.0, 0.0, 0.0])
7575

76-
def compute_velocity(self) -> Optional[Vector3]:
76+
def compute_velocity(self) -> Optional[Twist]:
7777
"""
7878
Compute velocity commands using GLAP algorithm.
7979
8080
Returns:
81-
Vector3 with x, y velocities in robot frame and z as angular velocity
81+
Twist with linear and angular velocities in robot frame
8282
"""
8383
if self.latest_odom is None or self.latest_path is None or self.latest_costmap is None:
8484
return None
@@ -151,7 +151,10 @@ def compute_velocity(self) -> Optional[Vector3]:
151151
v_filtered = self.alpha * v_raw + (1 - self.alpha) * self.v_prev
152152
self.v_prev = v_filtered
153153

154-
return Vector3(v_filtered[0], v_filtered[1], v_filtered[2])
154+
return Twist(
155+
linear=Vector3(v_filtered[0], v_filtered[1], 0.0),
156+
angular=Vector3(0.0, 0.0, v_filtered[2]),
157+
)
155158

156159
def _compute_path_following(self, pose: np.ndarray, path: np.ndarray) -> np.ndarray:
157160
"""

dimos/navigation/local_planner/local_planner.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from typing import Optional
2626

2727
from dimos.core import Module, In, Out, rpc
28-
from dimos.msgs.geometry_msgs import Vector3, PoseStamped
28+
from dimos.msgs.geometry_msgs import Twist, Vector3, PoseStamped
2929
from dimos.msgs.nav_msgs import OccupancyGrid, Path
3030
from dimos.utils.logging_config import setup_logger
3131
from dimos.utils.transform_utils import get_distance, quaternion_to_euler, normalize_angle
@@ -52,7 +52,7 @@ class BaseLocalPlanner(Module):
5252
path: In[Path] = None
5353

5454
# LCM outputs
55-
cmd_vel: Out[Vector3] = None
55+
cmd_vel: Out[Twist] = None
5656

5757
def __init__(
5858
self,
@@ -122,7 +122,7 @@ def _follow_path_loop(self):
122122
while not self.stop_planning.is_set():
123123
if self.is_goal_reached():
124124
self.stop_planning.set()
125-
stop_cmd = Vector3(0, 0, 0)
125+
stop_cmd = Twist()
126126
self.cmd_vel.publish(stop_cmd)
127127
break
128128

@@ -139,13 +139,13 @@ def _plan(self):
139139
self.cmd_vel.publish(cmd_vel)
140140

141141
@abstractmethod
142-
def compute_velocity(self) -> Optional[Vector3]:
142+
def compute_velocity(self) -> Optional[Twist]:
143143
"""
144144
Compute velocity commands based on current costmap, odometry, and path.
145145
Must be implemented by derived classes.
146146
147147
Returns:
148-
Vector3 message with velocity commands x, y, theta, or None if no command
148+
Twist message with linear and angular velocity commands, or None if no command
149149
"""
150150
pass
151151

@@ -196,7 +196,7 @@ def stop(self):
196196
self.stop_planning.set()
197197
self.planning_thread.join(timeout=1.0)
198198
self.planning_thread = None
199-
stop_cmd = Vector3(0, 0, 0)
199+
stop_cmd = Twist()
200200
self.cmd_vel.publish(stop_cmd)
201201

202202
logger.info("Local planner stopped")

dimos/navigation/local_planner/test_base_local_planner.py

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ def test_straight_path_no_obstacles(self, planner, empty_costmap):
7676

7777
# Should move along +X
7878
assert vel is not None
79-
assert vel.x > 0.9 # Close to v_max
80-
assert abs(vel.y) < 0.1 # Near zero
81-
assert abs(vel.z) < 0.1 # Small angular velocity when aligned with path
79+
assert vel.linear.x > 0.9 # Close to v_max
80+
assert abs(vel.linear.y) < 0.1 # Near zero
81+
assert abs(vel.angular.z) < 0.1 # Small angular velocity when aligned with path
8282

8383
def test_obstacle_gradient_repulsion(self, planner):
8484
"""Test that obstacle gradients create repulsive forces."""
@@ -110,7 +110,7 @@ def test_obstacle_gradient_repulsion(self, planner):
110110

111111
# Should have positive Y component (pushed north by gradient)
112112
assert vel is not None
113-
assert vel.y > 0.1 # Repulsion pushes north
113+
assert vel.linear.y > 0.1 # Repulsion pushes north
114114

115115
def test_lowpass_filter(self):
116116
"""Test that low-pass filter smooths velocity commands."""
@@ -146,7 +146,7 @@ def test_lowpass_filter(self):
146146
assert vel1 is not None
147147

148148
# Store first velocity
149-
first_vx = vel1.x
149+
first_vx = vel1.linear.x
150150

151151
# Second call - should be filtered
152152
vel2 = planner.compute_velocity()
@@ -156,8 +156,8 @@ def test_lowpass_filter(self):
156156
# v2 = 0.5 * v_raw + 0.5 * v1
157157
# The filtering effect should be visible
158158
# v2 should be between v1 and the raw velocity
159-
assert vel2.x != first_vx # Should be different due to filtering
160-
assert 0 < vel2.x <= planner.v_max # Should still be positive and within limits
159+
assert vel2.linear.x != first_vx # Should be different due to filtering
160+
assert 0 < vel2.linear.x <= planner.v_max # Should still be positive and within limits
161161

162162
def test_no_path(self, planner, empty_costmap):
163163
"""Test that planner returns None when no path is available."""
@@ -221,8 +221,8 @@ def test_goal_reached(self, planner, empty_costmap):
221221

222222
# Should have near-zero velocity
223223
assert vel is not None
224-
assert abs(vel.x) < 0.1
225-
assert abs(vel.y) < 0.1
224+
assert abs(vel.linear.x) < 0.1
225+
assert abs(vel.linear.y) < 0.1
226226

227227
def test_velocity_saturation(self, planner, empty_costmap):
228228
"""Test that velocities are capped at v_max."""
@@ -247,8 +247,9 @@ def test_velocity_saturation(self, planner, empty_costmap):
247247

248248
# Velocity should be saturated at v_max
249249
assert vel is not None
250-
assert abs(vel.x) <= planner.v_max + 0.01 # Small tolerance
251-
assert abs(vel.y) <= planner.v_max + 0.01
250+
assert abs(vel.linear.x) <= planner.v_max + 0.01 # Small tolerance
251+
assert abs(vel.linear.y) <= planner.v_max + 0.01
252+
assert abs(vel.angular.z) <= planner.v_max + 0.01
252253

253254
def test_lookahead_interpolation(self, planner, empty_costmap):
254255
"""Test that lookahead point is correctly interpolated on path."""
@@ -274,8 +275,8 @@ def test_lookahead_interpolation(self, planner, empty_costmap):
274275

275276
# Should move forward along path
276277
assert vel is not None
277-
assert vel.x > 0.5 # Moving forward
278-
assert abs(vel.y) < 0.1 # Staying on path
278+
assert vel.linear.x > 0.5 # Moving forward
279+
assert abs(vel.linear.y) < 0.1 # Staying on path
279280

280281
def test_curved_path_following(self, planner, empty_costmap):
281282
"""Test following a curved path."""
@@ -303,10 +304,10 @@ def test_curved_path_following(self, planner, empty_costmap):
303304
# Should have both X and Y components for curved motion
304305
assert vel is not None
305306
# Test general behavior: should be moving (not exact values)
306-
assert vel.x > 0 # Moving forward (any positive value)
307-
assert vel.y > 0 # Turning left (any positive value)
307+
assert vel.linear.x > 0 # Moving forward (any positive value)
308+
assert vel.linear.y > 0 # Turning left (any positive value)
308309
# Ensure we have meaningful movement, not just noise
309-
total_linear = np.sqrt(vel.x**2 + vel.y**2)
310+
total_linear = np.sqrt(vel.linear.x**2 + vel.linear.y**2)
310311
assert total_linear > 0.1 # Some reasonable movement
311312

312313
def test_robot_frame_transformation(self, empty_costmap):
@@ -346,11 +347,11 @@ def test_robot_frame_transformation(self, empty_costmap):
346347
assert vel is not None
347348
# Test relative magnitudes and signs rather than exact values
348349
# Path is to the right, so Y velocity should be negative
349-
assert vel.y < 0 # Should move right (negative Y in robot frame)
350+
assert vel.linear.y < 0 # Should move right (negative Y in robot frame)
350351
# Should turn to align with path
351-
assert vel.z < 0 # Should turn right (negative angular velocity)
352+
assert vel.angular.z < 0 # Should turn right (negative angular velocity)
352353
# X velocity should be relatively small compared to Y
353-
assert abs(vel.x) < abs(vel.y) # Lateral movement dominates
354+
assert abs(vel.linear.x) < abs(vel.linear.y) # Lateral movement dominates
354355

355356
def test_angular_velocity_computation(self, empty_costmap):
356357
"""Test that angular velocity is computed to align with path."""
@@ -386,11 +387,13 @@ def test_angular_velocity_computation(self, empty_costmap):
386387
# Should have positive angular velocity to turn left
387388
assert vel is not None
388389
# Test general behavior without exact thresholds
389-
assert vel.x > 0 # Moving forward (any positive value)
390-
assert vel.y > 0 # Moving left (holonomic, any positive value)
391-
assert vel.z > 0 # Turning left (positive angular velocity)
390+
assert vel.linear.x > 0 # Moving forward (any positive value)
391+
assert vel.linear.y > 0 # Moving left (holonomic, any positive value)
392+
assert vel.angular.z > 0 # Turning left (positive angular velocity)
392393
# Verify the robot is actually moving with reasonable speed
393-
total_linear = np.sqrt(vel.x**2 + vel.y**2)
394+
total_linear = np.sqrt(vel.linear.x**2 + vel.linear.y**2)
394395
assert total_linear > 0.1 # Some meaningful movement
395396
# Since path is diagonal, X and Y should be similar magnitude
396-
assert abs(vel.x - vel.y) < max(vel.x, vel.y) * 0.5 # Within 50% of each other
397+
assert (
398+
abs(vel.linear.x - vel.linear.y) < max(vel.linear.x, vel.linear.y) * 0.5
399+
) # Within 50% of each other

dimos/perception/object_tracker.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ class ObjectTracking(Module):
6060

6161
def __init__(
6262
self,
63-
camera_intrinsics: Optional[List[float]] = None, # [fx, fy, cx, cy]
6463
reid_threshold: int = 10,
6564
reid_fail_tolerance: int = 5,
6665
frame_id: str = "camera_link",
@@ -79,8 +78,7 @@ def __init__(
7978
# Call parent Module init
8079
super().__init__()
8180

82-
self.camera_intrinsics = camera_intrinsics
83-
self._camera_info_received = False
81+
self.camera_intrinsics = None
8482
self.reid_threshold = reid_threshold
8583
self.reid_fail_tolerance = reid_fail_tolerance
8684
self.frame_id = frame_id
@@ -141,7 +139,7 @@ def on_aligned_frames(frames_tuple):
141139
self.color_image.observable(),
142140
self.depth.observable(),
143141
buffer_size=2.0, # 2 second buffer
144-
match_tolerance=0.05, # 50ms tolerance
142+
match_tolerance=0.5, # 500ms tolerance
145143
)
146144
self._aligned_frames_subscription = aligned_frames.subscribe(on_aligned_frames)
147145

@@ -156,11 +154,6 @@ def on_camera_info(camera_info_msg: CameraInfo):
156154
camera_info_msg.K[2],
157155
camera_info_msg.K[5],
158156
]
159-
if not self._camera_info_received:
160-
self._camera_info_received = True
161-
logger.info(
162-
f"Camera intrinsics received from camera_info: {self.camera_intrinsics}"
163-
)
164157

165158
self.camera_info.subscribe(on_camera_info)
166159

dimos/robot/unitree_webrtc/connection.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from reactivex.subject import Subject
3131

3232
from dimos.core import In, Module, Out, rpc
33-
from dimos.msgs.geometry_msgs import Pose, Transform, Vector3
33+
from dimos.msgs.geometry_msgs import Pose, Transform, Twist, Vector3
3434
from dimos.msgs.sensor_msgs import Image
3535
from dimos.robot.connection_interface import ConnectionInterface
3636
from dimos.robot.unitree_webrtc.type.lidar import LidarMessage
@@ -80,20 +80,17 @@ def start_background_loop():
8080
self.thread.start()
8181
self.connection_ready.wait()
8282

83-
def move(self, velocity: Vector3, duration: float = 0.0) -> bool:
84-
"""Send movement command to the robot using velocity commands.
83+
def move(self, twist: Twist, duration: float = 0.0) -> bool:
84+
"""Send movement command to the robot using Twist commands.
8585
8686
Args:
87-
velocity: Velocity vector [x, y, yaw] where:
88-
x: Forward/backward velocity (m/s)
89-
y: Left/right velocity (m/s)
90-
yaw: Rotational velocity (rad/s)
87+
twist: Twist message with linear and angular velocities
9188
duration: How long to move (seconds). If 0, command is continuous
9289
9390
Returns:
9491
bool: True if command was sent successfully
9592
"""
96-
x, y, yaw = velocity.x, velocity.y, velocity.z
93+
x, y, yaw = twist.linear.x, twist.linear.y, twist.angular.z
9794

9895
# WebRTC coordinate mapping:
9996
# x - Positive right, negative left
@@ -289,7 +286,7 @@ def stop(self) -> bool:
289286
Returns:
290287
bool: True if stop command was sent successfully
291288
"""
292-
return self.move(Vector3(0.0, 0.0, 0.0))
289+
return self.move(Twist())
293290

294291
def disconnect(self) -> None:
295292
"""Disconnect from the robot and clean up resources."""

dimos/robot/unitree_webrtc/mujoco_connection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from reactivex import Observable
2626

27-
from dimos.msgs.geometry_msgs import Vector3
27+
from dimos.msgs.geometry_msgs import Twist
2828
from dimos.msgs.sensor_msgs import Image
2929
from dimos.utils.data import get_data
3030

@@ -169,9 +169,9 @@ def dispose():
169169

170170
return Observable(on_subscribe)
171171

172-
def move(self, vector: Vector3, duration: float = 0.0):
172+
def move(self, twist: Twist, duration: float = 0.0):
173173
if not self._is_cleaned_up:
174-
self.mujoco_thread.move(vector, duration)
174+
self.mujoco_thread.move(twist, duration)
175175

176176
def stop(self):
177177
"""Stop the MuJoCo connection gracefully."""

dimos/robot/unitree_webrtc/test_unitree_go2_integration.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from dimos import core
2020
from dimos.core import Module, Out, rpc
21-
from dimos.msgs.geometry_msgs import PoseStamped, Vector3, Quaternion
21+
from dimos.msgs.geometry_msgs import PoseStamped, Twist, Vector3, Quaternion
2222
from dimos.msgs.sensor_msgs import Image
2323
from dimos.msgs.nav_msgs import OccupancyGrid
2424
from dimos.protocol import pubsub
@@ -39,7 +39,7 @@
3939
class MovementControlModule(Module):
4040
"""Simple module to send movement commands for testing."""
4141

42-
movecmd: Out[Vector3] = None
42+
movecmd: Out[Twist] = None
4343

4444
def __init__(self):
4545
super().__init__()
@@ -48,7 +48,7 @@ def __init__(self):
4848
@rpc
4949
def send_move_command(self, x: float, y: float, yaw: float):
5050
"""Send a movement command."""
51-
cmd = Vector3(x, y, yaw)
51+
cmd = Twist(linear=Vector3(x, y, 0.0), angular=Vector3(0.0, 0.0, yaw))
5252
self.movecmd.publish(cmd)
5353
self.commands_sent.append(cmd)
5454
logger.info(f"Sent move command: x={x}, y={y}, yaw={yaw}")
@@ -102,7 +102,7 @@ async def test_unitree_go2_navigation_stack(self):
102102
navigator.goal_reached.transport = core.LCMTransport("/goal_reached", Bool)
103103
navigator.global_costmap.transport = core.LCMTransport("/global_costmap", OccupancyGrid)
104104
global_planner.path.transport = core.LCMTransport("/global_path", Path)
105-
local_planner.cmd_vel.transport = core.LCMTransport("/cmd_vel", Vector3)
105+
local_planner.cmd_vel.transport = core.LCMTransport("/cmd_vel", Twist)
106106

107107
# Configure navigation connections
108108
global_planner.target.connect(navigator.goal)
@@ -118,7 +118,7 @@ async def test_unitree_go2_navigation_stack(self):
118118

119119
# Deploy movement control module for testing
120120
movement = dimos.deploy(MovementControlModule)
121-
movement.movecmd.transport = core.LCMTransport("/test_move", Vector3)
121+
movement.movecmd.transport = core.LCMTransport("/test_move", Twist)
122122
connection.movecmd.connect(movement.movecmd)
123123

124124
# Start all modules

0 commit comments

Comments
 (0)