This commit is contained in:
Marcin M
2026-06-17 23:30:52 +02:00
parent f407eae6bc
commit 202249a9e4
8 changed files with 219 additions and 40 deletions
+1
View File
@@ -8,6 +8,7 @@ install(PROGRAMS
scripts/move_to_point.py scripts/move_to_point.py
scripts/arm_point_controller.py scripts/arm_point_controller.py
scripts/mission.py scripts/mission.py
scripts/detection.py
DESTINATION lib/${PROJECT_NAME} DESTINATION lib/${PROJECT_NAME}
) )
@@ -7,8 +7,9 @@ import time
# ROS 2 Message, Action, and Service Imports # ROS 2 Message, Action, and Service Imports
from moveit_msgs.action import MoveGroup, ExecuteTrajectory from moveit_msgs.action import MoveGroup, ExecuteTrajectory
from moveit_msgs.msg import Constraints, RobotState from moveit_msgs.msg import Constraints, RobotState, PositionConstraint, OrientationConstraint, BoundingVolume
from moveit_msgs.srv import GetCartesianPath, GetPositionFK from moveit_msgs.srv import GetCartesianPath, GetPositionFK
from shape_msgs.msg import SolidPrimitive
from geometry_msgs.msg import PoseStamped, Pose from geometry_msgs.msg import PoseStamped, Pose
from sensor_msgs.msg import JointState from sensor_msgs.msg import JointState
@@ -36,7 +37,7 @@ class CameraArmController(Node):
self.get_logger().info("Connecting to Forward Kinematics service...") self.get_logger().info("Connecting to Forward Kinematics service...")
self._fk_client.wait_for_service() self._fk_client.wait_for_service()
# 5. Subscriber to listen to current joint states (Required to compute current position) # 5. Subscriber to listen to current joint states
self.current_joint_state = None self.current_joint_state = None
self._joint_sub = self.create_subscription( self._joint_sub = self.create_subscription(
JointState, JointState,
@@ -52,13 +53,24 @@ class CameraArmController(Node):
self.current_joint_state = msg self.current_joint_state = msg
def get_horizontal_camera_quaternion(self): def get_horizontal_camera_quaternion(self):
"""Calculates a 90-degree pitch offset to keep the camera level with the horizon""" """Keeps camera level with horizon but yaws it 90 degrees sideways"""
pitch_angle = math.radians(-90.0) pitch = math.radians(-90.0) # Keep horizon level
yaw = math.radians(90.0) # Rotate 90 degrees sideways (use -90.0 for the opposite side)
roll = 0.0
# Standard Euler to Quaternion conversion equations
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
q = Pose().orientation q = Pose().orientation
q.x = 0.0 q.w = cr * cp * cy + sr * sp * sy
q.y = math.sin(pitch_angle / 2.0) q.x = sr * cp * cy - cr * sp * sy
q.z = 0.0 q.y = cr * sp * cy + sr * cp * sy
q.w = math.cos(pitch_angle / 2.0) q.z = cr * cp * sy - sr * sp * cy
return q return q
def get_current_end_effector_pose(self): def get_current_end_effector_pose(self):
@@ -101,31 +113,21 @@ class CameraArmController(Node):
target_pose.pose.position.x = float(x) target_pose.pose.position.x = float(x)
target_pose.pose.position.y = float(y) target_pose.pose.position.y = float(y)
target_pose.pose.position.z = float(z) target_pose.pose.position.z = float(z)
# Enforces the horizontal alignment strictly at the final destination pose
target_pose.pose.orientation = self.get_horizontal_camera_quaternion() target_pose.pose.orientation = self.get_horizontal_camera_quaternion()
# Add target pose to the path request constraints # Add target pose to the path request constraints
goal_constraints = Constraints() goal_constraints = Constraints()
goal_msg.request.goal_constraints.append(goal_constraints) goal_msg.request.goal_constraints.append(goal_constraints)
# MoveIt handles position and end-point orientation without path constraints
# By avoiding constraints during movement, the planner should avoid RRTConnect failures.
goal_msg.request.workspace_parameters.header.frame_id = "base_link" goal_msg.request.workspace_parameters.header.frame_id = "base_link"
# Pack the target pose directly into the goal message requests
from moveit_msgs.msg import PositionConstraint, OrientationConstraint
# Create a simple end-state constraint matching our target pose
# Position Constraint # Position Constraint
pos_con = PositionConstraint() pos_con = PositionConstraint()
pos_con.header.frame_id = "base_link" pos_con.header.frame_id = "base_link"
pos_con.link_name = "tool_link" pos_con.link_name = "tool_link"
from moveit_msgs.msg import BoundingVolume
from shape_msgs.msg import SolidPrimitive
box = SolidPrimitive() box = SolidPrimitive()
box.type = SolidPrimitive.BOX box.type = SolidPrimitive.BOX
box.dimensions = [0.01, 0.01, 0.01] # Tight convergence tolerance box.dimensions = [0.01, 0.01, 0.01]
volume = BoundingVolume() volume = BoundingVolume()
volume.primitives.append(box) volume.primitives.append(box)
@@ -141,25 +143,40 @@ class CameraArmController(Node):
ori_con.header.frame_id = "base_link" ori_con.header.frame_id = "base_link"
ori_con.link_name = "tool_link" ori_con.link_name = "tool_link"
ori_con.orientation = target_pose.pose.orientation ori_con.orientation = target_pose.pose.orientation
ori_con.absolute_x_axis_tolerance = 0.05 ori_con.absolute_x_axis_tolerance = 0.005
ori_con.absolute_y_axis_tolerance = 0.05 ori_con.absolute_y_axis_tolerance = 0.005
ori_con.absolute_z_axis_tolerance = 0.05 ori_con.absolute_z_axis_tolerance = 0.005
ori_con.weight = 1.0 ori_con.weight = 1.0
goal_constraints.position_constraints.append(pos_con) goal_constraints.position_constraints.append(pos_con)
goal_constraints.orientation_constraints.append(ori_con) goal_constraints.orientation_constraints.append(ori_con)
send_goal_future = self._action_client.send_goal_async(goal_msg) send_goal_future = self._action_client.send_goal_async(goal_msg)
rclpy.spin_until_future_complete(self, send_goal_future)
# 2. CREATE A TEMPORARY EXECUTOR FOR THIS BLOCKED MOVE
from rclpy.executors import SingleThreadedExecutor
temp_executor = SingleThreadedExecutor()
temp_executor.add_node(self) # Temporarily borrow this node context
# 3. Spin our isolated executor until MoveIt decides to accept or reject the goal
temp_executor.spin_until_future_complete(send_goal_future)
goal_handle = send_goal_future.result() goal_handle = send_goal_future.result()
if not goal_handle.accepted: if not goal_handle.accepted:
self.get_logger().error("Point-to-Point goal rejected by MoveIt!") self.get_logger().error("Point-to-Point goal rejected by MoveIt!")
temp_executor.remove_node(self) # Clean up before returning
return False return False
self.get_logger().info("Goal accepted! Executing path...") self.get_logger().info("Goal accepted! Executing path...")
# 4. Request the execution outcome and spin our local executor again until the arm physically lands
get_result_future = goal_handle.get_result_async() get_result_future = goal_handle.get_result_async()
rclpy.spin_until_future_complete(self, get_result_future) temp_executor.spin_until_future_complete(get_result_future)
# 5. MANDATORY CLEANUP: Release the node back to your main script's executor loop
temp_executor.remove_node(self)
self.get_logger().info("Ruch ramienia zakończony sukcesem!")
return True return True
def execute_straight_camera_sweep(self, distance=0.2): def execute_straight_camera_sweep(self, distance=0.2):
@@ -216,19 +233,52 @@ class CameraArmController(Node):
self.get_logger().error("Trajectory execution rejected.") self.get_logger().error("Trajectory execution rejected.")
return False return False
def home_arm(self):
self.send_pt_to_pt_goal(0.35, 0.1, 1.35)
def harvest(self):
inspection_x = 0.35 # 45cm forward past the shoulder joint
inspection_y = -0.4 # Centered
inspection_z = 1.35
at_target = self.send_pt_to_pt_goal(inspection_x, inspection_y, inspection_z)
def main(args=None): def main(args=None):
rclpy.init(args=args) rclpy.init(args=args)
node = CameraArmController() node = CameraArmController()
# Coordinates are now evaluated relative to the arm's base link. # --- DEFINE ROUTINE CONFIGURATIONS ---
# Reaches 40cm forward, 30cm down toward crops. It will rotate freely during # Targets are relative to the arm's base link origin (inverted setup)
# transit and level out perfectly flat once it locks into the target destination. inspection_x = 0.35 # 45cm forward past the shoulder joint
success = node.send_pt_to_pt_goal(.2, 0.3, 0.3) inspection_y = -0.4 # Centered
inspection_z = 1.35 # 35cm down toward the field plants
if success: home_x = 0.35 # Tucked back close to the vehicle frame
time.sleep(2.0) home_y = 0.10
# Execute the forward crawl sweep home_z = 1.35 # Retracted up high out of the crop height line
node.execute_straight_camera_sweep(distance=0.2)
# 1. Move to Predefined Inspection Target Point
node.get_logger().info("Starting inspection routine. Advancing to inspection coordinate...")
at_target = node.send_pt_to_pt_goal(inspection_x, inspection_y, inspection_z)
if at_target:
# 2. Arrived at target: Wait for sensors to settle/take pictures
wait_seconds = 3.0
node.get_logger().info(f"Arrived at inspection point. Holding position for {wait_seconds} seconds for camera sweep...")
time.sleep(wait_seconds)
# 3. Return to base point (Safe Home Configuration)
node.get_logger().info("Inspection complete. Returning to safe home position...")
returned_home = node.send_pt_to_pt_goal(home_x, home_y, home_z)
if returned_home:
node.get_logger().info("Arm securely home. Routine complete!")
else:
node.get_logger().error("Failed to return to home base point!")
else:
node.get_logger().error("Failed to reach initial inspection target!")
node.destroy_node() node.destroy_node()
rclpy.shutdown() rclpy.shutdown()
Binary file not shown.
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
# IMPORTUJEMY PROFIL QoS DLA SENSORÓW
from rclpy.qos import qos_profile_sensor_data
import numpy as np
import cv2
from ultralytics import YOLO
class YoloGazeboDetector(Node):
def __init__(self):
super().__init__('yolo_gazebo_detector')
self.get_logger().info("Ładowanie modelu YOLO...")
self.model = YOLO('best.pt')
self.model.to('cpu')
self.window_name = "YOLOv8 - Gazebo Camera"
cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
cv2.resizeWindow(self.window_name, 640, 480)
self.frame_count = 0
self.camera_topic = '/arm_camera/image_raw'
# ZMIANA: Zastępujemy liczbę queue_size gotowym profilem qos_profile_sensor_data
self.subscription = self.create_subscription(
Image,
self.camera_topic,
self.camera_callback,
qos_profile_sensor_data
)
self.window_timer = self.create_timer(0.03, self.refresh_blank_window)
self.latest_annotated_frame = None
self.get_logger().info(f"Node i Okno GUI gotowe (Zaktualizowano QoS). Słucham na: {self.camera_topic}")
def refresh_blank_window(self):
if self.latest_annotated_frame is None:
loading_frame = np.zeros((480, 640, 3), dtype=np.uint8)
cv2.putText(loading_frame, "Oczekiwanie na obrazy z Gazebo...", (80, 240),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
cv2.imshow(self.window_name, loading_frame)
else:
cv2.imshow(self.window_name, self.latest_annotated_frame)
cv2.waitKey(1)
def camera_callback(self, msg):
self.frame_count += 1
# Wymuszony log dla każdej klatki, żeby potwierdzić wejście do funkcji
self.get_logger().info(f"--- ODEBRANO WIADOMOŚĆ (Klatka {self.frame_count}) ---")
self.get_logger().info(f"Wymiary z nagłówka: Width={msg.width}, Height={msg.height}, Encoding={msg.encoding}")
self.get_logger().info(f"Rozmiar surowej tablicy danych (msg.data): {len(msg.data)} bajtów")
if len(msg.data) == 0:
self.get_logger().error("⚠️ Krytyczny błąd: Tablica msg.data jest całkowicie pusta!")
return
try:
# Konwersja do formatu numpy
flat_img_array = np.frombuffer(msg.data, dtype=np.uint8)
# Dynamiczne obliczanie oczekiwanego rozmiaru na podstawie kanałów
channels = 3
if 'rgba' in msg.encoding or 'bgra' in msg.encoding:
channels = 4
elif 'mono' in msg.encoding:
channels = 1
expected_elements = msg.height * msg.width * channels
if flat_img_array.size != expected_elements:
self.get_logger().error(f"⚠️ Niezgodność rozmiarów! Otrzymano {flat_img_array.size} elementów, a oczekiwano {expected_elements}")
# Próba awaryjnego dopasowania
cv_image = flat_img_array.reshape((msg.height, msg.step // channels, channels))[:, :msg.width, :]
else:
cv_image = flat_img_array.reshape((msg.height, msg.width, channels))
# Obsługa konwersji przestrzeni barw
if 'rgb' in msg.encoding.lower():
cv_image = cv2.cvtColor(cv_image, cv2.COLOR_RGB2BGR)
elif channels == 4:
cv_image = cv2.cvtColor(cv_image, cv2.COLOR_BGRA2BGR)
except Exception as e:
self.get_logger().error(f"💥 Błąd krytyczny podczas przetwarzania macierzy: {e}")
return
# Jeśli doszliśmy tutaj, obraz jest poprawny. Przekazujemy do YOLO
try:
results = self.model(cv_image, verbose=False, imgsz=320)
annotated_frame = cv_image.copy()
for r in results:
annotated_frame = r.plot()
# Sukces! Podmieniamy klatkę dla okna wyświetlacza
self.latest_annotated_frame = annotated_frame
except Exception as e:
self.get_logger().error(f"Błąd podczas wnioskowania YOLO: {e}")
def main(args=None):
rclpy.init(args=args)
node = YoloGazeboDetector()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.get_logger().info("Zamykanie node'a...")
cv2.destroyAllWindows()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
+11 -1
View File
@@ -9,6 +9,7 @@ import math
import time import time
import sys import sys
import numpy as np import numpy as np
from arm_point_controller import CameraArmController
try: try:
import pygame import pygame
@@ -34,6 +35,9 @@ class HarvesterStateMachine(Node):
self.lidar_left_sub = self.create_subscription(LaserScan, '/lidar_left', self.lidar_left_callback, qos_profile) self.lidar_left_sub = self.create_subscription(LaserScan, '/lidar_left', self.lidar_left_callback, qos_profile)
self.lidar_right_sub = self.create_subscription(LaserScan, '/lidar_right', self.lidar_right_callback, qos_profile) self.lidar_right_sub = self.create_subscription(LaserScan, '/lidar_right', self.lidar_right_callback, qos_profile)
self.arm = CameraArmController()
self.arm.home_arm()
self.STATE_INITIALIZING = "INITIALIZING" self.STATE_INITIALIZING = "INITIALIZING"
self.STATE_GPS_DRIVE = "GPS_DRIVE" self.STATE_GPS_DRIVE = "GPS_DRIVE"
self.STATE_LIDAR_DRIVE = "LIDAR_DRIVE" self.STATE_LIDAR_DRIVE = "LIDAR_DRIVE"
@@ -261,7 +265,13 @@ class HarvesterStateMachine(Node):
def execute_harvesting(self): def execute_harvesting(self):
self.stop_robot() self.stop_robot()
time.sleep(2.0)
time.sleep(0.5)
self.arm.harvest()
time.sleep(0.5)
self.arm.home_arm()
time.sleep(0.5)
self.harvesting_done_in_this_row = True self.harvesting_done_in_this_row = True
self.current_state = self.STATE_LIDAR_DRIVE self.current_state = self.STATE_LIDAR_DRIVE
View File
+3 -3
View File
@@ -66,7 +66,7 @@
<child link="link_2"/> <child link="link_2"/>
<origin rpy="0 0 0" xyz="0 0 0.48"/> <origin rpy="0 0 0" xyz="0 0 0.48"/>
<axis xyz="0 1 0"/> <axis xyz="0 1 0"/>
<limit effort="300.0" lower="-2.0" upper="2.0" velocity="2.0"/> <limit effort="300.0" lower="-3.141592653589793" upper="3.141592653589793" velocity="2.0"/>
<dynamics damping="1.0" friction="1.0"/> <dynamics damping="1.0" friction="1.0"/>
</joint> </joint>
<link name="link_2"> <link name="link_2">
@@ -94,7 +94,7 @@
<child link="link_3"/> <child link="link_3"/>
<origin rpy="0 0 0" xyz="0 0 0.6400000000000001"/> <origin rpy="0 0 0" xyz="0 0 0.6400000000000001"/>
<axis xyz="0 1 0"/> <axis xyz="0 1 0"/>
<limit effort="200.0" lower="-2.5" upper="2.5" velocity="2.5"/> <limit effort="200.0" lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5"/>
<dynamics damping="1.0" friction="1.0"/> <dynamics damping="1.0" friction="1.0"/>
</joint> </joint>
<link name="link_3"> <link name="link_3">
@@ -150,7 +150,7 @@
<child link="link_5"/> <child link="link_5"/>
<origin rpy="0 0 0" xyz="0 0 0.32000000000000006"/> <origin rpy="0 0 0" xyz="0 0 0.32000000000000006"/>
<axis xyz="0 1 0"/> <axis xyz="0 1 0"/>
<limit effort="100.0" lower="-2.0" upper="2.0" velocity="3.0"/> <limit effort="100.0" lower="-3.141592653589793" upper="3.141592653589793" velocity="3.0"/>
<dynamics damping="0.5" friction="0.5"/> <dynamics damping="0.5" friction="0.5"/>
</joint> </joint>
<link name="link_5"> <link name="link_5">
@@ -64,7 +64,7 @@
<child link="link_2"/> <child link="link_2"/>
<origin xyz="0 0 ${0.3 * LENGTH_SCALE}" rpy="0 0 0"/> <origin xyz="0 0 ${0.3 * LENGTH_SCALE}" rpy="0 0 0"/>
<axis xyz="0 1 0"/> <axis xyz="0 1 0"/>
<limit lower="-2.0" upper="2.0" effort="300.0" velocity="2.0"/> <limit lower="-${PI}" upper="${PI}" effort="300.0" velocity="2.0"/>
<dynamics damping="1.0" friction="1.0"/> <dynamics damping="1.0" friction="1.0"/>
</joint> </joint>
@@ -88,7 +88,7 @@
<child link="link_3"/> <child link="link_3"/>
<origin xyz="0 0 ${0.4 * LENGTH_SCALE}" rpy="0 0 0"/> <origin xyz="0 0 ${0.4 * LENGTH_SCALE}" rpy="0 0 0"/>
<axis xyz="0 1 0"/> <axis xyz="0 1 0"/>
<limit lower="-2.5" upper="2.5" effort="200.0" velocity="2.5"/> <limit lower="-${PI}" upper="${PI}" effort="200.0" velocity="2.5"/>
<dynamics damping="1.0" friction="1.0"/> <dynamics damping="1.0" friction="1.0"/>
</joint> </joint>
@@ -136,7 +136,7 @@
<child link="link_5"/> <child link="link_5"/>
<origin xyz="0 0 ${0.2 * LENGTH_SCALE}" rpy="0 0 0"/> <origin xyz="0 0 ${0.2 * LENGTH_SCALE}" rpy="0 0 0"/>
<axis xyz="0 1 0"/> <axis xyz="0 1 0"/>
<limit lower="-2.0" upper="2.0" effort="100.0" velocity="3.0"/> <limit lower="-${PI}" upper="${PI}" effort="100.0" velocity="3.0"/>
<dynamics damping="0.5" friction="0.5"/> <dynamics damping="0.5" friction="0.5"/>
</joint> </joint>