forked from hq/ros2_office_vlm
update
This commit is contained in:
parent
987cbdabd9
commit
478fb26d41
|
|
@ -1,47 +1,47 @@
|
||||||
{
|
{
|
||||||
"Refrigerator": {
|
"Refrigerator": {
|
||||||
"highest_score": 0.301025390625,
|
"highest_score": 0.301513671875,
|
||||||
"last_detected": "2026-04-29T11:38:32.954873",
|
"last_detected": "2026-05-18T14:13:25.085735",
|
||||||
"position": {
|
"position": {
|
||||||
"x": -3.258748492688493,
|
"x": -1.3181,
|
||||||
"y": -0.657160436573238,
|
"y": 0.5165,
|
||||||
"z": 0.23105746596100607
|
"z": 0.7319
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"water dispenser": {
|
"water dispenser": {
|
||||||
"highest_score": 0.310791015625,
|
"highest_score": 0.316162109375,
|
||||||
"last_detected": "2026-04-29T11:38:29.064743",
|
"last_detected": "2026-05-18T14:15:56.440127",
|
||||||
"position": {
|
"position": {
|
||||||
"x": -2.7678260060491566,
|
"x": -1.6292,
|
||||||
"y": -0.2820617355615878,
|
"y": -1.7906,
|
||||||
"z": -0.006539989127681528
|
"z": 0.661
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sofa": {
|
"sofa": {
|
||||||
"highest_score": 0.316650390625,
|
"highest_score": 0.307861328125,
|
||||||
"last_detected": "2026-04-29T11:36:16.740557",
|
"last_detected": "2026-05-18T14:17:27.585956",
|
||||||
"position": {
|
"position": {
|
||||||
"x": -1.444039469788105,
|
"x": 1.4535,
|
||||||
"y": -1.389487320024688,
|
"y": -0.4709,
|
||||||
"z": 0.9400984640933344
|
"z": 0.5445
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"white toilet": {
|
"white toilet": {
|
||||||
"highest_score": 0.29931640625,
|
"highest_score": 0.269287109375,
|
||||||
"last_detected": "2026-04-29T11:32:23.241802",
|
"last_detected": "2026-05-18T14:13:07.308074",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 0.5281400126286391,
|
"x": -4.3598,
|
||||||
"y": 7.222577558743074,
|
"y": -1.1593,
|
||||||
"z": 0.24172188472363199
|
"z": 0.3363
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"office chair with wheels": {
|
"office chair with wheels": {
|
||||||
"highest_score": 0.316650390625,
|
"highest_score": 0.3037109375,
|
||||||
"last_detected": "2026-04-29T11:34:58.447898",
|
"last_detected": "2026-05-18T14:12:39.416197",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 3.982877977168086,
|
"x": -2.8461,
|
||||||
"y": 1.6895332207339573,
|
"y": 2.6732,
|
||||||
"z": 0.5514825779390112
|
"z": 0.4811
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
|
@ -1,24 +1,41 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
import time
|
||||||
import clip
|
import clip
|
||||||
import torch
|
import torch
|
||||||
|
from copy import deepcopy
|
||||||
import os
|
import os
|
||||||
from math import isfinite
|
|
||||||
import yaml
|
|
||||||
import cv2
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from geometry_msgs.msg import PoseStamped
|
from geometry_msgs.msg import PoseStamped
|
||||||
|
from tf2_ros import Buffer, TransformListener
|
||||||
|
from rclpy.duration import Duration
|
||||||
|
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy
|
||||||
|
from nav_msgs.msg import OccupancyGrid
|
||||||
from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult
|
from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult
|
||||||
from ament_index_python.packages import get_package_share_directory
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
|
||||||
class SemanticNavNode(Node):
|
class SemanticNavNode(Node):
|
||||||
def __init__(self, json_path: str):
|
def __init__(self, json_path: str):
|
||||||
super().__init__('semantic_nav_node')
|
super().__init__('semantic_nav_node')
|
||||||
|
self.declare_parameter('approach_offset_m', 0.7)
|
||||||
|
self.declare_parameter('approach_offset_refrigerator_m', 0.7)
|
||||||
|
self.declare_parameter('approach_offset_water_dispenser_m', 0.7)
|
||||||
|
self.declare_parameter('approach_offset_sofa_m', 0.5)
|
||||||
|
self.declare_parameter('approach_offset_white_toilet_m', 0.7)
|
||||||
|
self.declare_parameter('approach_offset_office_chair_with_wheels_m', 0.45)
|
||||||
|
self.declare_parameter('approach_candidate_count', 8)
|
||||||
|
self.declare_parameter('approach_clearance_m', 0.30)
|
||||||
|
self.declare_parameter('approach_clearance_office_chair_with_wheels_m', 0.18)
|
||||||
|
self.declare_parameter('approach_candidate_count_office_chair_with_wheels', 16)
|
||||||
|
self.declare_parameter('approach_offset_office_chair_with_wheels_search_radii', [0.45, 0.60, 0.75])
|
||||||
|
self.map_msg = None
|
||||||
|
|
||||||
# Load object library
|
# Load object library
|
||||||
with open(json_path) as f:
|
with open(json_path) as f:
|
||||||
self.object_lib = json.load(f)
|
self.object_lib = json.load(f)
|
||||||
self.map_bounds = self._load_map_bounds()
|
|
||||||
|
|
||||||
# Load CLIP
|
# Load CLIP
|
||||||
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
@ -34,73 +51,21 @@ class SemanticNavNode(Node):
|
||||||
# Initialize navigator
|
# Initialize navigator
|
||||||
self.navigator = BasicNavigator()
|
self.navigator = BasicNavigator()
|
||||||
self.navigator.waitUntilNav2Active()
|
self.navigator.waitUntilNav2Active()
|
||||||
self.get_logger().info("Navigator ready")
|
self.tf_buffer = Buffer(cache_time=Duration(seconds=10))
|
||||||
|
self.tf_listener = TransformListener(self.tf_buffer, self, spin_thread=True)
|
||||||
def _load_map_bounds(self):
|
map_qos = QoSProfile(
|
||||||
map_yaml = os.environ.get('NAV2_MAP_PATH', '').strip()
|
depth=1,
|
||||||
map_yaml = os.path.expanduser(map_yaml) if map_yaml else ''
|
reliability=ReliabilityPolicy.RELIABLE,
|
||||||
if not map_yaml or not os.path.isfile(map_yaml):
|
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||||
try:
|
history=HistoryPolicy.KEEP_LAST,
|
||||||
tb3_dir = get_package_share_directory('turtlebot3_gazebo')
|
|
||||||
map_yaml = os.path.join(tb3_dir, 'map', 'office_map.yaml')
|
|
||||||
except Exception:
|
|
||||||
map_yaml = ''
|
|
||||||
if not map_yaml or not os.path.isfile(map_yaml):
|
|
||||||
self.get_logger().warn(
|
|
||||||
"Map bounds unavailable in semantic_nav; "
|
|
||||||
"fallback selection will skip bounds checks."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(map_yaml, 'r') as f:
|
|
||||||
cfg = yaml.safe_load(f)
|
|
||||||
resolution = float(cfg['resolution'])
|
|
||||||
ox, oy = float(cfg['origin'][0]), float(cfg['origin'][1])
|
|
||||||
image_path = str(cfg['image'])
|
|
||||||
if not os.path.isabs(image_path):
|
|
||||||
image_path = os.path.join(os.path.dirname(map_yaml), image_path)
|
|
||||||
img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
|
||||||
if img is None:
|
|
||||||
raise RuntimeError(f"cannot open map image: {image_path}")
|
|
||||||
h, w = img.shape[:2]
|
|
||||||
bounds = {
|
|
||||||
"min_x": ox,
|
|
||||||
"max_x": ox + w * resolution,
|
|
||||||
"min_y": oy,
|
|
||||||
"max_y": oy + h * resolution,
|
|
||||||
}
|
|
||||||
self.get_logger().info(
|
|
||||||
"Semantic map bounds: "
|
|
||||||
f"x=[{bounds['min_x']:.3f}, {bounds['max_x']:.3f}], "
|
|
||||||
f"y=[{bounds['min_y']:.3f}, {bounds['max_y']:.3f}]"
|
|
||||||
)
|
|
||||||
return bounds
|
|
||||||
except Exception as e:
|
|
||||||
self.get_logger().warn(f"Failed to load map bounds: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _sanitize_position(self, pos):
|
|
||||||
if not isinstance(pos, dict):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
x = float(pos["x"])
|
|
||||||
y = float(pos["y"])
|
|
||||||
z = float(pos.get("z", 0.0))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if not (isfinite(x) and isfinite(y) and isfinite(z)):
|
|
||||||
return None
|
|
||||||
return {"x": x, "y": y, "z": z}
|
|
||||||
|
|
||||||
def _in_map_bounds(self, pos):
|
|
||||||
if pos is None:
|
|
||||||
return False
|
|
||||||
if self.map_bounds is None:
|
|
||||||
return True
|
|
||||||
return (
|
|
||||||
self.map_bounds["min_x"] <= pos["x"] <= self.map_bounds["max_x"]
|
|
||||||
and self.map_bounds["min_y"] <= pos["y"] <= self.map_bounds["max_y"]
|
|
||||||
)
|
)
|
||||||
|
self.map_sub = self.create_subscription(
|
||||||
|
OccupancyGrid,
|
||||||
|
"/map",
|
||||||
|
self.map_callback,
|
||||||
|
map_qos,
|
||||||
|
)
|
||||||
|
self.get_logger().info("Navigator ready")
|
||||||
|
|
||||||
def query_object(self, prompt: str):
|
def query_object(self, prompt: str):
|
||||||
tokens = clip.tokenize([prompt]).to(self.device)
|
tokens = clip.tokenize([prompt]).to(self.device)
|
||||||
|
|
@ -112,46 +77,18 @@ class SemanticNavNode(Node):
|
||||||
best_idx = sims.argmax().item()
|
best_idx = sims.argmax().item()
|
||||||
best_name = self.object_names[best_idx]
|
best_name = self.object_names[best_idx]
|
||||||
|
|
||||||
# Align with vlm_detection:
|
# Align with vlm_detection: position may be None if not yet detected
|
||||||
# prefer best_position/position, fallback to last_position when best is out-of-bounds.
|
|
||||||
obj_info = self.object_lib.get(best_name, {})
|
obj_info = self.object_lib.get(best_name, {})
|
||||||
best_pos_raw = obj_info.get("best_position")
|
pos = obj_info.get("position")
|
||||||
if best_pos_raw is None:
|
|
||||||
best_pos_raw = obj_info.get("position")
|
|
||||||
last_pos_raw = obj_info.get("last_position")
|
|
||||||
best_pos = self._sanitize_position(best_pos_raw)
|
|
||||||
last_pos = self._sanitize_position(last_pos_raw)
|
|
||||||
|
|
||||||
if best_pos is not None and self._in_map_bounds(best_pos):
|
if pos is None:
|
||||||
return best_name, best_pos
|
|
||||||
|
|
||||||
if best_pos is not None and not self._in_map_bounds(best_pos):
|
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"'{best_name}' best_position out of map bounds: {best_pos}"
|
f"'{best_name}' matched but has no position in JSON. "
|
||||||
)
|
|
||||||
if last_pos is not None and self._in_map_bounds(last_pos):
|
|
||||||
self.get_logger().warn(
|
|
||||||
f"Falling back to last_position for '{best_name}': {last_pos}"
|
|
||||||
)
|
|
||||||
return best_name, last_pos
|
|
||||||
|
|
||||||
if last_pos is not None and self._in_map_bounds(last_pos):
|
|
||||||
self.get_logger().warn(
|
|
||||||
f"Using last_position for '{best_name}': {last_pos}"
|
|
||||||
)
|
|
||||||
return best_name, last_pos
|
|
||||||
|
|
||||||
if best_pos is None and last_pos is None:
|
|
||||||
self.get_logger().warn(
|
|
||||||
f"'{best_name}' matched but has no usable position in JSON. "
|
|
||||||
f"Run vlm_detection in AMCL mode first!"
|
f"Run vlm_detection in AMCL mode first!"
|
||||||
)
|
)
|
||||||
return best_name, None
|
return best_name, None
|
||||||
|
|
||||||
self.get_logger().warn(
|
return best_name, pos
|
||||||
f"'{best_name}' has only out-of-bounds position(s), cannot navigate."
|
|
||||||
)
|
|
||||||
return best_name, None
|
|
||||||
|
|
||||||
def navigate_to_object(self, prompt: str):
|
def navigate_to_object(self, prompt: str):
|
||||||
name, pos = self.query_object(prompt)
|
name, pos = self.query_object(prompt)
|
||||||
|
|
@ -159,35 +96,299 @@ class SemanticNavNode(Node):
|
||||||
self.get_logger().warn(f"Cannot navigate: no valid position for '{name}'. Please run vlm_detection first!")
|
self.get_logger().warn(f"Cannot navigate: no valid position for '{name}'. Please run vlm_detection first!")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
robot_pose = self.get_robot_pose_in_map()
|
||||||
|
if robot_pose is None:
|
||||||
|
self.get_logger().warn("Cannot navigate: failed to get robot pose in map frame.")
|
||||||
|
return
|
||||||
|
|
||||||
|
offset_m = self.get_approach_offset(name)
|
||||||
|
goal_x, goal_y, yaw = self.compute_approach_goal(name, robot_pose, pos, offset_m)
|
||||||
|
robot_x, robot_y = robot_pose
|
||||||
|
distance_to_goal = math.hypot(goal_x - robot_x, goal_y - robot_y)
|
||||||
|
|
||||||
goal_pose = PoseStamped()
|
goal_pose = PoseStamped()
|
||||||
goal_pose.header.frame_id = "map"
|
goal_pose.header.frame_id = "map"
|
||||||
goal_pose.header.stamp = self.navigator.get_clock().now().to_msg()
|
goal_pose.header.stamp = self.navigator.get_clock().now().to_msg()
|
||||||
goal_pose.pose.position.x = pos["x"]
|
goal_pose.pose.position.x = goal_x
|
||||||
goal_pose.pose.position.y = pos["y"]
|
goal_pose.pose.position.y = goal_y
|
||||||
goal_pose.pose.position.z = pos.get("z", 0.0)
|
goal_pose.pose.position.z = 0.0
|
||||||
goal_pose.pose.orientation.z = 0.0
|
goal_pose.pose.orientation.z = math.sin(yaw / 2.0)
|
||||||
goal_pose.pose.orientation.w = 1.0
|
goal_pose.pose.orientation.w = math.cos(yaw / 2.0)
|
||||||
|
|
||||||
self.navigator.followWaypoints([goal_pose])
|
self.navigator.goToPose(deepcopy(goal_pose))
|
||||||
self.get_logger().info(f"Navigating to {name} at {pos}")
|
self.get_logger().info(
|
||||||
|
f"Navigating to {name}: object={pos}, goal={{'x': {goal_x:.4f}, 'y': {goal_y:.4f}}}, "
|
||||||
|
f"approach_offset={offset_m:.2f}m"
|
||||||
|
)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"Robot pose={{'x': {robot_x:.4f}, 'y': {robot_y:.4f}}}, "
|
||||||
|
f"distance_to_goal={distance_to_goal:.4f}m"
|
||||||
|
)
|
||||||
|
|
||||||
# Wait until navigation completes
|
# Wait until navigation completes
|
||||||
while not self.navigator.isTaskComplete():
|
while not self.navigator.isTaskComplete():
|
||||||
feedback = self.navigator.getFeedback()
|
feedback = self.navigator.getFeedback()
|
||||||
if feedback:
|
if feedback:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"Executing waypoint {feedback.current_waypoint + 1}/1"
|
"Navigation task in progress"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = self.navigator.getResult()
|
result = self.navigator.getResult()
|
||||||
|
final_robot_pose = self.get_robot_pose_in_map()
|
||||||
|
if final_robot_pose is not None:
|
||||||
|
final_x, final_y = final_robot_pose
|
||||||
|
final_distance_to_goal = math.hypot(goal_x - final_x, goal_y - final_y)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"Final robot map pose={{'x': {final_x:.4f}, 'y': {final_y:.4f}}}, "
|
||||||
|
f"distance_to_goal={final_distance_to_goal:.4f}m"
|
||||||
|
)
|
||||||
if result == TaskResult.SUCCEEDED:
|
if result == TaskResult.SUCCEEDED:
|
||||||
self.get_logger().info("Navigation succeeded")
|
self.get_logger().info("Navigation succeeded")
|
||||||
elif result == TaskResult.CANCELED:
|
elif result == TaskResult.CANCELED:
|
||||||
self.get_logger().info("Navigation canceled")
|
self.get_logger().info("Navigation canceled")
|
||||||
elif result == TaskResult.FAILED:
|
elif result == TaskResult.FAILED:
|
||||||
self.get_logger().info("Navigation failed")
|
self.get_logger().info("Navigation failed")
|
||||||
|
|
||||||
|
def map_callback(self, msg: OccupancyGrid):
|
||||||
|
self.map_msg = msg
|
||||||
|
|
||||||
|
def get_robot_pose_in_map(self):
|
||||||
|
target_frame = "map"
|
||||||
|
source_frame = "base_footprint"
|
||||||
|
last_error = None
|
||||||
|
for _ in range(15):
|
||||||
|
try:
|
||||||
|
if not self.tf_buffer.can_transform(
|
||||||
|
target_frame,
|
||||||
|
source_frame,
|
||||||
|
rclpy.time.Time(),
|
||||||
|
timeout=Duration(seconds=0.2)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
transform = self.tf_buffer.lookup_transform(
|
||||||
|
target_frame,
|
||||||
|
source_frame,
|
||||||
|
rclpy.time.Time(),
|
||||||
|
timeout=Duration(seconds=0.2)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
transform.transform.translation.x,
|
||||||
|
transform.transform.translation.y,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
|
||||||
|
if last_error is None:
|
||||||
|
self.get_logger().warn(
|
||||||
|
"Robot pose lookup failed: transform map -> base_footprint was not available within timeout."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.get_logger().warn(f"Robot pose lookup failed: {last_error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def compute_approach_goal(self, object_name, robot_pose, obj_pos, offset_m):
|
||||||
|
robot_x, robot_y = robot_pose
|
||||||
|
obj_x = obj_pos["x"]
|
||||||
|
obj_y = obj_pos["y"]
|
||||||
|
search_radii = self.get_approach_search_radii(object_name, offset_m)
|
||||||
|
candidate_count = self.get_approach_candidate_count(object_name)
|
||||||
|
approach_candidates = []
|
||||||
|
for radius in search_radii:
|
||||||
|
approach_candidates.extend(
|
||||||
|
self.generate_approach_candidates(obj_x, obj_y, radius, candidate_count)
|
||||||
|
)
|
||||||
|
feasible_candidates = [
|
||||||
|
candidate for candidate in approach_candidates
|
||||||
|
if self.is_candidate_feasible(object_name, candidate[0], candidate[1])
|
||||||
|
]
|
||||||
|
|
||||||
|
path_candidates = []
|
||||||
|
start_pose = self.build_pose_stamped(robot_x, robot_y, 0.0)
|
||||||
|
for candidate in feasible_candidates:
|
||||||
|
candidate_x, candidate_y, candidate_yaw = candidate
|
||||||
|
candidate_goal_pose = self.build_pose_stamped(candidate_x, candidate_y, candidate_yaw)
|
||||||
|
path = self.navigator.getPath(start_pose, candidate_goal_pose, use_start=True)
|
||||||
|
if path and len(path.poses) >= 2:
|
||||||
|
path_length = self.compute_path_length(path)
|
||||||
|
if path_length >= 0.2:
|
||||||
|
path_candidates.append((candidate_x, candidate_y, candidate_yaw, path_length))
|
||||||
|
|
||||||
|
if path_candidates:
|
||||||
|
goal_x, goal_y, yaw, path_length = min(
|
||||||
|
path_candidates,
|
||||||
|
key=lambda candidate: candidate[3]
|
||||||
|
)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"Approach candidates for {object_name}: total={len(approach_candidates)} "
|
||||||
|
f"feasible={len(feasible_candidates)} path_feasible={len(path_candidates)} "
|
||||||
|
f"selected=({goal_x:.4f}, {goal_y:.4f}) path_length={path_length:.4f}m"
|
||||||
|
)
|
||||||
|
return goal_x, goal_y, yaw
|
||||||
|
|
||||||
|
if feasible_candidates:
|
||||||
|
goal_x, goal_y, yaw = min(
|
||||||
|
feasible_candidates,
|
||||||
|
key=lambda candidate: math.hypot(candidate[0] - robot_x, candidate[1] - robot_y)
|
||||||
|
)
|
||||||
|
self.get_logger().warn(
|
||||||
|
f"Approach candidates for {object_name}: total={len(approach_candidates)} "
|
||||||
|
f"feasible={len(feasible_candidates)} path_feasible=0, "
|
||||||
|
f"falling back to nearest map-feasible candidate=({goal_x:.4f}, {goal_y:.4f})"
|
||||||
|
)
|
||||||
|
return goal_x, goal_y, yaw
|
||||||
|
|
||||||
|
dx = obj_x - robot_x
|
||||||
|
dy = obj_y - robot_y
|
||||||
|
distance = math.hypot(dx, dy)
|
||||||
|
if distance < 1e-6:
|
||||||
|
yaw = 0.0
|
||||||
|
return obj_x, obj_y, yaw
|
||||||
|
|
||||||
|
ux = dx / distance
|
||||||
|
uy = dy / distance
|
||||||
|
goal_x = obj_x - ux * min(offset_m, max(distance - 0.05, 0.0))
|
||||||
|
goal_y = obj_y - uy * min(offset_m, max(distance - 0.05, 0.0))
|
||||||
|
yaw = math.atan2(dy, dx)
|
||||||
|
self.get_logger().warn(
|
||||||
|
f"Approach candidates for {object_name}: no feasible candidate found, "
|
||||||
|
f"falling back to line-of-sight offset goal=({goal_x:.4f}, {goal_y:.4f})"
|
||||||
|
)
|
||||||
|
return goal_x, goal_y, yaw
|
||||||
|
|
||||||
|
def generate_approach_candidates(self, obj_x, obj_y, offset_m, candidate_count):
|
||||||
|
candidates = []
|
||||||
|
for i in range(candidate_count):
|
||||||
|
angle = (2.0 * math.pi * i) / candidate_count
|
||||||
|
goal_x = obj_x + offset_m * math.cos(angle)
|
||||||
|
goal_y = obj_y + offset_m * math.sin(angle)
|
||||||
|
yaw = math.atan2(obj_y - goal_y, obj_x - goal_x)
|
||||||
|
candidates.append((goal_x, goal_y, yaw))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def is_candidate_feasible(self, object_name: str, world_x, world_y):
|
||||||
|
map_msg = self.wait_for_map()
|
||||||
|
if map_msg is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
info = map_msg.info
|
||||||
|
resolution = info.resolution
|
||||||
|
origin_x = info.origin.position.x
|
||||||
|
origin_y = info.origin.position.y
|
||||||
|
width = info.width
|
||||||
|
height = info.height
|
||||||
|
grid_x = int((world_x - origin_x) / resolution)
|
||||||
|
grid_y = int((world_y - origin_y) / resolution)
|
||||||
|
|
||||||
|
if grid_x < 0 or grid_x >= width or grid_y < 0 or grid_y >= height:
|
||||||
|
return False
|
||||||
|
|
||||||
|
clearance_m = self.get_approach_clearance(object_name)
|
||||||
|
clearance_cells = max(0, int(math.ceil(clearance_m / resolution)))
|
||||||
|
|
||||||
|
for dy in range(-clearance_cells, clearance_cells + 1):
|
||||||
|
for dx in range(-clearance_cells, clearance_cells + 1):
|
||||||
|
if dx * dx + dy * dy > clearance_cells * clearance_cells:
|
||||||
|
continue
|
||||||
|
xx = grid_x + dx
|
||||||
|
yy = grid_y + dy
|
||||||
|
if xx < 0 or xx >= width or yy < 0 or yy >= height:
|
||||||
|
return False
|
||||||
|
cost = map_msg.data[yy * width + xx]
|
||||||
|
if cost < 0 or cost >= 50:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def build_pose_stamped(self, x, y, yaw):
|
||||||
|
pose = PoseStamped()
|
||||||
|
pose.header.frame_id = "map"
|
||||||
|
pose.header.stamp = self.navigator.get_clock().now().to_msg()
|
||||||
|
pose.pose.position.x = x
|
||||||
|
pose.pose.position.y = y
|
||||||
|
pose.pose.position.z = 0.0
|
||||||
|
pose.pose.orientation.z = math.sin(yaw / 2.0)
|
||||||
|
pose.pose.orientation.w = math.cos(yaw / 2.0)
|
||||||
|
return pose
|
||||||
|
|
||||||
|
def compute_path_length(self, path):
|
||||||
|
if not path.poses or len(path.poses) < 2:
|
||||||
|
return 0.0
|
||||||
|
length = 0.0
|
||||||
|
for prev_pose, next_pose in zip(path.poses[:-1], path.poses[1:]):
|
||||||
|
dx = next_pose.pose.position.x - prev_pose.pose.position.x
|
||||||
|
dy = next_pose.pose.position.y - prev_pose.pose.position.y
|
||||||
|
length += math.hypot(dx, dy)
|
||||||
|
return length
|
||||||
|
|
||||||
|
def wait_for_map(self, timeout_sec: float = 2.0):
|
||||||
|
if self.map_msg is not None:
|
||||||
|
return self.map_msg
|
||||||
|
end_time = time.time() + timeout_sec
|
||||||
|
while self.map_msg is None and time.time() < end_time:
|
||||||
|
time.sleep(0.05)
|
||||||
|
return self.map_msg
|
||||||
|
|
||||||
|
def get_approach_candidate_count(self, object_name: str) -> int:
|
||||||
|
default_count = int(
|
||||||
|
self.get_parameter('approach_candidate_count').get_parameter_value().integer_value
|
||||||
|
)
|
||||||
|
param_name = (
|
||||||
|
"approach_candidate_count_"
|
||||||
|
+ object_name.lower().replace(" ", "_")
|
||||||
|
)
|
||||||
|
if self.has_parameter(param_name):
|
||||||
|
default_count = int(self.get_parameter(param_name).get_parameter_value().integer_value)
|
||||||
|
return min(max(default_count, 4), 16)
|
||||||
|
|
||||||
|
def get_approach_search_radii(self, object_name: str, default_offset: float):
|
||||||
|
param_name = (
|
||||||
|
"approach_offset_"
|
||||||
|
+ object_name.lower().replace(" ", "_")
|
||||||
|
+ "_search_radii"
|
||||||
|
)
|
||||||
|
if self.has_parameter(param_name):
|
||||||
|
value = self.get_parameter(param_name).value
|
||||||
|
if value:
|
||||||
|
return [float(radius) for radius in value]
|
||||||
|
return [default_offset]
|
||||||
|
|
||||||
|
def get_approach_clearance(self, object_name: str) -> float:
|
||||||
|
default_clearance = self.get_parameter(
|
||||||
|
'approach_clearance_m'
|
||||||
|
).get_parameter_value().double_value
|
||||||
|
param_name = (
|
||||||
|
"approach_clearance_"
|
||||||
|
+ object_name.lower().replace(" ", "_")
|
||||||
|
+ "_m"
|
||||||
|
)
|
||||||
|
if self.has_parameter(param_name):
|
||||||
|
return self.get_parameter(param_name).get_parameter_value().double_value
|
||||||
|
return default_clearance
|
||||||
|
|
||||||
|
def get_approach_offset(self, object_name: str) -> float:
|
||||||
|
default_offset = self.get_parameter(
|
||||||
|
'approach_offset_m'
|
||||||
|
).get_parameter_value().double_value
|
||||||
|
param_name = (
|
||||||
|
"approach_offset_"
|
||||||
|
+ object_name.lower().replace(" ", "_")
|
||||||
|
+ "_m"
|
||||||
|
)
|
||||||
|
if self.has_parameter(param_name):
|
||||||
|
return self.get_parameter(param_name).get_parameter_value().double_value
|
||||||
|
return default_offset
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
if hasattr(self, 'tf_listener'):
|
||||||
|
try:
|
||||||
|
if hasattr(self.tf_listener, 'executor'):
|
||||||
|
self.tf_listener.executor.shutdown(timeout_sec=0.5)
|
||||||
|
if hasattr(self.tf_listener, 'dedicated_listener_thread'):
|
||||||
|
self.tf_listener.dedicated_listener_thread.join(timeout=0.5)
|
||||||
|
self.tf_listener.unregister()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -197,10 +398,14 @@ def main():
|
||||||
json_file_path = os.path.join(package_share_dir, 'config', 'example_object_detection_vlm.json')
|
json_file_path = os.path.join(package_share_dir, 'config', 'example_object_detection_vlm.json')
|
||||||
node = SemanticNavNode(json_file_path)
|
node = SemanticNavNode(json_file_path)
|
||||||
|
|
||||||
user_input = input("Where do you want to go: ")
|
try:
|
||||||
node.navigate_to_object(user_input)
|
user_input = input("Where do you want to go: ")
|
||||||
|
node.navigate_to_object(user_input)
|
||||||
rclpy.shutdown()
|
finally:
|
||||||
|
node.cleanup()
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,27 +1,27 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from sensor_msgs.msg import Image
|
from rclpy.executors import MultiThreadedExecutor
|
||||||
|
from rclpy.callback_groups import ReentrantCallbackGroup, MutuallyExclusiveCallbackGroup
|
||||||
from cv_bridge import CvBridge, CvBridgeError
|
from cv_bridge import CvBridge, CvBridgeError
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
import torchvision
|
import torchvision
|
||||||
from torchvision import transforms
|
from torchvision import transforms
|
||||||
from PIL import Image as PILImage
|
|
||||||
import clip
|
import clip
|
||||||
import cv2
|
import cv2
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from ament_index_python.packages import get_package_share_directory
|
from ament_index_python.packages import get_package_share_directory
|
||||||
from tf2_ros import Buffer, TransformListener
|
from tf2_ros import Buffer, TransformListener
|
||||||
import tf2_geometry_msgs
|
|
||||||
from tf2_geometry_msgs.tf2_geometry_msgs import do_transform_pose
|
|
||||||
from rclpy.duration import Duration
|
from rclpy.duration import Duration
|
||||||
from rclpy.time import Time
|
|
||||||
from geometry_msgs.msg import PoseStamped
|
from geometry_msgs.msg import PoseStamped
|
||||||
from sensor_msgs.msg import Image, CameraInfo
|
from sensor_msgs.msg import Image, CameraInfo
|
||||||
|
import tf2_geometry_msgs
|
||||||
|
from tf2_geometry_msgs.tf2_geometry_msgs import do_transform_pose
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,16 +31,24 @@ from sensor_msgs.msg import Image, CameraInfo
|
||||||
class ClipDetectionNode(Node):
|
class ClipDetectionNode(Node):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('clip_detector')
|
super().__init__('clip_detector')
|
||||||
|
self.declare_parameter('reset_records_on_startup', False)
|
||||||
|
self.declare_parameter('detection_interval_sec', 1.0)
|
||||||
|
self.declare_parameter('frcnn_min_score', 0.3)
|
||||||
|
self.declare_parameter('frcnn_top_k', 2)
|
||||||
self.bridge = CvBridge()
|
self.bridge = CvBridge()
|
||||||
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
#camera init
|
# Camera cache: subscriber callbacks only keep the latest data.
|
||||||
self.latest_depth = None
|
self.latest_depth = None
|
||||||
self.camera_info = None
|
|
||||||
self.latest_stamp = None
|
|
||||||
self.latest_depth_stamp = None
|
self.latest_depth_stamp = None
|
||||||
self.last_tf_warn_time = None
|
self.camera_info = None
|
||||||
|
self.latest_camera_info_stamp = None
|
||||||
|
self.latest_stamp = None
|
||||||
self.latest_rgb = None
|
self.latest_rgb = None
|
||||||
|
self.img_frame = "realsense_depth_frame"
|
||||||
|
self.processing_frame = False
|
||||||
|
self.sensor_callback_group = ReentrantCallbackGroup()
|
||||||
|
self.timer_callback_group = MutuallyExclusiveCallbackGroup()
|
||||||
self.tf_buffer = Buffer(cache_time=Duration(seconds=10))
|
self.tf_buffer = Buffer(cache_time=Duration(seconds=10))
|
||||||
self.tf_listener = TransformListener(self.tf_buffer, self)
|
self.tf_listener = TransformListener(self.tf_buffer, self)
|
||||||
|
|
||||||
|
|
@ -51,32 +59,31 @@ class ClipDetectionNode(Node):
|
||||||
|
|
||||||
# Labels and score tracking
|
# Labels and score tracking
|
||||||
self.labels = ["Refrigerator","water dispenser", "sofa", "white toilet", "office chair with wheels"]
|
self.labels = ["Refrigerator","water dispenser", "sofa", "white toilet", "office chair with wheels"]
|
||||||
|
|
||||||
# Save directly to vlm_nav_pkg config so semantic_nav reads the same file.
|
# Initialize score tracking - save directly to vlm_nav_pkg config
|
||||||
|
# so semantic_nav reads the same file without manual copy
|
||||||
try:
|
try:
|
||||||
nav_pkg_dir = get_package_share_directory('vlm_nav_pkg')
|
nav_pkg_dir = get_package_share_directory('vlm_nav_pkg')
|
||||||
default_records_file = os.path.join(
|
self.score_records_file = os.path.join(
|
||||||
nav_pkg_dir, 'config', 'example_object_detection_vlm.json'
|
nav_pkg_dir, 'config', 'example_object_detection_vlm.json'
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
"vlm_nav_pkg not found, saving JSON to current directory"
|
"vlm_nav_pkg not found, saving JSON to current directory"
|
||||||
)
|
)
|
||||||
default_records_file = "object_detection_vlm.json"
|
self.score_records_file = "object_detection_vlm.json"
|
||||||
|
reset_records = self.get_parameter(
|
||||||
self.declare_parameter("score_records_file", default_records_file)
|
'reset_records_on_startup'
|
||||||
self.declare_parameter("reset_records_on_startup", False)
|
).get_parameter_value().bool_value
|
||||||
configured_file = self.get_parameter("score_records_file").value
|
if reset_records:
|
||||||
self.score_records_file = configured_file if configured_file else default_records_file
|
self.score_records = {
|
||||||
self.reset_records_on_startup = self.get_parameter("reset_records_on_startup").value
|
label: {"highest_score": 0.0, "last_detected": None, "position": None}
|
||||||
|
for label in self.labels
|
||||||
if self.reset_records_on_startup:
|
}
|
||||||
self.score_records = self.get_empty_score_records()
|
|
||||||
self.save_score_records()
|
self.save_score_records()
|
||||||
self.get_logger().info(f"Score records reset: {self.score_records_file}")
|
self.get_logger().info(f"Score records reset: {self.score_records_file}")
|
||||||
else:
|
else:
|
||||||
self.score_records = self.load_score_records()
|
self.score_records = self.load_score_records()
|
||||||
self.save_score_records()
|
|
||||||
self.get_logger().info(f"Score records loaded: {self.score_records_file}")
|
self.get_logger().info(f"Score records loaded: {self.score_records_file}")
|
||||||
|
|
||||||
# Subscribers & Publishers
|
# Subscribers & Publishers
|
||||||
|
|
@ -86,84 +93,53 @@ class ClipDetectionNode(Node):
|
||||||
Image,
|
Image,
|
||||||
"/intel_realsense_r200_rgb/image_raw",
|
"/intel_realsense_r200_rgb/image_raw",
|
||||||
self.rgb_callback,
|
self.rgb_callback,
|
||||||
10
|
10,
|
||||||
|
callback_group=self.sensor_callback_group
|
||||||
)
|
)
|
||||||
self.depth_sub = self.create_subscription(
|
self.depth_sub = self.create_subscription(
|
||||||
Image,
|
Image,
|
||||||
"/intel_realsense_r200_depth/depth/image_raw",
|
"/intel_realsense_r200_depth/depth/image_raw",
|
||||||
self.depth_callback,
|
self.depth_callback,
|
||||||
10
|
10,
|
||||||
|
callback_group=self.sensor_callback_group
|
||||||
)
|
)
|
||||||
self.camera_info_sub = self.create_subscription(
|
self.camera_info_sub = self.create_subscription(
|
||||||
CameraInfo, "/intel_realsense_r200_depth/camera_info",
|
CameraInfo, "/intel_realsense_r200_depth/camera_info",
|
||||||
self.camera_info_callback, 10)
|
self.camera_info_callback, 10,
|
||||||
|
callback_group=self.sensor_callback_group)
|
||||||
self.annotated_pub = self.create_publisher(Image, "/percep/annotated_image", 10)
|
self.annotated_pub = self.create_publisher(Image, "/percep/annotated_image", 10)
|
||||||
|
interval_sec = self.get_parameter(
|
||||||
|
'detection_interval_sec'
|
||||||
|
).get_parameter_value().double_value
|
||||||
|
self.detection_timer = self.create_timer(
|
||||||
|
interval_sec,
|
||||||
|
self.process_frame,
|
||||||
|
callback_group=self.timer_callback_group,
|
||||||
|
)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"Detection timer enabled: processing latest frame every {interval_sec:.2f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def camera_info_callback(self, msg):
|
def camera_info_callback(self, msg):
|
||||||
self.camera_info = msg
|
self.camera_info = msg
|
||||||
self.img_frame = "realsense_depth_frame"
|
self.latest_camera_info_stamp = msg.header.stamp
|
||||||
self.get_logger().info(f"Camera info received, using frame: {self.img_frame}")
|
if msg.header.frame_id:
|
||||||
|
self.img_frame = msg.header.frame_id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_empty_score_records(self):
|
|
||||||
return {
|
|
||||||
label: {"highest_score": 0.0, "last_detected": None, "position": None}
|
|
||||||
for label in self.labels
|
|
||||||
}
|
|
||||||
|
|
||||||
def load_score_records(self):
|
def load_score_records(self):
|
||||||
"""Load existing score records and normalize missing labels/fields."""
|
"""Load existing score records or create new ones"""
|
||||||
empty_records = self.get_empty_score_records()
|
if os.path.exists(self.score_records_file):
|
||||||
if not os.path.exists(self.score_records_file):
|
with open(self.score_records_file, 'r') as f:
|
||||||
self.get_logger().warn(
|
try:
|
||||||
f"Score records file not found, creating new one: {self.score_records_file}"
|
return json.load(f)
|
||||||
)
|
except json.JSONDecodeError:
|
||||||
return empty_records
|
self.get_logger().warn("Score file corrupted, creating new one")
|
||||||
|
|
||||||
with open(self.score_records_file, 'r') as f:
|
# Initialize with empty records
|
||||||
try:
|
return {label: {"highest_score": 0.0, "last_detected": None, "position":None} for label in self.labels}
|
||||||
loaded = json.load(f)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.get_logger().warn("Score file corrupted, creating new one")
|
|
||||||
return empty_records
|
|
||||||
|
|
||||||
if not isinstance(loaded, dict):
|
|
||||||
self.get_logger().warn("Score file format is invalid, creating new one")
|
|
||||||
return empty_records
|
|
||||||
|
|
||||||
def normalize_record(record):
|
|
||||||
if not isinstance(record, dict):
|
|
||||||
record = {}
|
|
||||||
|
|
||||||
highest_score = record.get("highest_score", 0.0)
|
|
||||||
try:
|
|
||||||
highest_score = float(highest_score)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
highest_score = 0.0
|
|
||||||
highest_score = max(0.0, highest_score)
|
|
||||||
|
|
||||||
last_detected = record.get("last_detected")
|
|
||||||
position = record.get("position")
|
|
||||||
if position is not None and not isinstance(position, dict):
|
|
||||||
position = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"highest_score": highest_score,
|
|
||||||
"last_detected": last_detected,
|
|
||||||
"position": position
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized = {}
|
|
||||||
for label, record in loaded.items():
|
|
||||||
normalized[label] = normalize_record(record)
|
|
||||||
|
|
||||||
for label in self.labels:
|
|
||||||
if label not in normalized:
|
|
||||||
normalized[label] = empty_records[label]
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
def save_score_records(self):
|
def save_score_records(self):
|
||||||
"""Save current score records to file"""
|
"""Save current score records to file"""
|
||||||
|
|
@ -171,40 +147,40 @@ class ClipDetectionNode(Node):
|
||||||
json.dump(self.score_records, f, indent=4)
|
json.dump(self.score_records, f, indent=4)
|
||||||
|
|
||||||
def update_score_records(self, label, score, position=None):
|
def update_score_records(self, label, score, position=None):
|
||||||
"""Keep highest score per label; equal score is treated as an update."""
|
"""Update records when confidence matches or exceeds the best score."""
|
||||||
current_time = datetime.now().isoformat()
|
current_time = datetime.now().isoformat()
|
||||||
|
|
||||||
if label not in self.score_records:
|
if label not in self.score_records:
|
||||||
self.score_records[label] = {
|
self.score_records[label] = {
|
||||||
"highest_score": 0.0,
|
"highest_score": score,
|
||||||
"last_detected": None,
|
"last_detected": current_time,
|
||||||
"position": None
|
"position": position
|
||||||
}
|
}
|
||||||
|
|
||||||
record = self.score_records[label]
|
|
||||||
updated = False
|
|
||||||
if score >= record["highest_score"]:
|
|
||||||
# Equal score is also accepted so records can refresh over time.
|
|
||||||
record["highest_score"] = score
|
|
||||||
if position is not None:
|
|
||||||
record["position"] = position
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
record["last_detected"] = current_time
|
|
||||||
if updated:
|
|
||||||
self.save_score_records()
|
self.save_score_records()
|
||||||
|
return True
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
if score >= self.score_records[label]["highest_score"]:
|
||||||
|
# Refresh the best score and associated position on ties or improvements.
|
||||||
|
self.score_records[label]["highest_score"] = score
|
||||||
|
if position is not None:
|
||||||
|
self.score_records[label]["position"] = position
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Always refresh timestamp (even without position/score change)
|
||||||
|
self.score_records[label]["last_detected"] = current_time
|
||||||
|
self.save_score_records()
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def rgb_callback(self, msg):
|
def rgb_callback(self, msg):
|
||||||
try:
|
try:
|
||||||
self.latest_rgb = self.bridge.imgmsg_to_cv2(msg, "bgr8")
|
self.latest_rgb = self.bridge.imgmsg_to_cv2(msg, "bgr8")
|
||||||
self.latest_stamp = msg.header.stamp # <-- Correct assignment
|
self.latest_stamp = msg.header.stamp
|
||||||
except CvBridgeError as e:
|
except CvBridgeError as e:
|
||||||
self.get_logger().error(f"CvBridge error: {e}")
|
self.get_logger().error(f"CvBridge error: {e}")
|
||||||
return
|
return
|
||||||
self.process_frame()
|
|
||||||
|
|
||||||
|
|
||||||
def depth_callback(self, msg):
|
def depth_callback(self, msg):
|
||||||
|
|
@ -214,85 +190,87 @@ class ClipDetectionNode(Node):
|
||||||
except CvBridgeError as e:
|
except CvBridgeError as e:
|
||||||
self.get_logger().error(f"Depth CvBridge error: {e}")
|
self.get_logger().error(f"Depth CvBridge error: {e}")
|
||||||
return
|
return
|
||||||
self.process_frame()
|
|
||||||
|
|
||||||
def process_frame(self):
|
def process_frame(self):
|
||||||
if self.latest_rgb is None or self.latest_depth is None:
|
if self.processing_frame:
|
||||||
return
|
return
|
||||||
|
|
||||||
cv_img = self.latest_rgb.copy()
|
if self.latest_rgb is None or self.latest_depth is None or self.camera_info is None:
|
||||||
image_tensor = transforms.ToTensor()(cv_img)
|
return
|
||||||
|
|
||||||
with torch.no_grad():
|
if self.latest_stamp is None or self.latest_depth_stamp is None:
|
||||||
frcnn_output = self.frcnn_model([image_tensor])[0]
|
return
|
||||||
candidate_boxes = frcnn_output['boxes']
|
|
||||||
|
|
||||||
best_labels_per_box = {} # key: box index, value: (label, score)
|
self.processing_frame = True
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
cv_img = self.latest_rgb.copy()
|
||||||
|
depth_img = self.latest_depth.copy()
|
||||||
|
frame_stamp = self.latest_depth_stamp
|
||||||
|
camera_info = self.camera_info
|
||||||
|
image_tensor = transforms.ToTensor()(cv_img)
|
||||||
|
frcnn_min_score = self.get_parameter(
|
||||||
|
'frcnn_min_score'
|
||||||
|
).get_parameter_value().double_value
|
||||||
|
frcnn_top_k = self.get_parameter(
|
||||||
|
'frcnn_top_k'
|
||||||
|
).get_parameter_value().integer_value
|
||||||
|
|
||||||
for i, box in enumerate(candidate_boxes):
|
with torch.no_grad():
|
||||||
max_score = -1
|
frcnn_output = self.frcnn_model([image_tensor])[0]
|
||||||
best_label = None
|
candidate_boxes = frcnn_output['boxes']
|
||||||
for label in self.labels:
|
candidate_scores = frcnn_output['scores']
|
||||||
_, score = self.match_label_box(label, image_tensor, [box])
|
total_candidate_count = len(candidate_boxes)
|
||||||
if score > max_score:
|
|
||||||
max_score = score
|
|
||||||
best_label = label
|
|
||||||
|
|
||||||
if max_score >= 0.25 and best_label is not None:
|
filtered_indices = [
|
||||||
best_labels_per_box[i] = (best_label, max_score)
|
i for i, score in enumerate(candidate_scores)
|
||||||
# Initialize record_updated as False first
|
if float(score) >= frcnn_min_score
|
||||||
record_updated = False
|
]
|
||||||
position = None
|
filtered_indices.sort(key=lambda i: float(candidate_scores[i]), reverse=True)
|
||||||
|
selected_indices = filtered_indices[:frcnn_top_k]
|
||||||
|
candidate_boxes = [candidate_boxes[i] for i in selected_indices]
|
||||||
|
self.get_logger().info(
|
||||||
|
f"FRCNN candidates: total={total_candidate_count} "
|
||||||
|
f"filtered={len(filtered_indices)} top_k={frcnn_top_k} "
|
||||||
|
f"selected={len(candidate_boxes)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
best_labels_per_box = {} # key: box index, value: (label, score)
|
||||||
|
|
||||||
|
for i, box in enumerate(candidate_boxes):
|
||||||
|
max_score = -1
|
||||||
|
best_label = None
|
||||||
|
for label in self.labels:
|
||||||
|
_, score = self.match_label_box(label, image_tensor, [box])
|
||||||
|
if score > max_score:
|
||||||
|
max_score = score
|
||||||
|
best_label = label
|
||||||
|
|
||||||
|
if max_score >= 0.25 and best_label is not None:
|
||||||
|
best_labels_per_box[i] = (best_label, max_score)
|
||||||
|
is_new_record = False
|
||||||
|
position = None
|
||||||
|
|
||||||
if self.latest_depth is not None and self.camera_info is not None:
|
|
||||||
box_index = i
|
box_index = i
|
||||||
box = candidate_boxes[box_index]
|
box = candidate_boxes[box_index]
|
||||||
x_min, y_min, x_max, y_max = map(int, box)
|
x_min, y_min, x_max, y_max = map(int, box)
|
||||||
x_center = (x_min + x_max) // 2
|
x_center = (x_min + x_max) // 2
|
||||||
y_center = (y_min + y_max) // 2
|
y_center = (y_min + y_max) // 2
|
||||||
depth = float(self.latest_depth[y_center, x_center])
|
depth = float(depth_img[y_center, x_center])
|
||||||
if np.isnan(depth) or depth <= 0.0:
|
if np.isnan(depth) or depth <= 0.0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
fx = self.camera_info.k[0]
|
fx = camera_info.k[0]
|
||||||
fy = self.camera_info.k[4]
|
fy = camera_info.k[4]
|
||||||
cx = self.camera_info.k[2]
|
cx = camera_info.k[2]
|
||||||
cy = self.camera_info.k[5]
|
cy = camera_info.k[5]
|
||||||
|
|
||||||
X = (x_center - cx) * depth / fx
|
X = (x_center - cx) * depth / fx
|
||||||
Y = (y_center - cy) * depth / fy
|
Y = (y_center - cy) * depth / fy
|
||||||
Z = depth
|
Z = depth
|
||||||
|
|
||||||
stamp_msg = self.latest_depth_stamp if self.latest_depth_stamp is not None else self.latest_stamp
|
|
||||||
if stamp_msg is None:
|
|
||||||
continue
|
|
||||||
query_time = Time.from_msg(
|
|
||||||
stamp_msg, clock_type=self.get_clock().clock_type
|
|
||||||
)
|
|
||||||
tf_stamp_msg = stamp_msg
|
|
||||||
has_tf_at_stamp = self.tf_buffer.can_transform(
|
|
||||||
"map", self.img_frame, query_time, timeout=Duration(seconds=0.05)
|
|
||||||
)
|
|
||||||
if not has_tf_at_stamp:
|
|
||||||
latest_query_time = Time(clock_type=self.get_clock().clock_type)
|
|
||||||
has_tf_latest = self.tf_buffer.can_transform(
|
|
||||||
"map", self.img_frame, latest_query_time, timeout=Duration(seconds=0.05)
|
|
||||||
)
|
|
||||||
if not has_tf_latest:
|
|
||||||
now_time = self.get_clock().now()
|
|
||||||
if (
|
|
||||||
self.last_tf_warn_time is None
|
|
||||||
or (now_time - self.last_tf_warn_time).nanoseconds > 2_000_000_000
|
|
||||||
):
|
|
||||||
self.get_logger().warn(
|
|
||||||
"TF unavailable at sensor stamp and latest time; skip this detection frame."
|
|
||||||
)
|
|
||||||
self.last_tf_warn_time = now_time
|
|
||||||
continue
|
|
||||||
tf_stamp_msg = latest_query_time.to_msg()
|
|
||||||
|
|
||||||
pose = PoseStamped()
|
pose = PoseStamped()
|
||||||
pose.header.stamp = tf_stamp_msg
|
pose.header.stamp = frame_stamp
|
||||||
pose.header.frame_id = self.img_frame
|
pose.header.frame_id = self.img_frame
|
||||||
pose.pose.position.x = X
|
pose.pose.position.x = X
|
||||||
pose.pose.position.y = Y
|
pose.pose.position.y = Y
|
||||||
|
|
@ -302,7 +280,7 @@ class ClipDetectionNode(Node):
|
||||||
# TF transform: camera frame -> map frame (semantic_nav 需要 map 坐标)
|
# TF transform: camera frame -> map frame (semantic_nav 需要 map 坐标)
|
||||||
try:
|
try:
|
||||||
ob2map = self.tf_buffer.transform(
|
ob2map = self.tf_buffer.transform(
|
||||||
pose, "map", timeout=Duration(seconds=0.05)
|
pose, "map", timeout=Duration(seconds=1.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.pose_pub.publish(ob2map)
|
self.pose_pub.publish(ob2map)
|
||||||
|
|
@ -316,33 +294,28 @@ class ClipDetectionNode(Node):
|
||||||
"y": round(ob2map.pose.position.y, 4),
|
"y": round(ob2map.pose.position.y, 4),
|
||||||
"z": round(ob2map.pose.position.z, 4)
|
"z": round(ob2map.pose.position.z, 4)
|
||||||
}
|
}
|
||||||
record_updated = self.update_score_records(best_label, max_score, position=position)
|
is_new_record = self.update_score_records(best_label, max_score, position=position)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.get_logger().error(f"TF transform to map failed: {e}")
|
self.get_logger().error(f"TF transform to map failed: {e}")
|
||||||
record_updated = False
|
is_new_record = False
|
||||||
|
|
||||||
|
log_msg = f"{best_label} detected with confidence score {max_score:.2f}"
|
||||||
|
if is_new_record:
|
||||||
|
log_msg += " (NEW RECORD!)"
|
||||||
|
self.get_logger().info(log_msg)
|
||||||
|
|
||||||
# Logging
|
matched_boxes = [candidate_boxes[i] for i in best_labels_per_box.keys()]
|
||||||
log_msg = f"{best_label} detected with confidence score {max_score:.2f}"
|
matched_labels = [f"{lbl} ({score:.2f})" for lbl, score in best_labels_per_box.values()]
|
||||||
if record_updated:
|
|
||||||
log_msg += " (BEST SCORE RECORD UPDATED)"
|
|
||||||
self.get_logger().info(log_msg)
|
|
||||||
|
|
||||||
|
annotated_img = self.draw_boxes(cv_img, matched_boxes, matched_labels)
|
||||||
###############################################
|
try:
|
||||||
|
self.annotated_pub.publish(self.bridge.cv2_to_imgmsg(annotated_img, encoding="bgr8"))
|
||||||
matched_boxes = [candidate_boxes[i] for i in best_labels_per_box.keys()]
|
except CvBridgeError as e:
|
||||||
matched_labels = [f"{lbl} ({score:.2f})" for lbl, score in best_labels_per_box.values()]
|
self.get_logger().error(f"Publish error: {e}")
|
||||||
|
finally:
|
||||||
annotated_img = self.draw_boxes(cv_img, matched_boxes, matched_labels)
|
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
||||||
try:
|
self.get_logger().info(f"Detection cycle took {elapsed_ms:.1f} ms")
|
||||||
self.annotated_pub.publish(self.bridge.cv2_to_imgmsg(annotated_img, encoding="bgr8"))
|
self.processing_frame = False
|
||||||
except CvBridgeError as e:
|
|
||||||
self.get_logger().error(f"Publish error: {e}")
|
|
||||||
|
|
||||||
self.latest_rgb, self.latest_depth = None, None
|
|
||||||
self.latest_stamp = None # 同步清空时间戳,避免TF查询残留旧stamp
|
|
||||||
self.latest_depth_stamp = None
|
|
||||||
|
|
||||||
def match_label_box(self, label, image_tensor, boxes):
|
def match_label_box(self, label, image_tensor, boxes):
|
||||||
max_sim, best_box = -1, None
|
max_sim, best_box = -1, None
|
||||||
|
|
@ -369,12 +342,15 @@ class ClipDetectionNode(Node):
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=args)
|
||||||
node = ClipDetectionNode()
|
node = ClipDetectionNode()
|
||||||
|
executor = MultiThreadedExecutor(num_threads=2)
|
||||||
|
executor.add_node(node)
|
||||||
try:
|
try:
|
||||||
rclpy.spin(node)
|
executor.spin()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
node.get_logger().info("KeyboardInterrupt received, shutting down...")
|
node.get_logger().info("KeyboardInterrupt received, shutting down...")
|
||||||
finally:
|
finally:
|
||||||
node.save_score_records()
|
node.save_score_records()
|
||||||
|
executor.shutdown()
|
||||||
node.destroy_node()
|
node.destroy_node()
|
||||||
if rclpy.ok():
|
if rclpy.ok():
|
||||||
rclpy.shutdown()
|
rclpy.shutdown()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue