Compare commits
No commits in common. "f14ce5c3badd550c07896b3f7e1c11dc1f9eb22f" and "5b9e9dd412ce7b30617295a6e56d073ad4126c0e" have entirely different histories.
f14ce5c3ba
...
5b9e9dd412
@ -1,126 +0,0 @@
|
|||||||
# Profile: Demo Mode (Issue #506)
|
|
||||||
# Agile settings for demonstration and autonomous tricks
|
|
||||||
# - Medium-high velocities for responsive behavior (0.6 m/s)
|
|
||||||
# - Enhanced obstacle detection with all sensors
|
|
||||||
# - Balanced costmap inflation (0.32m)
|
|
||||||
# - Medium-sized local costmap (3.5m x 3.5m)
|
|
||||||
# - Tuned for tricks demonstrations (spin, backups, dynamic behaviors)
|
|
||||||
|
|
||||||
velocity_smoother:
|
|
||||||
ros__parameters:
|
|
||||||
max_velocity: [0.6, 0.0, 1.2] # Agile: 0.6 m/s forward, 1.2 rad/s angular
|
|
||||||
min_velocity: [-0.3, 0.0, -1.2]
|
|
||||||
smoothing_frequency: 20.0 # Increased smoothing for tricks
|
|
||||||
|
|
||||||
controller_server:
|
|
||||||
ros__parameters:
|
|
||||||
controller_frequency: 10.0 # Hz
|
|
||||||
FollowPath:
|
|
||||||
max_vel_x: 0.6 # Agile forward speed
|
|
||||||
max_vel_theta: 1.2
|
|
||||||
min_vel_x: -0.3
|
|
||||||
vx_samples: 25 # More sampling for agility
|
|
||||||
vy_samples: 5
|
|
||||||
vtheta_samples: 25
|
|
||||||
sim_time: 1.7
|
|
||||||
critics:
|
|
||||||
["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"]
|
|
||||||
BaseObstacle.scale: 0.025 # Enhanced obstacle avoidance
|
|
||||||
PathAlign.scale: 32.0
|
|
||||||
GoalAlign.scale: 24.0
|
|
||||||
PathDist.scale: 32.0
|
|
||||||
GoalDist.scale: 24.0
|
|
||||||
RotateToGoal.scale: 32.0
|
|
||||||
RotateToGoal.slowing_factor: 4.0 # Faster rotations for tricks
|
|
||||||
|
|
||||||
behavior_server:
|
|
||||||
ros__parameters:
|
|
||||||
cycle_frequency: 10.0
|
|
||||||
max_rotational_vel: 1.5 # Enable faster spins for tricks
|
|
||||||
min_rotational_vel: 0.3
|
|
||||||
|
|
||||||
local_costmap:
|
|
||||||
local_costmap:
|
|
||||||
ros__parameters:
|
|
||||||
width: 3.5 # 3.5m x 3.5m rolling window
|
|
||||||
height: 3.5
|
|
||||||
resolution: 0.05
|
|
||||||
update_frequency: 10.0
|
|
||||||
publish_frequency: 5.0
|
|
||||||
plugins: ["obstacle_layer", "voxel_layer", "inflation_layer"]
|
|
||||||
|
|
||||||
obstacle_layer:
|
|
||||||
plugin: "nav2_costmap_2d::ObstacleLayer"
|
|
||||||
observation_sources: scan surround_cameras
|
|
||||||
scan:
|
|
||||||
topic: /scan
|
|
||||||
max_obstacle_height: 0.80
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
data_type: "LaserScan"
|
|
||||||
raytrace_max_range: 8.0
|
|
||||||
obstacle_max_range: 7.5
|
|
||||||
surround_cameras:
|
|
||||||
topic: /surround_vision/obstacles
|
|
||||||
min_obstacle_height: 0.05
|
|
||||||
max_obstacle_height: 1.50
|
|
||||||
marking: true
|
|
||||||
data_type: "PointCloud2"
|
|
||||||
raytrace_max_range: 3.5
|
|
||||||
obstacle_max_range: 3.0
|
|
||||||
|
|
||||||
voxel_layer:
|
|
||||||
enabled: true
|
|
||||||
observation_sources: depth_camera
|
|
||||||
depth_camera:
|
|
||||||
topic: /camera/depth/color/points
|
|
||||||
min_obstacle_height: 0.05
|
|
||||||
max_obstacle_height: 0.80
|
|
||||||
marking: true
|
|
||||||
clearing: true
|
|
||||||
data_type: "PointCloud2"
|
|
||||||
raytrace_max_range: 4.0
|
|
||||||
obstacle_max_range: 3.5
|
|
||||||
|
|
||||||
inflation_layer:
|
|
||||||
cost_scaling_factor: 3.0
|
|
||||||
inflation_radius: 0.32 # Balanced inflation for demo tricks
|
|
||||||
|
|
||||||
global_costmap:
|
|
||||||
global_costmap:
|
|
||||||
ros__parameters:
|
|
||||||
resolution: 0.05
|
|
||||||
update_frequency: 5.0
|
|
||||||
publish_frequency: 1.0
|
|
||||||
plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
|
|
||||||
|
|
||||||
obstacle_layer:
|
|
||||||
observation_sources: scan depth_scan surround_cameras
|
|
||||||
scan:
|
|
||||||
topic: /scan
|
|
||||||
max_obstacle_height: 0.80
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
data_type: "LaserScan"
|
|
||||||
raytrace_max_range: 8.0
|
|
||||||
obstacle_max_range: 7.5
|
|
||||||
depth_scan:
|
|
||||||
topic: /depth_scan
|
|
||||||
max_obstacle_height: 0.80
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
data_type: "LaserScan"
|
|
||||||
raytrace_max_range: 6.0
|
|
||||||
obstacle_max_range: 5.5
|
|
||||||
surround_cameras:
|
|
||||||
topic: /surround_vision/obstacles
|
|
||||||
min_obstacle_height: 0.05
|
|
||||||
max_obstacle_height: 1.50
|
|
||||||
marking: true
|
|
||||||
data_type: "PointCloud2"
|
|
||||||
raytrace_max_range: 3.5
|
|
||||||
obstacle_max_range: 3.0
|
|
||||||
|
|
||||||
inflation_layer:
|
|
||||||
cost_scaling_factor: 3.0
|
|
||||||
inflation_radius: 0.32 # Balanced inflation for demo tricks
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
# Profile: Indoor Mode (Issue #506)
|
|
||||||
# Conservative settings for safe indoor navigation
|
|
||||||
# - Lower max velocities for precision and safety (0.4 m/s)
|
|
||||||
# - Tighter costmap inflation for confined spaces (0.35m)
|
|
||||||
# - Aggressive obstacle detection (RealSense depth + LIDAR + surround cameras)
|
|
||||||
# - Smaller local costmap window (3m x 3m)
|
|
||||||
|
|
||||||
velocity_smoother:
|
|
||||||
ros__parameters:
|
|
||||||
max_velocity: [0.4, 0.0, 0.8] # Conservative: 0.4 m/s forward, 0.8 rad/s angular
|
|
||||||
min_velocity: [-0.2, 0.0, -0.8]
|
|
||||||
|
|
||||||
controller_server:
|
|
||||||
ros__parameters:
|
|
||||||
controller_frequency: 10.0 # Hz
|
|
||||||
FollowPath:
|
|
||||||
max_vel_x: 0.4 # Conservative forward speed
|
|
||||||
max_vel_theta: 0.8
|
|
||||||
min_vel_x: -0.2
|
|
||||||
vx_samples: 20
|
|
||||||
vy_samples: 5
|
|
||||||
vtheta_samples: 20
|
|
||||||
sim_time: 1.7
|
|
||||||
critics:
|
|
||||||
["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"]
|
|
||||||
BaseObstacle.scale: 0.03 # Slightly aggressive obstacle avoidance
|
|
||||||
PathAlign.scale: 32.0
|
|
||||||
GoalAlign.scale: 24.0
|
|
||||||
PathDist.scale: 32.0
|
|
||||||
GoalDist.scale: 24.0
|
|
||||||
RotateToGoal.scale: 32.0
|
|
||||||
|
|
||||||
local_costmap:
|
|
||||||
local_costmap:
|
|
||||||
ros__parameters:
|
|
||||||
width: 3 # 3m x 3m rolling window
|
|
||||||
height: 3
|
|
||||||
resolution: 0.05
|
|
||||||
update_frequency: 10.0
|
|
||||||
plugins: ["obstacle_layer", "voxel_layer", "inflation_layer"]
|
|
||||||
|
|
||||||
inflation_layer:
|
|
||||||
cost_scaling_factor: 3.0
|
|
||||||
inflation_radius: 0.35 # Tighter inflation for confined spaces
|
|
||||||
|
|
||||||
global_costmap:
|
|
||||||
global_costmap:
|
|
||||||
ros__parameters:
|
|
||||||
resolution: 0.05
|
|
||||||
update_frequency: 5.0
|
|
||||||
plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
|
|
||||||
|
|
||||||
inflation_layer:
|
|
||||||
cost_scaling_factor: 3.0
|
|
||||||
inflation_radius: 0.35 # Tighter inflation for confined spaces
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
# Profile: Outdoor Mode (Issue #506)
|
|
||||||
# Moderate settings for outdoor GPS-based navigation
|
|
||||||
# - Medium velocities for practical outdoor operation (0.8 m/s)
|
|
||||||
# - Standard costmap inflation (0.3m)
|
|
||||||
# - Larger local costmap window (4m x 4m) for path preview
|
|
||||||
# - Reduced obstacle layer complexity (LIDAR focused)
|
|
||||||
|
|
||||||
velocity_smoother:
|
|
||||||
ros__parameters:
|
|
||||||
max_velocity: [0.8, 0.0, 1.0] # Moderate: 0.8 m/s forward, 1.0 rad/s angular
|
|
||||||
min_velocity: [-0.4, 0.0, -1.0]
|
|
||||||
|
|
||||||
controller_server:
|
|
||||||
ros__parameters:
|
|
||||||
controller_frequency: 10.0 # Hz
|
|
||||||
FollowPath:
|
|
||||||
max_vel_x: 0.8 # Moderate forward speed
|
|
||||||
max_vel_theta: 1.0
|
|
||||||
min_vel_x: -0.4
|
|
||||||
vx_samples: 20
|
|
||||||
vy_samples: 5
|
|
||||||
vtheta_samples: 20
|
|
||||||
sim_time: 1.7
|
|
||||||
critics:
|
|
||||||
["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"]
|
|
||||||
BaseObstacle.scale: 0.02 # Standard obstacle avoidance
|
|
||||||
PathAlign.scale: 32.0
|
|
||||||
GoalAlign.scale: 24.0
|
|
||||||
PathDist.scale: 32.0
|
|
||||||
GoalDist.scale: 24.0
|
|
||||||
RotateToGoal.scale: 32.0
|
|
||||||
|
|
||||||
local_costmap:
|
|
||||||
local_costmap:
|
|
||||||
ros__parameters:
|
|
||||||
width: 4 # 4m x 4m rolling window
|
|
||||||
height: 4
|
|
||||||
resolution: 0.05
|
|
||||||
update_frequency: 5.0
|
|
||||||
plugins: ["obstacle_layer", "inflation_layer"]
|
|
||||||
|
|
||||||
obstacle_layer:
|
|
||||||
plugin: "nav2_costmap_2d::ObstacleLayer"
|
|
||||||
observation_sources: scan
|
|
||||||
scan:
|
|
||||||
topic: /scan
|
|
||||||
max_obstacle_height: 0.80
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
data_type: "LaserScan"
|
|
||||||
raytrace_max_range: 8.0
|
|
||||||
obstacle_max_range: 7.5
|
|
||||||
|
|
||||||
inflation_layer:
|
|
||||||
cost_scaling_factor: 3.0
|
|
||||||
inflation_radius: 0.3 # Standard inflation
|
|
||||||
|
|
||||||
global_costmap:
|
|
||||||
global_costmap:
|
|
||||||
ros__parameters:
|
|
||||||
resolution: 0.05
|
|
||||||
update_frequency: 5.0
|
|
||||||
plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
|
|
||||||
|
|
||||||
obstacle_layer:
|
|
||||||
observation_sources: scan
|
|
||||||
scan:
|
|
||||||
topic: /scan
|
|
||||||
max_obstacle_height: 0.80
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
data_type: "LaserScan"
|
|
||||||
raytrace_max_range: 8.0
|
|
||||||
obstacle_max_range: 7.5
|
|
||||||
|
|
||||||
inflation_layer:
|
|
||||||
cost_scaling_factor: 3.0
|
|
||||||
inflation_radius: 0.3 # Standard inflation
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
# Issue #506: Demo Launch Profile
|
|
||||||
# Optimized for demonstrations: tricks, dancing, social interactions, tight maneuvering
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - Max speed: 0.3 m/s (controlled for safe demo performance)
|
|
||||||
# - Geofence: tight (demo area boundary)
|
|
||||||
# - GPS: disabled (indoor demo focus)
|
|
||||||
# - Costmap inflation: aggressive (enhanced obstacle awareness)
|
|
||||||
# - Recovery behaviors: quick recovery from collisions
|
|
||||||
# - Tricks: all enabled (spin, dance, nod, celebrate, shy)
|
|
||||||
# - Social features: enhanced (face emotion, audio response)
|
|
||||||
|
|
||||||
profile: demo
|
|
||||||
description: "Demo mode with tricks, dancing, and social features (0.3 m/s)"
|
|
||||||
|
|
||||||
# ── Navigation & Velocity ──────────────────────────────────────────────────────
|
|
||||||
max_linear_vel: 0.3 # m/s — controlled for safe trick execution
|
|
||||||
max_angular_vel: 0.8 # rad/s — agile rotation for tricks
|
|
||||||
max_vel_theta: 0.8 # rad/s — Nav2 controller (agile)
|
|
||||||
|
|
||||||
# ── Costmap Configuration ──────────────────────────────────────────────────────
|
|
||||||
costmap:
|
|
||||||
inflation_radius: 0.70 # 0.35m robot + 0.35m padding (enhanced safety)
|
|
||||||
cost_scaling_factor: 12.0 # slightly higher cost scaling for obstacle avoidance
|
|
||||||
obstacle_layer:
|
|
||||||
inflation_radius: 0.70
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
|
|
||||||
# ── Behavior Server (Recovery) ─────────────────────────────────────────────────
|
|
||||||
behavior_server:
|
|
||||||
spin:
|
|
||||||
max_rotational_vel: 2.0 # fast spins for tricks
|
|
||||||
min_rotational_vel: 0.5
|
|
||||||
rotational_acc_lim: 3.5 # higher acceleration for trick execution
|
|
||||||
backup:
|
|
||||||
max_linear_vel: 0.12 # conservative backup
|
|
||||||
min_linear_vel: -0.12
|
|
||||||
linear_acc_lim: 2.5
|
|
||||||
max_distance: 0.25 # 25cm max backup distance (safety first)
|
|
||||||
wait:
|
|
||||||
wait_duration: 1000 # 1 second waits (quick recovery)
|
|
||||||
|
|
||||||
# ── Geofence (if enabled) ──────────────────────────────────────────────────────
|
|
||||||
geofence:
|
|
||||||
enabled: true
|
|
||||||
mode: "tight" # tight geofence for controlled demo area
|
|
||||||
radius_m: 3.0 # 3m radius (small demo arena)
|
|
||||||
|
|
||||||
# ── SLAM Configuration ─────────────────────────────────────────────────────────
|
|
||||||
slam:
|
|
||||||
enabled: true
|
|
||||||
mode: "rtabmap" # RTAB-Map for demo space mapping
|
|
||||||
gps_enabled: false # no GPS in demo mode
|
|
||||||
|
|
||||||
# ── Trick Behaviors (Social) ───────────────────────────────────────────────────
|
|
||||||
tricks:
|
|
||||||
enabled: true
|
|
||||||
available: ["spin", "dance", "nod", "celebrate", "shy"]
|
|
||||||
trick_cooldown_sec: 2.0 # slightly longer cooldown for safe sequencing
|
|
||||||
max_trick_duration_sec: 5.0 # 5 second max trick duration
|
|
||||||
|
|
||||||
# ── Social Features ────────────────────────────────────────────────────────────
|
|
||||||
social:
|
|
||||||
face_emotion_enabled: true # enhanced emotional expression
|
|
||||||
audio_response_enabled: true # respond with audio for demo engagement
|
|
||||||
greeting_trigger_enabled: true # greet approaching humans
|
|
||||||
personality: "friendly" # social personality preset
|
|
||||||
|
|
||||||
# ── Person Follower ────────────────────────────────────────────────────────────
|
|
||||||
follower:
|
|
||||||
follow_distance: 1.0 # closer following for demo engagement
|
|
||||||
max_linear_vel: 0.3
|
|
||||||
min_linear_vel: 0.05
|
|
||||||
kp_linear: 0.6 # higher proportional gain for responsiveness
|
|
||||||
kp_angular: 0.4 # moderate angular gain for agility
|
|
||||||
following_enabled: true
|
|
||||||
|
|
||||||
# ── Scenario Presets ──────────────────────────────────────────────────────────
|
|
||||||
scenario:
|
|
||||||
preset: "public_demo" # optimized for crowds and public spaces
|
|
||||||
collision_tolerance: "low" # abort tricks on any obstacle
|
|
||||||
speed_limit_enforcement: "strict" # strictly enforce max_linear_vel
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
# Issue #506: Indoor Launch Profile
|
|
||||||
# Optimized for controlled indoor environments: tight spaces, no GPS, conservative speed
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - Max speed: 0.2 m/s (tight indoor corridors)
|
|
||||||
# - Geofence: tight (e.g., single room: ~5m radius)
|
|
||||||
# - GPS: disabled
|
|
||||||
# - Costmap inflation: aggressive (0.55m → 0.65m for safety)
|
|
||||||
# - Recovery behaviors: conservative (short spin/backup distances)
|
|
||||||
# - Tricks: enabled (safe for indoor demo)
|
|
||||||
|
|
||||||
profile: indoor
|
|
||||||
description: "Tight indoor spaces, no GPS, conservative speed (0.2 m/s)"
|
|
||||||
|
|
||||||
# ── Navigation & Velocity ──────────────────────────────────────────────────────
|
|
||||||
max_linear_vel: 0.2 # m/s — tight indoor corridors
|
|
||||||
max_angular_vel: 0.3 # rad/s — conservative rotation
|
|
||||||
max_vel_theta: 0.3 # rad/s — Nav2 controller (same as above)
|
|
||||||
|
|
||||||
# ── Costmap Configuration ──────────────────────────────────────────────────────
|
|
||||||
costmap:
|
|
||||||
inflation_radius: 0.65 # 0.35m robot + 0.30m padding (aggressive for safety)
|
|
||||||
cost_scaling_factor: 10.0
|
|
||||||
obstacle_layer:
|
|
||||||
inflation_radius: 0.65
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
|
|
||||||
# ── Behavior Server (Recovery) ─────────────────────────────────────────────────
|
|
||||||
behavior_server:
|
|
||||||
spin:
|
|
||||||
max_rotational_vel: 1.0
|
|
||||||
min_rotational_vel: 0.4
|
|
||||||
rotational_acc_lim: 3.2
|
|
||||||
backup:
|
|
||||||
max_linear_vel: 0.10 # very conservative backup
|
|
||||||
min_linear_vel: -0.10
|
|
||||||
linear_acc_lim: 2.5
|
|
||||||
max_distance: 0.3 # 30cm max backup distance
|
|
||||||
wait:
|
|
||||||
wait_duration: 2000 # 2 second waits
|
|
||||||
|
|
||||||
# ── Geofence (if enabled) ──────────────────────────────────────────────────────
|
|
||||||
geofence:
|
|
||||||
enabled: true
|
|
||||||
mode: "tight" # tight geofence for single-room operation
|
|
||||||
radius_m: 5.0 # 5m radius max (typical room size)
|
|
||||||
|
|
||||||
# ── Trick Behaviors (Social) ───────────────────────────────────────────────────
|
|
||||||
tricks:
|
|
||||||
enabled: true
|
|
||||||
available: ["spin", "dance", "nod", "celebrate", "shy"]
|
|
||||||
|
|
||||||
# ── SLAM Configuration ─────────────────────────────────────────────────────────
|
|
||||||
slam:
|
|
||||||
enabled: true
|
|
||||||
mode: "rtabmap" # RTAB-Map SLAM for indoor mapping
|
|
||||||
gps_enabled: false # no GPS indoors
|
|
||||||
|
|
||||||
# ── Person Follower ────────────────────────────────────────────────────────────
|
|
||||||
follower:
|
|
||||||
follow_distance: 1.5 # meters
|
|
||||||
max_linear_vel: 0.2
|
|
||||||
min_linear_vel: 0.05
|
|
||||||
kp_linear: 0.5 # proportional gain for linear velocity
|
|
||||||
kp_angular: 0.3 # proportional gain for angular velocity
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# Issue #506: Outdoor Launch Profile
|
|
||||||
# Optimized for outdoor environments: wide spaces, GPS-enabled, moderate speed
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - Max speed: 0.5 m/s (open outdoor terrain)
|
|
||||||
# - Geofence: wide (e.g., park boundary: ~20m radius)
|
|
||||||
# - GPS: enabled via outdoor_nav with EKF fusion
|
|
||||||
# - Costmap inflation: moderate (0.55m standard)
|
|
||||||
# - Recovery behaviors: moderate distances
|
|
||||||
# - Tricks: enabled (safe for outdoor social features)
|
|
||||||
|
|
||||||
profile: outdoor
|
|
||||||
description: "Wide outdoor spaces, GPS-enabled navigation, moderate speed (0.5 m/s)"
|
|
||||||
|
|
||||||
# ── Navigation & Velocity ──────────────────────────────────────────────────────
|
|
||||||
max_linear_vel: 0.5 # m/s — open outdoor terrain
|
|
||||||
max_angular_vel: 0.5 # rad/s — moderate rotation
|
|
||||||
max_vel_theta: 0.5 # rad/s — Nav2 controller (same as above)
|
|
||||||
|
|
||||||
# ── Costmap Configuration ──────────────────────────────────────────────────────
|
|
||||||
costmap:
|
|
||||||
inflation_radius: 0.55 # 0.35m robot + 0.20m padding (standard)
|
|
||||||
cost_scaling_factor: 10.0
|
|
||||||
obstacle_layer:
|
|
||||||
inflation_radius: 0.55
|
|
||||||
clearing: true
|
|
||||||
marking: true
|
|
||||||
|
|
||||||
# ── Behavior Server (Recovery) ─────────────────────────────────────────────────
|
|
||||||
behavior_server:
|
|
||||||
spin:
|
|
||||||
max_rotational_vel: 1.5
|
|
||||||
min_rotational_vel: 0.4
|
|
||||||
rotational_acc_lim: 3.2
|
|
||||||
backup:
|
|
||||||
max_linear_vel: 0.15 # moderate backup speed
|
|
||||||
min_linear_vel: -0.15
|
|
||||||
linear_acc_lim: 2.5
|
|
||||||
max_distance: 0.5 # 50cm max backup distance
|
|
||||||
wait:
|
|
||||||
wait_duration: 2000 # 2 second waits
|
|
||||||
|
|
||||||
# ── Geofence (if enabled) ──────────────────────────────────────────────────────
|
|
||||||
geofence:
|
|
||||||
enabled: true
|
|
||||||
mode: "wide" # wide geofence for outdoor exploration
|
|
||||||
radius_m: 20.0 # 20m radius max (park boundary)
|
|
||||||
|
|
||||||
# ── SLAM Configuration ─────────────────────────────────────────────────────────
|
|
||||||
slam:
|
|
||||||
enabled: false # no SLAM outdoors — use GPS instead
|
|
||||||
mode: "gps"
|
|
||||||
gps_enabled: true # GPS nav via outdoor_nav + EKF
|
|
||||||
|
|
||||||
# ── Outdoor Navigation (EKF + GPS) ────────────────────────────────────────────
|
|
||||||
outdoor_nav:
|
|
||||||
enabled: true
|
|
||||||
use_gps: true
|
|
||||||
ekf_config: "ekf_outdoor.yaml"
|
|
||||||
|
|
||||||
# ── Person Follower ────────────────────────────────────────────────────────────
|
|
||||||
follower:
|
|
||||||
follow_distance: 2.0 # meters (slightly further for outdoor)
|
|
||||||
max_linear_vel: 0.5
|
|
||||||
min_linear_vel: 0.05
|
|
||||||
kp_linear: 0.4 # slightly lower gain for stability
|
|
||||||
kp_angular: 0.25 # slightly lower gain for outdoor
|
|
||||||
|
|
||||||
# ── Trick Behaviors (Social) ───────────────────────────────────────────────────
|
|
||||||
tricks:
|
|
||||||
enabled: true
|
|
||||||
available: ["spin", "dance", "celebrate"] # subset safe for outdoor
|
|
||||||
@ -1,22 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
full_stack.launch.py — One-command full autonomous stack bringup for SaltyBot.
|
full_stack.launch.py — One-command full autonomous stack bringup for SaltyBot.
|
||||||
|
|
||||||
Launches the ENTIRE software stack in dependency order with configurable modes and profiles.
|
Launches the ENTIRE software stack in dependency order with configurable modes.
|
||||||
|
|
||||||
Usage
|
Usage
|
||||||
─────
|
─────
|
||||||
# Full indoor autonomous (SLAM + Nav2 + person follow + UWB):
|
# Full indoor autonomous (SLAM + Nav2 + person follow + UWB):
|
||||||
ros2 launch saltybot_bringup full_stack.launch.py
|
ros2 launch saltybot_bringup full_stack.launch.py
|
||||||
|
|
||||||
# Indoor profile (conservative 0.2 m/s, tight geofence, no GPS):
|
|
||||||
ros2 launch saltybot_bringup full_stack.launch.py profile:=indoor
|
|
||||||
|
|
||||||
# Outdoor profile (0.5 m/s, wide geofence, GPS-enabled):
|
|
||||||
ros2 launch saltybot_bringup full_stack.launch.py profile:=outdoor
|
|
||||||
|
|
||||||
# Demo profile (tricks, dancing, social features, 0.3 m/s):
|
|
||||||
ros2 launch saltybot_bringup full_stack.launch.py profile:=demo
|
|
||||||
|
|
||||||
# Person-follow only (no SLAM, no Nav2 — living room demo):
|
# Person-follow only (no SLAM, no Nav2 — living room demo):
|
||||||
ros2 launch saltybot_bringup full_stack.launch.py mode:=follow
|
ros2 launch saltybot_bringup full_stack.launch.py mode:=follow
|
||||||
|
|
||||||
@ -144,28 +135,14 @@ def generate_launch_description():
|
|||||||
|
|
||||||
# ── Launch arguments ──────────────────────────────────────────────────────
|
# ── Launch arguments ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Issue #506: Profile argument — overrides mode-based defaults
|
|
||||||
profile_arg = DeclareLaunchArgument(
|
|
||||||
"profile",
|
|
||||||
default_value="indoor",
|
|
||||||
choices=["indoor", "outdoor", "demo"],
|
|
||||||
description=(
|
|
||||||
"Launch profile (Issue #506) — overrides nav2/costmap/behavior params: "
|
|
||||||
"indoor (0.2 m/s, tight geofence, no GPS); "
|
|
||||||
"outdoor (0.5 m/s, wide geofence, GPS); "
|
|
||||||
"demo (0.3 m/s, tricks, social features)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
mode_arg = DeclareLaunchArgument(
|
mode_arg = DeclareLaunchArgument(
|
||||||
"mode",
|
"mode",
|
||||||
default_value="indoor",
|
default_value="indoor",
|
||||||
choices=["indoor", "outdoor", "follow"],
|
choices=["indoor", "outdoor", "follow"],
|
||||||
description=(
|
description=(
|
||||||
"Stack mode (legacy) — indoor: SLAM+Nav2+follow; "
|
"Stack mode — indoor: SLAM+Nav2+follow; "
|
||||||
"outdoor: GPS nav+follow; "
|
"outdoor: GPS nav+follow; "
|
||||||
"follow: sensors+UWB+perception+follower only. "
|
"follow: sensors+UWB+perception+follower only"
|
||||||
"Profiles (profile arg) take precedence over mode."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -260,8 +237,6 @@ enable_mission_logging_arg = DeclareLaunchArgument(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Shared substitution handles ───────────────────────────────────────────
|
# ── Shared substitution handles ───────────────────────────────────────────
|
||||||
# Profile argument for parameter override (Issue #506)
|
|
||||||
profile = LaunchConfiguration("profile")
|
|
||||||
mode = LaunchConfiguration("mode")
|
mode = LaunchConfiguration("mode")
|
||||||
use_sim_time = LaunchConfiguration("use_sim_time")
|
use_sim_time = LaunchConfiguration("use_sim_time")
|
||||||
follow_distance = LaunchConfiguration("follow_distance")
|
follow_distance = LaunchConfiguration("follow_distance")
|
||||||
|
|||||||
@ -12,13 +12,6 @@ Localization is provided by RTAB-Map (slam_rtabmap.launch.py must be running):
|
|||||||
Output:
|
Output:
|
||||||
/cmd_vel — consumed by saltybot_bridge → STM32 over UART
|
/cmd_vel — consumed by saltybot_bridge → STM32 over UART
|
||||||
|
|
||||||
Profile Support (Issue #506)
|
|
||||||
────────────────────────────
|
|
||||||
Supports profile-based parameter overrides via 'profile' launch argument:
|
|
||||||
profile:=indoor — conservative (0.2 m/s, tight geofence, aggressive inflation)
|
|
||||||
profile:=outdoor — moderate (0.5 m/s, wide geofence, standard inflation)
|
|
||||||
profile:=demo — agile (0.3 m/s, tricks enabled, enhanced obstacle avoidance)
|
|
||||||
|
|
||||||
Run sequence on Orin:
|
Run sequence on Orin:
|
||||||
1. docker compose up saltybot-ros2 # RTAB-Map + sensors
|
1. docker compose up saltybot-ros2 # RTAB-Map + sensors
|
||||||
2. docker compose up saltybot-nav2 # this launch file
|
2. docker compose up saltybot-nav2 # this launch file
|
||||||
@ -27,25 +20,14 @@ Run sequence on Orin:
|
|||||||
import os
|
import os
|
||||||
from ament_index_python.packages import get_package_share_directory
|
from ament_index_python.packages import get_package_share_directory
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, LogInfo
|
from launch.actions import IncludeLaunchDescription
|
||||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||||
from launch.substitutions import LaunchConfiguration
|
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description():
|
def generate_launch_description():
|
||||||
nav2_bringup_dir = get_package_share_directory('nav2_bringup')
|
nav2_bringup_dir = get_package_share_directory('nav2_bringup')
|
||||||
bringup_dir = get_package_share_directory('saltybot_bringup')
|
bringup_dir = get_package_share_directory('saltybot_bringup')
|
||||||
|
|
||||||
# Profile argument (Issue #506)
|
|
||||||
profile_arg = DeclareLaunchArgument(
|
|
||||||
"profile",
|
|
||||||
default_value="indoor",
|
|
||||||
choices=["indoor", "outdoor", "demo"],
|
|
||||||
description="Launch profile for parameter overrides (Issue #506)"
|
|
||||||
)
|
|
||||||
|
|
||||||
profile = LaunchConfiguration('profile')
|
|
||||||
|
|
||||||
nav2_params_file = os.path.join(bringup_dir, 'config', 'nav2_params.yaml')
|
nav2_params_file = os.path.join(bringup_dir, 'config', 'nav2_params.yaml')
|
||||||
bt_xml_file = os.path.join(
|
bt_xml_file = os.path.join(
|
||||||
bringup_dir, 'behavior_trees', 'navigate_to_pose_with_recovery.xml'
|
bringup_dir, 'behavior_trees', 'navigate_to_pose_with_recovery.xml'
|
||||||
@ -65,12 +47,4 @@ def generate_launch_description():
|
|||||||
}.items(),
|
}.items(),
|
||||||
)
|
)
|
||||||
|
|
||||||
profile_log = LogInfo(
|
return LaunchDescription([nav2_launch])
|
||||||
msg=['[nav2] Loaded profile: ', profile]
|
|
||||||
)
|
|
||||||
|
|
||||||
return LaunchDescription([
|
|
||||||
profile_arg,
|
|
||||||
profile_log,
|
|
||||||
nav2_launch,
|
|
||||||
])
|
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Issue #506: Profile Loader for SaltyBot Launch Profiles
|
|
||||||
|
|
||||||
Loads and merges launch parameter profiles (indoor, outdoor, demo) into
|
|
||||||
the full_stack.launch.py configuration.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from saltybot_bringup.profile_loader import ProfileLoader
|
|
||||||
loader = ProfileLoader()
|
|
||||||
profile_params = loader.load_profile('indoor')
|
|
||||||
# Override parameters based on profile
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import yaml
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from ament_index_python.packages import get_package_share_directory
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileLoader:
|
|
||||||
"""Load and parse launch parameter profiles."""
|
|
||||||
|
|
||||||
VALID_PROFILES = ["indoor", "outdoor", "demo"]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize profile loader with package paths."""
|
|
||||||
self.pkg_dir = get_package_share_directory("saltybot_bringup")
|
|
||||||
self.profiles_dir = os.path.join(self.pkg_dir, "config", "profiles")
|
|
||||||
|
|
||||||
def load_profile(self, profile_name: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Load a profile by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_name: Profile name (indoor, outdoor, demo)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary of profile parameters
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If profile doesn't exist
|
|
||||||
yaml.YAMLError: If profile YAML is invalid
|
|
||||||
"""
|
|
||||||
if profile_name not in self.VALID_PROFILES:
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid profile '{profile_name}'. "
|
|
||||||
f"Valid profiles: {', '.join(self.VALID_PROFILES)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_path = os.path.join(self.profiles_dir, f"{profile_name}.yaml")
|
|
||||||
|
|
||||||
if not os.path.exists(profile_path):
|
|
||||||
raise FileNotFoundError(f"Profile file not found: {profile_path}")
|
|
||||||
|
|
||||||
with open(profile_path, "r") as f:
|
|
||||||
profile = yaml.safe_load(f)
|
|
||||||
|
|
||||||
if not profile:
|
|
||||||
raise ValueError(f"Profile file is empty: {profile_path}")
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def get_nav2_overrides(self, profile: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Extract Nav2-specific parameters from profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: Loaded profile dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with nav2_params_file overrides
|
|
||||||
"""
|
|
||||||
overrides = {}
|
|
||||||
|
|
||||||
# Velocity limits
|
|
||||||
if "max_linear_vel" in profile:
|
|
||||||
overrides["max_vel_x"] = profile["max_linear_vel"]
|
|
||||||
overrides["max_speed_xy"] = profile["max_linear_vel"]
|
|
||||||
|
|
||||||
if "max_angular_vel" in profile:
|
|
||||||
overrides["max_vel_theta"] = profile["max_angular_vel"]
|
|
||||||
|
|
||||||
# Costmap parameters
|
|
||||||
if "costmap" in profile and "inflation_radius" in profile["costmap"]:
|
|
||||||
overrides["inflation_radius"] = profile["costmap"]["inflation_radius"]
|
|
||||||
|
|
||||||
return overrides
|
|
||||||
|
|
||||||
def get_launch_args(self, profile: Dict[str, Any]) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Extract launch arguments from profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: Loaded profile dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary of launch argument name → value
|
|
||||||
"""
|
|
||||||
args = {}
|
|
||||||
|
|
||||||
# Core parameters
|
|
||||||
args["max_linear_vel"] = str(profile.get("max_linear_vel", 0.2))
|
|
||||||
args["follow_distance"] = str(
|
|
||||||
profile.get("follower", {}).get("follow_distance", 1.5)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mode (indoor/outdoor affects SLAM vs GPS)
|
|
||||||
if profile.get("slam", {}).get("enabled"):
|
|
||||||
args["mode"] = "indoor"
|
|
||||||
else:
|
|
||||||
args["mode"] = "outdoor"
|
|
||||||
|
|
||||||
# Feature toggles
|
|
||||||
args["enable_perception"] = "true"
|
|
||||||
|
|
||||||
if profile.get("tricks", {}).get("enabled"):
|
|
||||||
args["enable_follower"] = "true"
|
|
||||||
else:
|
|
||||||
args["enable_follower"] = "false"
|
|
||||||
|
|
||||||
return args
|
|
||||||
|
|
||||||
def validate_profile(self, profile: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
Validate profile structure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: Profile dictionary to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid, raises ValueError otherwise
|
|
||||||
"""
|
|
||||||
required_keys = ["profile", "description"]
|
|
||||||
|
|
||||||
for key in required_keys:
|
|
||||||
if key not in profile:
|
|
||||||
raise ValueError(f"Profile missing required key: {key}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def merge_profiles(
|
|
||||||
self,
|
|
||||||
base_profile: Dict[str, Any],
|
|
||||||
override_profile: Dict[str, Any],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Deep merge profile dictionaries (override_profile takes precedence).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_profile: Base profile (e.g., indoor)
|
|
||||||
override_profile: Profile to merge in (higher priority)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Merged profile dictionary
|
|
||||||
"""
|
|
||||||
merged = base_profile.copy()
|
|
||||||
|
|
||||||
for key, value in override_profile.items():
|
|
||||||
if key in merged and isinstance(merged[key], dict) and isinstance(
|
|
||||||
value, dict
|
|
||||||
):
|
|
||||||
merged[key] = self.merge_profiles(merged[key], value)
|
|
||||||
else:
|
|
||||||
merged[key] = value
|
|
||||||
|
|
||||||
return merged
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
terrain_classification:
|
|
||||||
ros__parameters:
|
|
||||||
control_rate: 10.0
|
|
||||||
depth_topic: "/camera/depth/image_rect_raw"
|
|
||||||
lidar_topic: "/scan"
|
|
||||||
depth_std_threshold: 0.02
|
|
||||||
min_depth_m: 0.1
|
|
||||||
max_depth_m: 3.0
|
|
||||||
confidence_threshold: 0.5
|
|
||||||
hysteresis_samples: 5
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Launch file for terrain classification node."""
|
|
||||||
|
|
||||||
from launch import LaunchDescription
|
|
||||||
from launch_ros.actions import Node
|
|
||||||
from launch.substitutions import LaunchConfiguration
|
|
||||||
from launch.actions import DeclareLaunchArgument
|
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description():
|
|
||||||
"""Generate launch description."""
|
|
||||||
depth_topic = LaunchConfiguration("depth_topic", default="/camera/depth/image_rect_raw")
|
|
||||||
lidar_topic = LaunchConfiguration("lidar_topic", default="/scan")
|
|
||||||
control_rate = LaunchConfiguration("control_rate", default="10.0")
|
|
||||||
confidence_threshold = LaunchConfiguration("confidence_threshold", default="0.5")
|
|
||||||
|
|
||||||
return LaunchDescription([
|
|
||||||
DeclareLaunchArgument("depth_topic", default_value="/camera/depth/image_rect_raw",
|
|
||||||
description="RealSense depth topic"),
|
|
||||||
DeclareLaunchArgument("lidar_topic", default_value="/scan",
|
|
||||||
description="RPLIDAR scan topic"),
|
|
||||||
DeclareLaunchArgument("control_rate", default_value="10.0",
|
|
||||||
description="Control loop rate (Hz)"),
|
|
||||||
DeclareLaunchArgument("confidence_threshold", default_value="0.5",
|
|
||||||
description="Minimum confidence to publish"),
|
|
||||||
|
|
||||||
Node(
|
|
||||||
package="saltybot_terrain_classification",
|
|
||||||
executable="terrain_classification_node",
|
|
||||||
name="terrain_classification",
|
|
||||||
output="screen",
|
|
||||||
parameters=[
|
|
||||||
{"depth_topic": depth_topic},
|
|
||||||
{"lidar_topic": lidar_topic},
|
|
||||||
{"control_rate": control_rate},
|
|
||||||
{"confidence_threshold": confidence_threshold},
|
|
||||||
{"depth_std_threshold": 0.02},
|
|
||||||
{"min_depth_m": 0.1},
|
|
||||||
{"max_depth_m": 3.0},
|
|
||||||
{"hysteresis_samples": 5},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?xml version="1.0"?>
|
|
||||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
|
||||||
<package format="3">
|
|
||||||
<name>saltybot_terrain_classification</name>
|
|
||||||
<version>0.1.0</version>
|
|
||||||
<description>Terrain classification using RealSense depth and RPLIDAR (Issue #469)</description>
|
|
||||||
<maintainer email="seb@example.com">SaltyLab</maintainer>
|
|
||||||
<license>MIT</license>
|
|
||||||
|
|
||||||
<buildtool_depend>ament_python</buildtool_depend>
|
|
||||||
|
|
||||||
<depend>rclpy</depend>
|
|
||||||
<depend>std_msgs</depend>
|
|
||||||
<depend>geometry_msgs</depend>
|
|
||||||
<depend>sensor_msgs</depend>
|
|
||||||
<depend>message_filters</depend>
|
|
||||||
<depend>numpy</depend>
|
|
||||||
<depend>opencv-python</depend>
|
|
||||||
<depend>cv_bridge</depend>
|
|
||||||
|
|
||||||
<test_depend>ament_copyright</test_depend>
|
|
||||||
<test_depend>ament_flake8</test_depend>
|
|
||||||
<test_depend>ament_pep257</test_depend>
|
|
||||||
<test_depend>python3-pytest</test_depend>
|
|
||||||
|
|
||||||
<export>
|
|
||||||
<build_type>ament_python</build_type>
|
|
||||||
</export>
|
|
||||||
</package>
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
"""Depth Feature Extractor — Surface roughness from RealSense depth."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
import numpy as np
|
|
||||||
from typing import Optional
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
|
|
||||||
class DepthExtractor:
|
|
||||||
"""Extract terrain roughness features from RealSense depth images."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
roi_width_px: int = 320,
|
|
||||||
roi_height_px: int = 240,
|
|
||||||
min_depth_m: float = 0.1,
|
|
||||||
max_depth_m: float = 3.0,
|
|
||||||
bilateral_d: int = 5,
|
|
||||||
):
|
|
||||||
self._roi_w = roi_width_px
|
|
||||||
self._roi_h = roi_height_px
|
|
||||||
self._min_depth = min_depth_m
|
|
||||||
self._max_depth = max_depth_m
|
|
||||||
self._bilateral_d = bilateral_d
|
|
||||||
|
|
||||||
def extract_roughness(self, depth_image: np.ndarray) -> Optional[float]:
|
|
||||||
"""Extract surface roughness from depth image."""
|
|
||||||
if depth_image is None or depth_image.size == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if depth_image.dtype == np.uint16:
|
|
||||||
depth_m = depth_image.astype(np.float32) / 1000.0
|
|
||||||
else:
|
|
||||||
depth_m = depth_image.astype(np.float32)
|
|
||||||
|
|
||||||
h, w = depth_m.shape
|
|
||||||
x_start = (w - self._roi_w) // 2
|
|
||||||
y_start = (h - self._roi_h) // 2
|
|
||||||
x_end = x_start + self._roi_w
|
|
||||||
y_end = y_start + self._roi_h
|
|
||||||
|
|
||||||
roi = depth_m[max(0, y_start):min(h, y_end), max(0, x_start):min(w, x_end)]
|
|
||||||
|
|
||||||
valid_mask = (roi > self._min_depth) & (roi < self._max_depth)
|
|
||||||
valid_depths = roi[valid_mask]
|
|
||||||
|
|
||||||
if len(valid_depths) < 10:
|
|
||||||
return None
|
|
||||||
|
|
||||||
roi_smooth = cv2.bilateralFilter(roi.astype(np.float32), self._bilateral_d, 0.1, 1.0)
|
|
||||||
depth_variance = np.var(valid_depths)
|
|
||||||
|
|
||||||
grad_x = cv2.Sobel(roi_smooth, cv2.CV_32F, 1, 0, ksize=3)
|
|
||||||
grad_y = cv2.Sobel(roi_smooth, cv2.CV_32F, 0, 1, ksize=3)
|
|
||||||
grad_magnitude = np.sqrt(grad_x**2 + grad_y**2)
|
|
||||||
|
|
||||||
valid_grad = grad_magnitude[valid_mask]
|
|
||||||
grad_mean = np.mean(valid_grad) if len(valid_grad) > 0 else 0.0
|
|
||||||
|
|
||||||
depth_roughness = min(1.0, depth_variance / 0.05)
|
|
||||||
grad_roughness = min(1.0, grad_mean / 0.02)
|
|
||||||
|
|
||||||
roughness = 0.6 * depth_roughness + 0.4 * grad_roughness
|
|
||||||
return float(roughness)
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
"""LIDAR Feature Extractor — Surface characteristics from RPLIDAR."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
import numpy as np
|
|
||||||
from typing import Optional
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
|
|
||||||
class LidarExtractor:
|
|
||||||
"""Extract terrain reflectance features from RPLIDAR scans."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ground_angle_front: float = 10.0,
|
|
||||||
ground_angle_rear: float = 170.0,
|
|
||||||
reflectance_window: int = 20,
|
|
||||||
):
|
|
||||||
self._front_angle = ground_angle_front
|
|
||||||
self._rear_angle = ground_angle_rear
|
|
||||||
self._refl_window = deque(maxlen=reflectance_window)
|
|
||||||
|
|
||||||
def extract_reflectance(self, ranges: np.ndarray, intensities: np.ndarray) -> Optional[float]:
|
|
||||||
"""Extract mean reflectance from ground-hitting rays."""
|
|
||||||
if len(ranges) == 0 or len(intensities) == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
intensities_norm = intensities.astype(np.float32)
|
|
||||||
if np.max(intensities_norm) > 1.5:
|
|
||||||
intensities_norm /= 255.0
|
|
||||||
else:
|
|
||||||
intensities_norm = np.clip(intensities_norm, 0.0, 1.0)
|
|
||||||
|
|
||||||
valid_mask = (ranges > 0.2) & (ranges < 5.0)
|
|
||||||
valid_intensities = intensities_norm[valid_mask]
|
|
||||||
|
|
||||||
if len(valid_intensities) < 5:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mean_reflectance = float(np.mean(valid_intensities))
|
|
||||||
self._refl_window.append(mean_reflectance)
|
|
||||||
|
|
||||||
if self._refl_window:
|
|
||||||
return float(np.mean(list(self._refl_window)))
|
|
||||||
return mean_reflectance
|
|
||||||
|
|
||||||
def extract_range_variance(self, ranges: np.ndarray) -> Optional[float]:
|
|
||||||
"""Extract surface variance from range measurements."""
|
|
||||||
if len(ranges) == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
valid_ranges = ranges[(ranges > 0.2) & (ranges < 5.0)]
|
|
||||||
|
|
||||||
if len(valid_ranges) < 5:
|
|
||||||
return None
|
|
||||||
|
|
||||||
range_variance = float(np.var(valid_ranges))
|
|
||||||
normalized = min(1.0, range_variance / 0.2)
|
|
||||||
return float(normalized)
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
terrain_classification_node.py — Terrain classification (Issue #469).
|
|
||||||
|
|
||||||
Fuses RealSense D435i depth + RPLIDAR A1M8 to classify terrain and recommend speed.
|
|
||||||
|
|
||||||
Pipeline (10 Hz)
|
|
||||||
────────────────
|
|
||||||
1. Extract depth-based roughness features
|
|
||||||
2. Extract lidar-based reflectance features
|
|
||||||
3. Classify terrain using rule-based matcher
|
|
||||||
4. Publish /saltybot/terrain_type (JSON + string)
|
|
||||||
5. Publish speed recommendations
|
|
||||||
|
|
||||||
Publishes
|
|
||||||
─────────
|
|
||||||
/saltybot/terrain_type std_msgs/String — JSON: type, confidence, speed_scale
|
|
||||||
/saltybot/terrain_type_string std_msgs/String — Human-readable type name
|
|
||||||
/saltybot/terrain_speed_scale std_msgs/Float32 — Speed multiplier [0.0, 1.0]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
import rclpy
|
|
||||||
from rclpy.node import Node
|
|
||||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
|
|
||||||
|
|
||||||
from sensor_msgs.msg import Image, LaserScan
|
|
||||||
from std_msgs.msg import String, Float32
|
|
||||||
import cv2
|
|
||||||
from cv_bridge import CvBridge
|
|
||||||
|
|
||||||
from saltybot_terrain_classification.terrain_classifier import TerrainClassifier
|
|
||||||
from saltybot_terrain_classification.depth_extractor import DepthExtractor
|
|
||||||
from saltybot_terrain_classification.lidar_extractor import LidarExtractor
|
|
||||||
|
|
||||||
|
|
||||||
class TerrainClassificationNode(Node):
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__("terrain_classification")
|
|
||||||
|
|
||||||
self._declare_params()
|
|
||||||
p = self._load_params()
|
|
||||||
|
|
||||||
self._classifier = TerrainClassifier(
|
|
||||||
depth_std_threshold=p["depth_std_threshold"],
|
|
||||||
hysteresis_count=p["hysteresis_samples"],
|
|
||||||
)
|
|
||||||
self._depth_extractor = DepthExtractor(
|
|
||||||
roi_width_px=320,
|
|
||||||
roi_height_px=240,
|
|
||||||
min_depth_m=p["min_depth_m"],
|
|
||||||
max_depth_m=p["max_depth_m"],
|
|
||||||
)
|
|
||||||
self._lidar_extractor = LidarExtractor()
|
|
||||||
self._cv_bridge = CvBridge()
|
|
||||||
|
|
||||||
self._latest_depth_image = None
|
|
||||||
self._latest_scan = None
|
|
||||||
self._last_depth_t = 0.0
|
|
||||||
self._last_scan_t = 0.0
|
|
||||||
|
|
||||||
depth_qos = QoSProfile(
|
|
||||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
|
||||||
history=HistoryPolicy.KEEP_LAST,
|
|
||||||
depth=1,
|
|
||||||
)
|
|
||||||
lidar_qos = QoSProfile(
|
|
||||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
|
||||||
history=HistoryPolicy.KEEP_LAST,
|
|
||||||
depth=1,
|
|
||||||
)
|
|
||||||
pub_qos = QoSProfile(
|
|
||||||
reliability=ReliabilityPolicy.RELIABLE,
|
|
||||||
history=HistoryPolicy.KEEP_LAST,
|
|
||||||
depth=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.create_subscription(
|
|
||||||
Image, p["depth_topic"], self._depth_cb, depth_qos
|
|
||||||
)
|
|
||||||
self.create_subscription(
|
|
||||||
LaserScan, p["lidar_topic"], self._scan_cb, lidar_qos
|
|
||||||
)
|
|
||||||
|
|
||||||
self._terrain_type_pub = self.create_publisher(
|
|
||||||
String, "/saltybot/terrain_type", pub_qos
|
|
||||||
)
|
|
||||||
self._terrain_type_string_pub = self.create_publisher(
|
|
||||||
String, "/saltybot/terrain_type_string", pub_qos
|
|
||||||
)
|
|
||||||
self._speed_scale_pub = self.create_publisher(
|
|
||||||
Float32, "/saltybot/terrain_speed_scale", pub_qos
|
|
||||||
)
|
|
||||||
|
|
||||||
rate = p["control_rate"]
|
|
||||||
self._timer = self.create_timer(1.0 / rate, self._control_cb)
|
|
||||||
|
|
||||||
self.get_logger().info(
|
|
||||||
f"TerrainClassificationNode ready rate={rate}Hz "
|
|
||||||
f"depth={p['depth_topic']} lidar={p['lidar_topic']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _declare_params(self) -> None:
|
|
||||||
self.declare_parameter("control_rate", 10.0)
|
|
||||||
self.declare_parameter("depth_topic", "/camera/depth/image_rect_raw")
|
|
||||||
self.declare_parameter("lidar_topic", "/scan")
|
|
||||||
self.declare_parameter("depth_std_threshold", 0.02)
|
|
||||||
self.declare_parameter("min_depth_m", 0.1)
|
|
||||||
self.declare_parameter("max_depth_m", 3.0)
|
|
||||||
self.declare_parameter("confidence_threshold", 0.5)
|
|
||||||
self.declare_parameter("hysteresis_samples", 5)
|
|
||||||
|
|
||||||
def _load_params(self) -> dict:
|
|
||||||
g = self.get_parameter
|
|
||||||
return {k: g(k).value for k in [
|
|
||||||
"control_rate", "depth_topic", "lidar_topic",
|
|
||||||
"depth_std_threshold", "min_depth_m", "max_depth_m",
|
|
||||||
"confidence_threshold", "hysteresis_samples",
|
|
||||||
]}
|
|
||||||
|
|
||||||
def _depth_cb(self, msg: Image) -> None:
|
|
||||||
self._latest_depth_image = msg
|
|
||||||
self._last_depth_t = time.monotonic()
|
|
||||||
|
|
||||||
def _scan_cb(self, msg: LaserScan) -> None:
|
|
||||||
self._latest_scan = msg
|
|
||||||
self._last_scan_t = time.monotonic()
|
|
||||||
|
|
||||||
def _control_cb(self) -> None:
|
|
||||||
p = self._load_params()
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
depth_age = (now - self._last_depth_t) if self._last_depth_t > 0.0 else 1e9
|
|
||||||
scan_age = (now - self._last_scan_t) if self._last_scan_t > 0.0 else 1e9
|
|
||||||
|
|
||||||
if depth_age > 1.0 or scan_age > 1.0:
|
|
||||||
return
|
|
||||||
|
|
||||||
depth_roughness = None
|
|
||||||
lidar_reflectance = None
|
|
||||||
|
|
||||||
if self._latest_depth_image is not None:
|
|
||||||
try:
|
|
||||||
depth_cv = self._cv_bridge.imgmsg_to_cv2(
|
|
||||||
self._latest_depth_image, desired_encoding="passthrough"
|
|
||||||
)
|
|
||||||
depth_roughness = self._depth_extractor.extract_roughness(depth_cv)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self._latest_scan is not None:
|
|
||||||
try:
|
|
||||||
ranges = np.array(self._latest_scan.ranges, dtype=np.float32)
|
|
||||||
intensities = np.array(self._latest_scan.intensities, dtype=np.float32)
|
|
||||||
lidar_reflectance = self._lidar_extractor.extract_reflectance(
|
|
||||||
ranges, intensities
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if depth_roughness is None:
|
|
||||||
depth_roughness = 0.3
|
|
||||||
if lidar_reflectance is None:
|
|
||||||
lidar_reflectance = 0.5
|
|
||||||
|
|
||||||
classification = self._classifier.update(depth_roughness, lidar_reflectance)
|
|
||||||
|
|
||||||
if classification.confidence < p["confidence_threshold"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._publish_terrain_type(classification)
|
|
||||||
self._publish_speed_scale(classification.speed_scale)
|
|
||||||
|
|
||||||
self.get_logger().info(
|
|
||||||
f"Terrain: {classification.surface_type} "
|
|
||||||
f"confidence={classification.confidence:.2f} "
|
|
||||||
f"speed_scale={classification.speed_scale:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _publish_terrain_type(self, classification) -> None:
|
|
||||||
"""Publish terrain classification JSON."""
|
|
||||||
msg = String()
|
|
||||||
msg.data = json.dumps({
|
|
||||||
"surface_type": classification.surface_type,
|
|
||||||
"confidence": round(classification.confidence, 3),
|
|
||||||
"roughness": round(classification.roughness, 3),
|
|
||||||
"reflectance": round(classification.reflectance, 3),
|
|
||||||
"speed_scale": round(classification.speed_scale, 3),
|
|
||||||
})
|
|
||||||
self._terrain_type_pub.publish(msg)
|
|
||||||
|
|
||||||
msg_str = String()
|
|
||||||
msg_str.data = classification.surface_type
|
|
||||||
self._terrain_type_string_pub.publish(msg_str)
|
|
||||||
|
|
||||||
def _publish_speed_scale(self, scale: float) -> None:
|
|
||||||
"""Publish speed scale multiplier."""
|
|
||||||
msg = Float32()
|
|
||||||
msg.data = float(scale)
|
|
||||||
self._speed_scale_pub.publish(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
|
||||||
rclpy.init(args=args)
|
|
||||||
node = TerrainClassificationNode()
|
|
||||||
try:
|
|
||||||
rclpy.spin(node)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
node.destroy_node()
|
|
||||||
rclpy.try_shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
"""Terrain Classifier — Multi-sensor classification (Issue #469)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from collections import deque
|
|
||||||
import numpy as np
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TerrainClassification:
|
|
||||||
"""Classification result with confidence."""
|
|
||||||
surface_type: str
|
|
||||||
confidence: float
|
|
||||||
roughness: float
|
|
||||||
reflectance: float
|
|
||||||
speed_scale: float
|
|
||||||
|
|
||||||
|
|
||||||
class TerrainClassifier:
|
|
||||||
"""Multi-sensor terrain classifier using depth variance + reflectance."""
|
|
||||||
|
|
||||||
_SURFACE_RULES = [
|
|
||||||
("smooth", 0.10, 0.70, 1.00, 1.00),
|
|
||||||
("carpet", 0.25, 0.60, 0.90, 0.90),
|
|
||||||
("grass", 0.40, 0.40, 0.70, 0.75),
|
|
||||||
("gravel", 1.00, 0.20, 0.60, 0.60),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
depth_std_threshold: float = 0.02,
|
|
||||||
reflectance_window_size: int = 10,
|
|
||||||
hysteresis_count: int = 5,
|
|
||||||
control_rate_hz: float = 10.0,
|
|
||||||
):
|
|
||||||
self._depth_std_threshold = depth_std_threshold
|
|
||||||
self._hysteresis_count = hysteresis_count
|
|
||||||
self._depth_vars = deque(maxlen=reflectance_window_size)
|
|
||||||
self._reflectances = deque(maxlen=reflectance_window_size)
|
|
||||||
self._current_type = "unknown"
|
|
||||||
self._current_confidence = 0.0
|
|
||||||
self._candidate_type = "unknown"
|
|
||||||
self._candidate_count = 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_surface_type(self) -> str:
|
|
||||||
return self._current_type
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_confidence(self) -> float:
|
|
||||||
return self._current_confidence
|
|
||||||
|
|
||||||
def update(self, depth_variance: float, reflectance_mean: float) -> TerrainClassification:
|
|
||||||
"""Update classifier with sensor measurements."""
|
|
||||||
roughness = min(1.0, depth_variance / (self._depth_std_threshold + 1e-6))
|
|
||||||
reflectance = np.clip(reflectance_mean, 0.0, 1.0)
|
|
||||||
|
|
||||||
self._depth_vars.append(roughness)
|
|
||||||
self._reflectances.append(reflectance)
|
|
||||||
|
|
||||||
avg_roughness = np.mean(list(self._depth_vars)) if self._depth_vars else 0.0
|
|
||||||
avg_reflectance = np.mean(list(self._reflectances)) if self._reflectances else 0.5
|
|
||||||
|
|
||||||
candidate_type, candidate_confidence, speed_scale = self._classify(
|
|
||||||
avg_roughness, avg_reflectance
|
|
||||||
)
|
|
||||||
|
|
||||||
if candidate_type == self._candidate_type:
|
|
||||||
self._candidate_count += 1
|
|
||||||
else:
|
|
||||||
self._candidate_type = candidate_type
|
|
||||||
self._candidate_count = 1
|
|
||||||
|
|
||||||
if self._candidate_count >= self._hysteresis_count:
|
|
||||||
self._current_type = candidate_type
|
|
||||||
self._current_confidence = candidate_confidence
|
|
||||||
|
|
||||||
return TerrainClassification(
|
|
||||||
surface_type=self._current_type,
|
|
||||||
confidence=self._current_confidence,
|
|
||||||
roughness=avg_roughness,
|
|
||||||
reflectance=avg_reflectance,
|
|
||||||
speed_scale=speed_scale,
|
|
||||||
)
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
"""Clear buffers and reset to unknown."""
|
|
||||||
self._depth_vars.clear()
|
|
||||||
self._reflectances.clear()
|
|
||||||
self._current_type = "unknown"
|
|
||||||
self._current_confidence = 0.0
|
|
||||||
self._candidate_type = "unknown"
|
|
||||||
self._candidate_count = 0
|
|
||||||
|
|
||||||
def _classify(self, roughness: float, reflectance: float) -> tuple[str, float, float]:
|
|
||||||
"""Classify terrain and compute confidence + speed scale."""
|
|
||||||
best_match = None
|
|
||||||
best_score = 0.0
|
|
||||||
best_speed = 1.0
|
|
||||||
|
|
||||||
for name, rough_max, refl_min, refl_max, speed in self._SURFACE_RULES:
|
|
||||||
rough_penalty = max(0.0, (roughness - rough_max) / rough_max) if roughness > rough_max else 0.0
|
|
||||||
refl_penalty = 0.0
|
|
||||||
|
|
||||||
if reflectance < refl_min:
|
|
||||||
refl_penalty = (refl_min - reflectance) / refl_min
|
|
||||||
elif reflectance > refl_max:
|
|
||||||
refl_penalty = (reflectance - refl_max) / (1.0 - refl_max)
|
|
||||||
|
|
||||||
score = 1.0 - (rough_penalty + refl_penalty) * 0.5
|
|
||||||
|
|
||||||
if score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best_match = name
|
|
||||||
best_speed = speed
|
|
||||||
|
|
||||||
confidence = max(0.0, best_score)
|
|
||||||
return best_match or "unknown", confidence, best_speed
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def speed_scale_for_surface(surface_type: str) -> float:
|
|
||||||
"""Get speed scale for a surface type."""
|
|
||||||
for name, _, _, _, speed in TerrainClassifier._SURFACE_RULES:
|
|
||||||
if name == surface_type:
|
|
||||||
return speed
|
|
||||||
return 0.75
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
[develop]
|
|
||||||
script_dir=$base/lib/saltybot_terrain_classification
|
|
||||||
[install]
|
|
||||||
script_dir=$base/lib/saltybot_terrain_classification
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from setuptools import setup, find_packages
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="saltybot_terrain_classification",
|
|
||||||
version="0.1.0",
|
|
||||||
packages=find_packages(),
|
|
||||||
data_files=[
|
|
||||||
("share/ament_index/resource_index/packages",
|
|
||||||
["resource/saltybot_terrain_classification"]),
|
|
||||||
("share/saltybot_terrain_classification", ["package.xml"]),
|
|
||||||
],
|
|
||||||
install_requires=["setuptools"],
|
|
||||||
zip_safe=True,
|
|
||||||
maintainer="SaltyLab",
|
|
||||||
maintainer_email="seb@example.com",
|
|
||||||
description="Terrain classification using RealSense depth and RPLIDAR (Issue #469)",
|
|
||||||
license="MIT",
|
|
||||||
tests_require=["pytest"],
|
|
||||||
entry_points={
|
|
||||||
"console_scripts": [
|
|
||||||
"terrain_classification_node=saltybot_terrain_classification.terrain_classification_node:main",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user