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

На мобильной платформе на данный момент установлен 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, чтобы постепенно перейти от такой условной сетки к полноценной навигации.
Комментарии (4)

Sencis
17.09.2026 07:32нужно решить гораздо более фундаментальную задачу — научить мобильную платформу нормально перемещаться между заданными точками.
Я давно решаю эту фундаментальную задачу, и, честно говоря, конца и края ей не видно. Если вкратце, то карты в ROS (в частности, costmap) оставляют желать лучшего. Карта представляет собой огромный массив данных, который хорошо параллелится, но в ROS он обрабатывается на CPU. Из-за этого карта обновляется медленно и на небольшом участке.
При этом на GPU можно легко в реальном времени (например, на частоте 60 Гц) обновлять кусок карты 800х800х100 с разрешением 10 см. Это примерно 6 400 кв. м., то есть 40 метров впереди робота. Для дешевого 3д лидара этого вполне достаточно, так как он видит не далее чем 40 метров тёмные предметы такие как дорога. В ROS же скользящее окно составляет всего 5х5 (25 кв. м) или 10х10 (100 кв. м.) метров, чего для улицы явно мало, ведь машины и пешеходы преодолевают это расстояние слишком быстро. В общем, построение карты нужно переносить на GPU, так как использовать для этого CPU — архаизм.Сам алгоритм построения карты в ROS тоже примитивный, или 2д срез или проекцию карты сверху вниз строит в costmap в которой роботу доступны далеко не все зоны где он может проехать.
Существующие планировщики (SmacPlanner Hybrid A*, State Lattice) работают медленно, поскольку тоже задействуют CPU. Пути они ищут плохо, особенно для ходовой Аккермана: робот порой заезжает в тупик, а выехать обратно задом по тому же пути уже не может. Примитивы приходится закладывать большие, с запасом, чтобы контроллеры могли по ним нормально идти. Однако тот же State Lattice не позволяет бесконечно увеличивать их размер — слишком большие примитивы не сходятся в сетке. Приходится балансировать на грани, ведь чем больше примитив, тем плавнее маршрут и тем легче контроллеру вести платформу.
Что касается контроллеров, то радует появление более современного решения с поддержкой CUDA mppi т.к. контроллеры прогностические с моделью едят много ресурсов и особенно MPPI. Но без обработки карты на CUDA его смысл частично теряется, контроллер работает быстро и прогнозирует далеко а карта маленькая, не раскрывает его потенциал. Тем не менее разработчикам всё равно спасибо. Правда, модель оставили примитивную — кинематическую. Хотя в MPPI Generic есть примеры моделей, поддерживающих динамику, моделирование сил инерции, трения и заноса. Это необходимо для того, чтобы базовая кинематика работала точнее, а MPPI понимал, что колёса при повороте плугуют и мог разворачиваться на месте с учётом ограничений ходовой, и парковаться правильно.
Одометрия во всех стандартных пакетах идет без детектирования планарных сцен, так что этот узел снова придётся собирать самостоятельно и настраивать переключение на резервный источник. Динамические препятствия тоже нужно детектировать своими силами. Всё начинается с поиска движущихся точек: большинство систем одометрии внутри себя их рассчитывают и фильтруют, но наружу не выдают. Они отдают очищенные облака, а сами подвижные точки — нет. А значит, опять придётся изобретать велосипед.
Примерно так выглядит RoadMap для решения задачи «научить мобильную платформу нормально перемещаться между заданными точками». И это уровень еще даже не Яндекс ровера (там нейросети распознают прохожих, тротуары, преграды на пути не геометрические, дорожные знаки, разметку и т.д. ), а просто базовой езды по координатам на улице.

wowa144 Автор
17.09.2026 07:32Если вы хотите использовать код из предыдущей статьи для управления манипулятором, то потребуется ряд правок в текущем коде.
Да. Тут не надо переделывать архитектуру. Самый простой вариант — добавить управление манипулятором прямо в
RMC2Controller, а потом вызвать его в нужном местеmain().Но у тебя есть один важный момент: первый код управляет манипулятором
RMC1, а второй — мобильной платформойRMC2. Ниже я оставляю топики манипулятора именно/RMC1/..., как в твоём рабочем примере. Если манипулятор физически установлен на РМК-2, потом просто заменишьRMC1наRMC2.1. Добавление импортов
К существующим импортам добавить еще 3, отвечающие за траектории, action и захват:
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint from control_msgs.action import GripperCommand from rclpy.action import ActionClient2. В
initдобавить манипулятор и захватВ конец
initклассаRMC2Controller:self.arm = self.create_publisher( JointTrajectory, "/RMC1/arm95/arm_joint_trajectory_controller/joint_trajectory", 10 ) self.gripper = ActionClient( self, GripperCommand, "/RMC1/arm95/gripper_controller/gripper_cmd" )3. Добавить две функции в
RMC2ControllerНапример, сразу после
lift_down():def move_arm(self): msg = JointTrajectory() msg.joint_names = [ "joint1", "joint2", "joint3", "joint4", "joint5", "joint6" ] point = JointTrajectoryPoint() point.positions = [ 0.1, 0.3, -0.0, 0.0, 0.0, 0.0 ] point.time_from_start.sec = 3 msg.points = [point] self.arm.publish(msg) time.sleep(3) def close_gripper(self): self.gripper.wait_for_server() goal = GripperCommand.Goal() goal.command.position = 0.0 #0.04 для открытого self.gripper.send_goal_async(goal) rclpy.spin_once( self, timeout_sec=1 )На этом основная часть правок завершена, отдельный код манипулятора теперь превратился фактически в две команды:
controller.move_arm() controller.close_gripper()4. Теперь вставляем их в сценарий
Допустим, логика должна быть такой:
СТАРТ ↓ едем к хранению ↓ поднимаем лифт ↓ едем к комплектации ↓ двигаем манипулятор ↓ закрываем захват ↓ опускаем лифт ↓ возвращаемся домойТогда в
main()меняется только этот кусок:try: controller.follow_route(route_storage) if not controller.lift_up(): return controller.follow_route(route_picking) controller.move_arm() controller.close_gripper() if not controller.lift_down(): return controller.follow_route(route_home)То есть в коде фактически нужно сделать три изменения: добавить импорты, добавить publisher/action client и добавить две функции.
Есть только один нюанс в
move_arm(): установленоtime.sleep(3), чтобы следующий этап не начался раньше, чем закончится заданное трёхсекундное движение манипулятора. Это самый простой вариант для твоего текущего кода. Позже лучше заменить его на нормальное ожидание результата контроллера.
Также стоит напомнить, что возможен запуск методом ros2 run имя пакета имя ноды. Можно также запустить стандартным методом: python3 имя_файла.py из той директории, в которой лежит файл.
Poison48
Всегда думал, что в промышленности клубнику выращиваю на подвесных грядках.
wowa144 Автор
Не обязательно так, в нашей ферме клубника находится на стеллажах, а ягоды свешиваются на длинных стеблях вниз.
В качестве иллюстрации: