В прошлых статьях я тезисно рассказывал о своих роботах для сбора клубники. Теперь хотелось бы показать их подробнее и постепенно разобрать программную часть проекта — от относительно простых вещей до технического зрения, работы манипулятора и непосредственно поиска и сбора ягоды.

В этой статье начну с мобильной платформы и довольно простой задачи: заставим робота самостоятельно пройти заданный маршрут.

Немного о самих роботах

Основная идея проекта - мобильный робот, который может перемещаться вдоль посадок клубники, находить ягоды и собирать их с помощью установленного на платформе манипулятора.

Конструкция сейчас выглядит так:

Прототип платформы
Прототип платформы

На мобильной платформе на данный момент установлен 3-осевой манипулятор с плоским захватом. Планируется установка полноразмерного 6-осевого манипулятора, но платформа не подходит для такого. В конструкции используются магнитные концевики и энкодеры. Подробнее механику манипулятора, кинематику и нижний уровень управления я хочу разобрать отдельно - здесь они нам пока практически не понадобятся.

В контексте сбора клубники манипулятор решает локальную задачу. Когда мобильная платформа подъехала к нужному участку растений, уже манипулятор должен дотянуться до обнаруженной ягоды, правильно расположить захват и выполнить операцию сбора.

При этом делать манипулятор с огромной рабочей зоной нет особого смысла. Гораздо удобнее разделить движение системы на два уровня.

Финальный манипулятор
Финальный манипулятор

Мобильная платформа выполняет грубое позиционирование всего робота относительно грядки, а манипулятор — точное позиционирование относительно конкретной ягоды.

Снова платформа
Снова платформа

Кроме манипулятора, на платформе установлены датчики, необходимые для навигации и дальнейшей работы системы. Но прежде чем переходить к техническому зрению и автоматическому сбору клубники, нужно решить гораздо более фундаментальную задачу — научить мобильную платформу нормально перемещаться между заданными точками.

Именно этим и займёмся дальше.

Что будем делать

В данном случае мы начнем рассматривать комплексную навигацию от простого к сложному. Для эксперимента я максимально упростил пространство, в котором работает робот.

Представим его как поле 5×5:

24  19  14   9   4
23  18  13   8   3
22  17  12   7   2
21  16  11   6   1
20  15  10   5   0

Каждая точка соответствует определённому положению мобильной платформы. Расстояние между соседними точками в текущем варианте принимается равным одному метру. Именно такая нумерация заложена в программе.

У робота будет три основных положения: Стартовая позиция, место сбора и место складирования. Кроме этого, некоторые клетки можно объявить запрещёнными, чтобы робот их избегал. Это служит эмуляцией ситуаций, когда часть пространства перекрыта. Программа должна сама найти маршрут в обход запрещённых клеток, физически провести по нему робота, выполнить операцию с лифтом и затем вернуть платформу обратно. Ее вполне можно разбить на несколько блоков.

Часть 1. Поле и поиск маршрута

Начнем вообще без ROS2. Сначала нам нужно описать наше поле и научиться находить путь между двумя клетками. Для этого был использован обычный BFS - поиск в ширину.

import math
import time
from collections import deque

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from std_msgs.msg import String, Float64

GRID_SIZE = 5
CELL_STEP = 1.0

LINEAR_SPEED = 0.15
ANGULAR_SPEED = 0.35

TARGET_DISTANCE = 0.10
ANGLE_TOLERANCE = math.radians(5)

LIFT_UP_VALUE = 1.0
LIFT_DOWN_VALUE = 0.0
LIFT_TIMEOUT = 15.0

robot = {
    "x": 0.0,
    "y": 0.0,
    "yaw": 0.0,
    "odom_received": False
}

start_cell = 0

start_odom_x = 0.0
start_odom_y = 0.0
start_odom_yaw = 0.0

def cell_to_grid(cell):
    grid_y = cell % GRID_SIZE
    grid_x = GRID_SIZE - 1 - cell // GRID_SIZE
    return grid_x, grid_y

def get_neighbors(cell):
    neighbors = []

    row = cell % GRID_SIZE
    column = cell // GRID_SIZE

    if row < GRID_SIZE - 1:
        neighbors.append(cell + 1)

    if row > 0:
        neighbors.append(cell - 1)

    if column < GRID_SIZE - 1:
        neighbors.append(cell + GRID_SIZE)

    if column > 0:
        neighbors.append(cell - GRID_SIZE)

    return neighbors

def bfs(start, target, forbidden):
    if start == target:
        return [start]

    queue = deque([[start]])
    visited = {start}

    while queue:
        route = queue.popleft()
        current = route[-1]

        for neighbor in get_neighbors(current):
            if neighbor in forbidden or neighbor in visited:
                continue

            new_route = route + [neighbor]

            if neighbor == target:
                return new_route

            visited.add(neighbor)
            queue.append(new_route)

    return []

Не буду заострять внимание на работе BFS, скажу лишь, что суть алгоритма в том, что поиск начинает распространяться по карте как волна. Если какая-то клетка находится в forbidden, алгоритм просто не рассматривает её. В результате мы получаем не координаты движения, а пока только последовательность номеров. Теперь понятно куда ехать, но пока не ясно как именно.

Часть 2. Переходим от клеток к реальным координатам

Следующая проблема - BFS работает с номерами клеток, а мобильный робот ничего о клетках не знает. От ROS2 мы получаем обычную одометрию, содержащую x, y, yaw. В связи с этим появляется необходимость перевода маршрута из клеток в реальные координаты.

Для этого используются три функции:

def quaternion_to_yaw(q):
    sin_yaw = 2.0 * (q.w * q.z + q.x * q.y)
    cos_yaw = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
    return math.atan2(sin_yaw, cos_yaw)

def normalize_angle(angle):
    while angle > math.pi:
        angle -= 2.0 * math.pi

    while angle < -math.pi:
        angle += 2.0 * math.pi

    return angle

def cell_to_world(cell):
    target_grid_x, target_grid_y = cell_to_grid(cell)
    start_grid_x, start_grid_y = cell_to_grid(start_cell)

    dx_cells = target_grid_x - start_grid_x
    dy_cells = target_grid_y - start_grid_y

    forward = dy_cells * CELL_STEP
    left = -dx_cells * CELL_STEP

    forward_x = forward * math.cos(start_odom_yaw)
    forward_y = forward * math.sin(start_odom_yaw)

    left_x = -left * math.sin(start_odom_yaw)
    left_y = left * math.cos(start_odom_yaw)

    world_x = start_odom_x + forward_x + left_x
    world_y = start_odom_y + forward_y + left_y

    return world_x, world_y

Здесь есть важный момент. Нет привязки сетки к какой-то заранее известной глобальной системе координат. В момент запуска запоминаются реальные показания одометрии и уже относительно этой позиции строится наше условное поле, то есть стартовая ориентация робота принимается за направление «вверх» по сетке. Именно такую схему использует исходный алгоритм преобразования клетки в odom.

Часть 3. ROS2 и физическое движение робота

Теперь появляется непосредственно мобильная платформа. Стоит сразу оговориться, что действие будет происходить в симуляторе, на реальном роботе покажу тесты немного позднее. Для управления создадим ноды и подключимся к двум основным топикам: cmd_vel и odometry. Первый используется для управления скоростью, второй - для получения текущего положения.

class RMC2Controller(Node):

    def __init__(self):
        super().__init__("rmc2_task")

        self.cmd_pub = self.create_publisher(
            Twist,
            "/RMC2/cmd_vel",
            10
        )

        self.create_subscription(
            Odometry,
            "/RMC2/odometry",
            self.odom_callback,
            10
        )

        self.lift_pub = self.create_publisher(
            Float64,
            "/RMC2/lift",
            10
        )

        self.lift_status = None

        self.create_subscription(
            String,
            "/RMC2/lift_status",
            self.lift_status_callback,
            10
        )

    def odom_callback(self, msg):
        robot["x"] = msg.pose.pose.position.x
        robot["y"] = msg.pose.pose.position.y
        robot["yaw"] = quaternion_to_yaw(
            msg.pose.pose.orientation
        )
        robot["odom_received"] = True

    def lift_status_callback(self, msg):
        self.lift_status = msg.data.strip().lower()

    def send_speed(self, linear, angular):
        msg = Twist()
        msg.linear.x = linear
        msg.angular.z = angular
        self.cmd_pub.publish(msg)

    def stop(self):
        self.send_speed(0.0, 0.0)

    def move_to_cell(self, cell):
        target_x, target_y = cell_to_world(cell)

        while rclpy.ok():
            rclpy.spin_once(self, timeout_sec=0.02)

            x = robot["x"]
            y = robot["y"]
            yaw = robot["yaw"]

            dx = target_x - x
            dy = target_y - y

            distance = math.hypot(dx, dy)

            if distance < TARGET_DISTANCE:
                self.stop()
                return

            target_angle = math.atan2(dy, dx)
            angle_error = normalize_angle(target_angle - yaw)

            if abs(angle_error) > ANGLE_TOLERANCE:
                angular = (
                    ANGULAR_SPEED
                    if angle_error > 0
                    else -ANGULAR_SPEED
                )

                self.send_speed(0.0, angular)

            else:
                self.send_speed(LINEAR_SPEED, 0.0)

    def follow_route(self, route):
        for cell in route[1:]:
            self.move_to_cell(cell)

        self.stop()

