Полная система для сбора клубники должна объединять сразу несколько достаточно разных задач:

навигация    ↓
поиск рабочей зоны    ↓
техническое зрение    ↓
поиск спелой ягоды    ↓
манипулятор    ↓
захват

Разрабатывать всё одновременно неудобно, поэтому проект был разбит на отдельные модули.

В этой статье займёмся мобильной частью.

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

Для экспериментов используются ROS 2, Python и Webots.

Упрощаем теплицу

На первом этапе реальную теплицу заменим сеткой 5×5:

мобильная платформа        +
навигация        +
лидар        +
техническое зрение        +
поиск спелой клубники        +
манипулятор        +
захват

Всего получается 25 возможных положений робота.

Каждой позиции соответствует ArUco-маркер. Например, робот может физически находиться над меткой 7, а рабочая зона располагается около клетки 22, тогда необходимо построить маршрут от клетки 7 до клетки 22.

Определяем положение по ArUco

Снизу мобильной платформы расположена камера.

Отдельный ArUco-детектор обрабатывает её изображение и публикует обнаруженную метку в ROS2. Навигационной программе поэтому вообще не нужно заниматься обработкой изображения. Она получает уже готовый ID. Но принимать первое же распознавание тоже рискованно.

Например, камера может получить:

aruco_17
aruco_17
aruco_8
aruco_17
aruco_17

Единичная ошибка не должна заставлять робота считать, что он внезапно оказался на клетке 8. Поэтому используется простое подтверждение. Нам нужны три одинаковых последовательных определения, теперь можно переходить к построению маршрута.

Представляем поле как граф

За карту отвечает класс Graph.

В нашем случае:

class Graph:    SIZE = 5    MARKER_COUNT = 25

Номер клетки можно довольно просто преобразовать в координаты, где x = 17 % 5, y = 17 // 5, то есть x = 2, y = 3. Операция % возвращает остаток от деления, а // выполняет целочисленное деление. Благодаря этому не требуется вручную хранить таблицу всех 25 координат. Для каждой клетки также определяются соседи. Именно из таких связей постепенно формируется граф, по которому робот будет искать путь.

Ищем маршрут с помощью BFS

Допустим:

START  = 7
TARGET = 22

Теперь нужно найти кратчайший путь.

Для этого используется BFS — Breadth-First Search, или поиск в ширину. Его удобно представить как волну. Если стартовать с клетки 0, сначала проверяются все клетки, находящиеся на расстоянии одного перехода, затем двух, трех и так далее.

Используется очередь, в начале в неё помещается стартовый маршрут:

from collections import deque
queue.append([start])
new_route = route + [next_marker]

Затем берём маршрут и добавляем к нему соседнюю клетку:

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

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

Превращаем маршрут в движение

За движение отвечает отдельный Motion, он получает данные одометрии и отправляет команды скорости через стандартное ROS2 сообщение Twist.

Схема получается такой:

маршрут   ↓
следующая клетка   ↓
координаты X/Y   ↓
где робот сейчас?   ↓
куда нужно повернуться?   ↓
Twist   ↓
робот

Управляющий цикл выполняется примерно 20 раз в секунду.

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

dx = target_x - self.x
dy = target_y - self.y
distance = math.hypot(dx, dy)
target_yaw = math.atan2(dy, dx)
angle_error = target_yaw - self.yaw

После выравнивания начинаем движение вперёд. Во время движения остаётся небольшая коррекция курса:

correction = 1.2 * angle_error

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

Но что произойдёт, если дорога перекрыта?

Теперь добавим вторую часть эксперимента. Пусть робот успешно приехал к рабочей позиции.

Сохраняем показания 2D-лидара, после этого в симуляторе ставим препятствие и получаем новый LaserScan. Сравниваем два измерения, получаем разницу, которая означает, что перед лучом появился новый объект. Каждый луч лидара содержит расстояние и угол, можно перевести их в локальные декартовы координаты так:

local_x = distance * math.cos(angle)
local_y = distance * math.sin(angle)

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

Перестраиваем маршрут

После обнаружения препятствия снова запускается тот же BFS. Но теперь граф выглядит немного иначе. Например, клетка 17 оказалась занята. При запросе соседей Graph просто исключает заблокированную клетку, поэтому планировщик вынужден найти другой путь.

В этом и состоит основной эксперимент.

Зачем здесь одновременно ArUco, одометрия и лидар

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

ArUco отвечает на вопрос: На какой логической позиции робот начинает работу?

Одометрия: Где робот находится во время движения?

Лидар: Что находится вокруг робота?

А планировщик работает уже с результатами этих измерений. Получается уже довольно характерная для робототехники архитектура: восприятие окружающей среды отделено от планирования и непосредственного управления приводами.

Как это связано с клубникой

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

В дальнейшем общий цикл должен выглядеть примерно так:

НАВИГАЦИЯ    ↓
робот приехал к растениям    ↓
ТЕХНИЧЕСКОЕ ЗРЕНИЕ    ↓
найдена спелая клубника    ↓
получено положение ягоды    ↓
МАНИПУЛЯТОР    ↓
подвод захвата    ↓
сбор    ↓
укладка ягоды    ↓
следующая рабочая зона

Таким образом, навигация и манипулятор являются двумя разными уровнями одной системы.

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

Результат

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

КАМЕРА   ↓
ArUco   ↓
локализация   ↓
Graph   ↓
BFS   ↓
маршрут   ↓
Odometry   ↓
управление движением   ↓
Lidar   ↓
обнаружение препятствия   ↓
изменение карты   ↓
повторное планирование

Пока всё это работает на маленькой сетке и в симуляции. Но задача этого этапа и не заключалась в создании готового сельскохозяйственного робота.

А дальше этот навигационный модуль уже можно объединять с манипулятором и системой технического зрения — и постепенно переходить от движения по условной теплице непосредственно к автоматизированному сбору клубники.

Комментарии (5)


  1. redfox0
    15.09.2026 04:37

    [ЗДЕСЬ ССЫЛКА НА РЕПОЗИТОРИЙ]

    [ДАННЫЕ УДАЛЕНЫ]


  1. Laaladar
    15.09.2026 04:37

    Разрешите подушнить: выложить код с комментариями - это все-таки не статья.


  1. Sencis
    15.09.2026 04:37

    И симулятору не плохо-бы добить визуализатор а то как-то:

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

    Плохо осоциируется с картой 5х5. Но за проделанную работу и написанный с 0ля код + ставлю. Надеюсь это не сплошной вайб кодинг и вы понимаете как это работает.


  1. zum
    15.09.2026 04:37

    Это не статья, а вывод логов из чата с LLM. Как это прошло модерацию — загадка.


  1. wowa144 Автор
    15.09.2026 04:37

    Можно также рассмотреть простую реализацию перемещения по времени:

    import time
    import rclpy
    
    from rclpy.node import Node
    from geometry_msgs.msg import Twist
    
    
    rclpy.init()
    
    robot = Node("robot_test")
    go = robot.create_publisher(Twist, "/RMC2/cmd_vel", 10)
    
    poehali = Twist()
    poehali.linear.x = 0.2
    
    t = time.time()
    
    while time.time() - t < 2:
        go.publish(poehali)
        time.sleep(0.05)
    
    stop = Twist()
    go.publish(stop)
    
    print("вроде приехал")
    
    robot.destroy_node()
    rclpy.shutdown()