Для очередной точки вычисляется вектор: текущее положение - цель, по нему получаем расстояние до цели и необходимый угол.

Дальше используется очень простой алгоритм управления:

угол неправильный?        ↓ да
поворачиваемся        ↓ нет
едем прямо        ↓
проверяем расстояние        ↓
точка достигнута

Если ошибка направления больше пяти градусов, платформа сначала поворачивается. Когда направление становится приемлемым, робот начинает движение вперёд. Точка считается достигнутой, когда до неё остаётся менее 10 см. Это далеко не самый совершенный регулятор движения. Но для первой версии системы его преимущество как раз в простоте: легко понять, что происходит с роботом и на каком этапе появляется ошибка.

Часть 4. Работа с лифтом

На мобильной платформе есть ещё один исполнительный механизм - лифт. Конечно, на фото лифта не видно. На тестовой платформе лифт эмулируется движением манипулятора по одной оси. Для него используются специально созданные топики, описывающие статус и команды лифта.

    def move_lift(self, value):
        self.stop()

        self.lift_status = None

        msg = Float64()
        msg.data = float(value)
        self.lift_pub.publish(msg)

        moving_seen = False
        start_time = time.monotonic()

        while rclpy.ok():
            rclpy.spin_once(self, timeout_sec=0.1)

            status = self.lift_status

            if status == "moving":
                moving_seen = True

            elif moving_seen and status in ("raised", "lowered"):
                return True

            if time.monotonic() - start_time > LIFT_TIMEOUT:
                return False

    def lift_up(self):
        return self.move_lift(LIFT_UP_VALUE)

    def lift_down(self):
        return self.move_lift(LIFT_DOWN_VALUE)

Здесь я специально не продолжаю движение сразу после отправки команды. Сначала платформа останавливается, затем отправляется команда лифту, и программа ждёт подтверждения его движения. Дополнительно используется таймаут. Если за 15 секунд механизм не подтвердил завершение операции, выполнение не должно бесконечно зависнуть в ожидании.

Часть 5. Собираем всё в одно задание

Осталось соединить поиск пути, одометрию, движение и лифт.

Для статьи я убрал большую часть консольного интерфейса исходной программы и оставил саму последовательность работы.

def main():
    global start_cell
    global start_odom_x
    global start_odom_y
    global start_odom_yaw

    start_cell = int(input("Начальная точка: "))
    storage_cell = int(input("Точка хранения: "))
    picking_cell = int(input("Точка комплектации: "))

    text = input("Запретные точки через пробел: ")
    forbidden = set(map(int, text.split())) if text.strip() else set()

    route_storage = bfs(
        start_cell,
        storage_cell,
        forbidden
    )

    route_picking = bfs(
        storage_cell,
        picking_cell,
        forbidden
    )

    route_home = bfs(
        picking_cell,
        start_cell,
        forbidden
    )

    if not route_storage or not route_picking or not route_home:
        print("Маршрут не найден")
        return

    rclpy.init()
    controller = RMC2Controller()

    while rclpy.ok() and not robot["odom_received"]:
        rclpy.spin_once(controller, timeout_sec=0.1)

    start_odom_x = robot["x"]
    start_odom_y = robot["y"]
    start_odom_yaw = robot["yaw"]

    try:
        controller.follow_route(route_storage)

        if not controller.lift_up():
            return

        controller.follow_route(route_picking)

        if not controller.lift_down():
            return

        controller.follow_route(route_home)

    finally:
        controller.stop()
        controller.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

Теперь уже хорошо видна логика задания целиком. Сначала программа получает три точки и список препятствий. BFS независимо строит три маршрута: старт - сбор, сбор - складирование, складирование - старт После подключения к ROS2 программа ждёт первую одометрию и запоминает реальное начальное положение платформы. А затем выполняется само задание - сбор и складирование уже подготовленных ягод.

Что получилось

Несмотря на довольно большой исходный код, сама архитектура первой версии получается простой: робот через BFS строит план, едет по контрольным точка, избегая запретные, а также управляет лифтом.

И это пока намеренно простой вариант. Здесь ещё нет полноценной локализации по карте, сложного планировщика траекторий и других вещей, которые обычно ожидаешь увидеть в автономном мобильном роботе.

Следующий шаг уже интереснее: роботу недостаточно знать, что его стартовая точка — условный 0. Он должен сам определить, где находится, а затем уже построить маршрут. И здесь в систему можно добавить лидар и SLAM, чтобы постепенно перейти от такой условной сетки к полноценной навигации.