וואלהאח של מבוקש ניסה לחטוף ללוחם את נשקו - הכוחות ירו בו למוותThe Jerusalem PostUS officials prepare for chaos amid concerns Trump will try use federal power to swing midtermsCNN TürkGalatasaray, Barcelona maçı öncesi stadyum zeminini yeniliyorESPNZverev claims the US Open title amid a chaotic tournament -- and seasonPunchTrump declines appeal to pardon mother accused of killing three childrenInquirerWATCH: Sara Duterte impeachment trial | Sept. 15, 2026Bollywood HungamaSooraj Pancholi DENIES link to Disha Salian death case after CBI FIR names him alongside Aaditya Thackeray, Rhea Chakraborty: “Very unfair and extremely upsetting”Inquirer EntertainmentHundred Islands Film Fest draws top original films in Pangasinan한겨레초소형군집위성 등 15기 탑재 완료…누리호 다음달 우주로Egypt IndependentYemen’s Gate of Tears: How another critical waterway became Iran’s wild cardSözcü40 kişinin yaşadığı köyde bedava ev dağıtılıyorUOLLivro resgata o romance avassalador de Marcel Duchamp e Maria Martins
The Daily Newsstand · Free, Always
Tuesday, September 15, 2026

Навигация мобильного робота для сбора клубники

Translate

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

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

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

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

Поэтому разработку я решил разбить на отдельные модули.

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

Для разработки используются ROS 2, Python и Webots.

Постановка задачи

На первом этапе движение робота по теплице сильно упрощено.

Рабочая область представлена сеткой 5×5:

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

Каждый номер соответствует возможной позиции мобильной платформы.

Такое представление пока не является полноценной картой теплицы. Оно нужно для отработки основных алгоритмов:

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

Например:

START  = 0
TARGET = 22

Робот должен построить маршрут от 0 до 22, выполнить его и остановиться в целевой позиции.

После этого в симуляции появляется препятствие.

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

Робот получает данные лидара, определяет занятую область и при возвращении уже не должен использовать её.

В результате эксперимент выглядит следующим образом:

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

Что реализовано

Программа разделена на четыре основных компонента:

Graph
│
├── карта поля
├── координаты маркеров
└── соседние позиции
Planner
│
├── BFS
├── кратчайший маршрут
└── минимизация количества поворотов
Motion
│
├── Odometry
├── управление скоростью
└── движение между маркерами
ObstacleDetector
│
├── LaserScan
├── сравнение сканов
└── определение занятой клетки

Главный модуль связывает эти части в единый сценарий.

[Далее основная часть статьи: описание Graph, BFS, минимизации поворотов, Motion, Odometry, Twist, LaserScan и определения препятствия.]

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

На данном этапе робот ещё не собирает ягоды.

Сейчас решается задача нижнего уровня — мобильная платформа должна надёжно доставлять роботизированную систему к рабочим позициям.

В дальнейшем архитектура может выглядеть следующим образом:

                 РОБОТ-СБОРЩИК                       │        ┌──────────────┼──────────────┐        │              │              │        ↓              ↓              ↓   Навигация      Тех. зрение    Манипулятор        │              │              │        ↓              ↓              ↓   куда ехать     где клубника    как сорвать

Навигационный модуль отвечает за перемещение платформы.

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

Камера определяет ягоды:

изображение    ↓
поиск клубники    ↓
определение спелости    ↓
координаты ягоды

После этого координаты передаются манипулятору:

координаты ягоды       ↓
планирование движения       ↓
подвод захвата       ↓
срыв       ↓
укладка ягоды

После завершения сбора в данной позиции мобильная платформа переезжает дальше.

То есть в перспективе полный рабочий цикл должен выглядеть так:

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

Текущая работа закрывает первую часть этой цепочки.

Результат

На текущем этапе реализован прототип навигационного модуля робота-сборщика клубники.

Робот умеет:

  • работать с дискретной картой 5×5;

  • определять соседние доступные позиции;

  • искать кратчайший маршрут;

  • при одинаковой длине маршрутов выбирать вариант с меньшим количеством поворотов;

  • двигаться по маршруту с использованием одометрии;

  • управляться через Twist;

  • получать данные 2D-лидара;

  • обнаруживать появление нового препятствия;

  • определять занятую клетку;

  • исключать её из дальнейшего планирования;

  • строить новый маршрут.

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

Исходный код

Весь пример разделён на несколько Python-файлов:

module_b_navigation/
│
├── __init__.py
├── main.py
├── graph.py
├── planner.py
├── motion_controller.py
└── obstacle_detector.py

graph.py содержит модель поля 5×5, координаты 25 маркеров и получение доступных соседних клеток.

planner.py содержит BFS-планировщик. Сначала выбирается минимальная длина маршрута, затем минимальное количество поворотов.

motion_controller.py отвечает за фактическое движение мобильной платформы. Положение получается из:

/RMC2/odometry

а команды отправляются в:

/RMC2/cmd_vel

obstacle_detector.py получает:

/RMC2/scan

сохраняет исходный LaserScan, сравнивает его с новым сканом и определяет, возле какого маркера появилось препятствие.

main.py объединяет всё в один эксперимент:

построение маршрута
→ движение
→ сохранение лидара
→ появление препятствия
→ определение занятого маркера
→ перепланирование
→ возвращение

Запуск

Стартовый и конечный маркеры передаются параметрами ROS 2.

Например:

ros2 run <package_name> <executable_name> --ros-args \    -p start_id:=0 \    -p target_id:=22

После запуска робот строит маршрут и начинает движение.

В целевой точке программа предложит установить препятствие в Webots:

РОБОТ ОСТАНОВИЛСЯ В ЦЕЛЕВОЙ ТОЧКЕ
Поставьте ОДНО препятствие в Webots.
После этого введите y и нажмите Enter.
Препятствие установлено? [y]:

После ввода:

y

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

Репозиторий

Полный исходный код проекта я выложил на GitHub:

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

В репозитории находятся исходники навигационного модуля и инструкции по запуску проекта в ROS 2/Webots.

Что дальше

Следующий этап — приблизить модель к реальной задаче сбора клубники.

В первую очередь я планирую добавить анализ лидара не после остановки, а непосредственно во время движения:

робот движется      ↓
постоянно работает lidar      ↓
обнаружено препятствие?    ↙           ↘  нет            да   ↓              ↓
едем дальше    остановка                  ↓             обновление карты                  ↓             новый маршрут

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

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

В итоге хотелось бы получить полный экспериментальный цикл:

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

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

Полный код примера

Ниже приведу полный код навигационной части прототипа робота для сбора клубники.

Проект разделён на несколько небольших модулей:

module_b_navigation/
│
├── __init__.py
├── main.py
├── graph.py
├── planner.py
├── motion_controller.py
└── obstacle_detector.py

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

graph.py

Первый модуль описывает наше условное поле теплицы.

import math
class Graph:    """    Простая карта поля 5x5.    Нумерация маркеров:        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    Расстояние между соседними маркерами принимаем равным 1 метру.    """    SIZE = 5    def valid(self, marker):        """Проверяет, существует ли такой маркер."""        return 0 <= marker < 25    def position(self, marker):        """        Возвращает координаты маркера.        Например:        marker = 12        x = 12 % 5  = 2        y = 12 // 5 = 2        Значит маркер 12 находится в точке (2, 2).        """        x = marker % self.SIZE        y = marker // self.SIZE        return float(x), float(y)    def neighbors(self, marker, blocked=None):        """        Возвращает соседние клетки.        Порядок:        вправо -> вверх -> влево -> вниз.        """        if blocked is None:            blocked = set()        row = marker // self.SIZE        col = marker % self.SIZE        result = []        # Вправо        if col < self.SIZE - 1:            result.append(marker + 1)        # Вверх        if row < self.SIZE - 1:            result.append(marker + self.SIZE)        # Влево        if col > 0:            result.append(marker - 1)        # Вниз        if row > 0:            result.append(marker - self.SIZE)        # Убираем занятые клетки        return [m for m in result if m not in blocked]    def direction(self, a, b):        """        Возвращает направление движения между двумя соседними маркерами.        Нужно только для подсчёта поворотов.        """        ax, ay = self.position(a)        bx, by = self.position(b)        if bx > ax:            return "RIGHT"        if bx < ax:            return "LEFT"        if by > ay:            return "UP"        if by < ay:            return "DOWN"    def nearest_marker(        self,        x,        y,        max_distance=0.75,        excluded=None    ):        """        По координатам точки ищет ближайший маркер.        Это используется для лидара:        лидар дал координату препятствия ->        определяем номер клетки.        """        if excluded is None:            excluded = set()        best_marker = None        best_distance = 999999.0        for marker in range(25):            if marker in excluded:                continue            mx, my = self.position(marker)            distance = math.hypot(                x - mx,                y - my            )            if distance < best_distance:                best_distance = distance                best_marker = marker        if best_distance > max_distance:            return None        return best_marker

planner.py

Следующий модуль занимается поиском маршрута.

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

from collections import deque
class Planner:    """    Строит маршрут по клеткам.    Критерии:    1. Сначала ищем самый короткий маршрут.    2. Если коротких маршрутов несколько,       выбираем маршрут с меньшим количеством поворотов.    3. Если и это одинаково,       выбираем маршрут с меньшими ID маркеров.    """    def __init__(self, graph):        self.graph = graph    def count_turns(self, route):        """Считает количество поворотов в маршруте."""        if len(route) < 3:            return 0        turns = 0        old_direction = self.graph.direction(            route[0],            route[1]        )        for i in range(1, len(route) - 1):            new_direction = self.graph.direction(                route[i],                route[i + 1]            )            if new_direction != old_direction:                turns += 1            old_direction = new_direction        return turns    def plan(self, start, target, blocked=None):        """        Возвращает:            route, distance, turns        Например:            [0, 1, 2, 7, 12, 17, 22], 6, 1        """        if blocked is None:            blocked = set()        if not self.graph.valid(start):            raise ValueError(                "Неверный стартовый маркер"            )        if not self.graph.valid(target):            raise ValueError(                "Неверный целевой маркер"            )        if start in blocked:            raise ValueError(                "Стартовая клетка занята"            )        if target in blocked:            raise ValueError(                "Целевая клетка занята"            )        if start == target:            return [start], 0, 0        # В очереди лежат целые маршруты.        queue = deque()        queue.append([start])        # Здесь будем хранить все самые короткие маршруты до цели.        found_routes = []        shortest_length = None        while queue:            route = queue.popleft()            # Если уже нашли более короткий маршрут,            # этот маршрут продолжать бессмысленно.            if shortest_length is not None:                if len(route) > shortest_length:                    break            current = route[-1]            if current == target:                shortest_length = len(route)                found_routes.append(route)                continue            for next_marker in self.graph.neighbors(                current,                blocked            ):                # Не заходим второй раз в клетку,                # которая уже есть в этом маршруте.                if next_marker in route:                    continue                new_route = route + [next_marker]                queue.append(new_route)        if not found_routes:            raise RuntimeError(                "Маршрут не найден"            )        # Из всех кратчайших маршрутов выбираем:        # сначала по количеству поворотов,        # потом по самим ID маркеров.        best_route = min(            found_routes,            key=lambda route: (                self.count_turns(route),                route            )        )        distance = len(best_route) - 1        turns = self.count_turns(best_route)        return best_route, distance, turns

motion_controller.py

Теперь нужен контроллер, который заставит мобильную платформу физически выполнить полученный маршрут в Webots.

Он получает положение из одометрии и отправляет команды скорости через Twist.

import math
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
def angle_to_pi(angle):    """    Приводит угол к диапазону от -pi до +pi.    """    while angle > math.pi:        angle -= 2 * math.pi    while angle < -math.pi:        angle += 2 * math.pi    return angle
def quaternion_to_yaw(q):    """    ROS хранит ориентацию как quaternion.    Для движения по плоскости нам нужен    только угол вокруг Z (yaw).    """    sin_yaw = 2 * (        q.w * q.z +        q.x * q.y    )    cos_yaw = 1 - 2 * (        q.y * q.y +        q.z * q.z    )    return math.atan2(        sin_yaw,        cos_yaw    )
class Motion:    """    Простое движение робота по готовому маршруту.    Класс:    - получает odometry;    - знает положение робота;    - поворачивает робота к следующему маркеру;    - едет к нему;    - после достижения переходит к следующему.    """    def __init__(self, node, graph):        self.node = node        self.graph = graph        # Команды скорости робота        self.publisher = node.create_publisher(            Twist,            "/RMC2/cmd_vel",            10        )        # Положение робота        node.create_subscription(            Odometry,            "/RMC2/odometry",            self.odom_callback,            10        )        # Управление вызывается каждые 0.05 секунды        node.create_timer(            0.05,            self.control        )        self.x = None        self.y = None        self.yaw = None        self.route = []        self.index = 0        # Пока active == True,        # робот выполняет маршрут.        self.active = False        # Скорости        self.linear_speed = 0.20        self.angular_speed = 0.60        # Допуски        self.distance_tolerance = 0.12        self.angle_tolerance = math.radians(5)    def odom_callback(self, msg):        """Получаем текущее положение робота."""        self.x = msg.pose.pose.position.x        self.y = msg.pose.pose.position.y        self.yaw = quaternion_to_yaw(            msg.pose.pose.orientation        )    def start(self, route):        """Запускает движение по маршруту."""        self.route = route        # route[0] — клетка,        # где робот уже находится.        # Поэтому начинаем с route[1].        self.index = 1        if len(route) < 2:            self.active = False            return        self.active = True        self.node.get_logger().info(            f"movement_start route={route}"        )    def stop(self):        """Посылает нулевую скорость."""        self.publisher.publish(            Twist()        )    def finish(self):        """Останавливает робот и завершает маршрут."""        self.stop()        self.active = False        self.node.get_logger().info(            "movement_stop"        )    def control(self):        """        Основной алгоритм движения.        1. Берём следующий маркер.        2. Вычисляем, где он находится.        3. Если робот смотрит не туда — поворачиваемся.        4. Если смотрит туда — едем прямо.        5. Если приехали — берём следующий маркер.        """        if not self.active:            return        # Ждём первую odometry.        if self.x is None:            return        # Если маршрут закончился.        if self.index >= len(self.route):            self.finish()            return        target = self.route[self.index]        target_x, target_y = self.graph.position(            target        )        dx = target_x - self.x        dy = target_y - self.y        distance = math.hypot(            dx,            dy        )        # Угол, куда нужно смотреть.        target_yaw = math.atan2(            dy,            dx        )        # Ошибка по углу.        error = angle_to_pi(            target_yaw - self.yaw        )        # Уже приехали к маркеру.        if distance < self.distance_tolerance:            self.node.get_logger().info(                f"marker_reached: {target}"            )            self.stop()            self.index += 1            if self.index >= len(self.route):                self.finish()            return        command = Twist()        # Сначала поворачиваемся.        if abs(error) > self.angle_tolerance:            command.linear.x = 0.0            if error > 0:                command.angular.z = (                    self.angular_speed                )            else:                command.angular.z = (                    -self.angular_speed                )        # Потом едем прямо.        else:            command.linear.x = self.linear_speed            # Небольшая коррекция курса.            command.angular.z = 1.2 * error            # Ограничиваем скорость поворота.            if command.angular.z > 0.30:                command.angular.z = 0.30            if command.angular.z < -0.30:                command.angular.z = -0.30        self.publisher.publish(            command        )

obstacle_detector.py

Этот модуль отвечает за лидар.

Перед появлением препятствия сохраняется исходный скан. После установки объекта новый скан сравнивается с исходным.

import math
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import LaserScan
class ObstacleDetector:    """    Ищет ОДНО новое препятствие.    Идея:    1. До появления препятствия запоминаем lidar.    2. Пользователь ставит препятствие.    3. Получаем новый lidar.    4. Ищем лучи, у которых расстояние сильно уменьшилось.    5. Переводим эти точки в координаты мира.    6. Смотрим, возле какого маркера больше всего таких точек.    """    def __init__(self, node, graph):        self.node = node        self.graph = graph        # Последнее сообщение лидара.        self.scan = None        # Расстояния до появления препятствия.        self.baseline = None        node.create_subscription(            LaserScan,            "/RMC2/scan",            self.scan_callback,            qos_profile_sensor_data        )        # Если расстояние уменьшилось минимум на 20 см,        # считаем, что появился новый объект.        self.min_change = 0.20        # Насколько далеко точка может быть        # от центра клетки.        self.max_marker_distance = 0.75        # Минимум lidar-точек,        # чтобы поверить результату.        self.min_votes = 3    def scan_callback(self, msg):        """Просто сохраняем самый свежий scan."""        self.scan = msg    def save_baseline(self):        """        Запоминаем lidar до появления препятствия.        """        if self.scan is None:            return False        self.baseline = list(            self.scan.ranges        )        return True    def find_obstacle(        self,        robot_x,        robot_y,        robot_yaw,        excluded=None    ):        """        Возвращает номер занятой клетки.        Если препятствие не найдено —        возвращает None.        """        if excluded is None:            excluded = set()        if self.scan is None:            return None        if self.baseline is None:            return None        if len(self.baseline) != len(            self.scan.ranges        ):            return None        # votes:        # ключ = номер маркера        # значение = сколько lidar-точек        # попало возле него        votes = {}        # Для лога сохраняем координаты точек.        points = {}        for i in range(            len(self.scan.ranges)        ):            old_range = self.baseline[i]            new_range = self.scan.ranges[i]            # Текущая дальность должна быть нормальной.            if not math.isfinite(new_range):                continue            if new_range < self.scan.range_min:                continue            if new_range > self.scan.range_max:                continue            # Проверяем, появился ли здесь новый объект.            new_object = False            # Раньше лидар ничего не видел,            # а теперь увидел объект.            if not math.isfinite(old_range):                new_object = True            # Или объект стал минимум            # на 20 см ближе.            elif (                old_range - new_range                >= self.min_change            ):                new_object = True            if not new_object:                continue            # Угол конкретного луча лидара.            angle = (                self.scan.angle_min                + i * self.scan.angle_increment            )            # Координаты относительно робота.            local_x = (                new_range * math.cos(angle)            )            local_y = (                new_range * math.sin(angle)            )            # Переводим точку            # в мировые координаты.            world_x = robot_x + (                local_x * math.cos(robot_yaw)                - local_y * math.sin(robot_yaw)            )            world_y = robot_y + (                local_x * math.sin(robot_yaw)                + local_y * math.cos(robot_yaw)            )            # Определяем ближайший маркер.            marker = self.graph.nearest_marker(                world_x,                world_y,                self.max_marker_distance,                excluded            )            if marker is None:                continue            # Голосуем за этот маркер.            votes[marker] = (                votes.get(marker, 0) + 1            )            if marker not in points:                points[marker] = []            points[marker].append(                (world_x, world_y)            )        if not votes:            return None        # Маркер с максимальным количеством голосов.        best_marker = max(            votes,            key=votes.get        )        self.node.get_logger().info(            f"obstacle_votes="            f"{dict(sorted(votes.items()))}"        )        if votes[best_marker] < self.min_votes:            return None        # Средняя координата lidar-точек,        # проголосовавших за победивший маркер.        best_points = points[best_marker]        avg_x = (            sum(p[0] for p in best_points)            / len(best_points)        )        avg_y = (            sum(p[1] for p in best_points)            / len(best_points)        )        self.node.get_logger().info(            f"obstacle_world_position≈"            f"({avg_x:.3f}, {avg_y:.3f})"        )        return best_marker

main.py

И наконец главный файл.

Он объединяет планировщик, контроллер движения и лидар в один эксперимент.

import rclpy
from rclpy.node import Node
from module_b_navigation.graph import Graph
from module_b_navigation.planner import Planner
from module_b_navigation.motion_controller import Motion
from module_b_navigation.obstacle_detector import (    ObstacleDetector
)
def wait_robot(node, motion):    """    Пока робот едет,    даём ROS обрабатывать сообщения.    """    while motion.active and rclpy.ok():        rclpy.spin_once(            node,            timeout_sec=0.1        )
def wait_for_new_scan(node):    """    После установки препятствия    несколько раз обрабатываем ROS-сообщения,    чтобы получить свежий lidar scan.    """    for i in range(10):        rclpy.spin_once(            node,            timeout_sec=0.1        )
def print_route(    title,    start,    target,    blocked,    route,    distance,    turns
):    """Печатает найденный маршрут."""    print()    print("========================================")    print(title)    print("========================================")    print("Start marker :", start)    print("Target marker:", target)    print("Blocked      :", sorted(blocked))    print()    print("ROUTE    :", route)    print("DISTANCE :", distance)    print("TURNS    :", turns)    print("========================================")    print()
def main(args=None):    # Запускаем ROS.    rclpy.init(args=args)    node = Node(        "module_b_navigation"    )    # Параметры запуска.    node.declare_parameter(        "start_id",        0    )    node.declare_parameter(        "target_id",        22    )    start = node.get_parameter(        "start_id"    ).value    target = node.get_parameter(        "target_id"    ).value    # Создаём основные части программы.    graph = Graph()    planner = Planner(        graph    )    motion = Motion(        node,        graph    )    detector = ObstacleDetector(        node,        graph    )    try:        # ==============================================        # 1. СТРОИМ МАРШРУТ ТУДА        # ==============================================        blocked = set()        node.get_logger().info(            "OUTBOUND_PLANNING_START"        )        route, distance, turns = planner.plan(            start,            target,            blocked        )        print_route(            "OUTBOUND ROUTE",            start,            target,            blocked,            route,            distance,            turns        )        # ==============================================        # 2. ЕДЕМ К ЦЕЛИ        # ==============================================        node.get_logger().info(            "OUTBOUND_MOVEMENT_START"        )        motion.start(            route        )        wait_robot(            node,            motion        )        node.get_logger().info(            "OUTBOUND_MOVEMENT_STOP"        )        # ==============================================        # 3. ЗАПОМИНАЕМ ЛИДАР        # ==============================================        if not detector.save_baseline():            node.get_logger().error(                "BASELINE_SCAN_NOT_AVAILABLE"            )            return        node.get_logger().info(            "BASELINE_SCAN_CAPTURED"        )        # ==============================================        # 4. ПОЛЬЗОВАТЕЛЬ СТАВИТ ПРЕПЯТСТВИЕ        # ==============================================        print()        print("========================================")        print("РОБОТ ОСТАНОВИЛСЯ В ЦЕЛЕВОЙ ТОЧКЕ")        print("========================================")        print(            "Поставьте ОДНО препятствие в Webots."        )        print(            "После этого введите y и нажмите Enter."        )        print("========================================")        while True:            answer = input(                "Препятствие установлено? [y]: "            )            if answer.lower().strip() == "y":                break        # ==============================================        # 5. ПОЛУЧАЕМ СВЕЖИЙ ЛИДАР        # ==============================================        wait_for_new_scan(            node        )        # ==============================================        # 6. ИЩЕМ ЗАНЯТУЮ КЛЕТКУ        # ==============================================        node.get_logger().info(            "OBSTACLE_SCAN_START"        )        blocked_marker = detector.find_obstacle(            motion.x,            motion.y,            motion.yaw,            excluded={                start,                target            }        )        if blocked_marker is None:            print()            print(                "Препятствие не найдено."            )            print()            return        print()        print("========================================")        print(            "OBSTACLE DETECTED ON MARKER",            blocked_marker        )        print("========================================")        print()        # Эта клетка теперь запрещена.        blocked = {            blocked_marker        }        # ==============================================        # 7. СТРОИМ ОБРАТНЫЙ МАРШРУТ        # ==============================================        node.get_logger().info(            "RETURN_PLANNING_START"        )        return_route, distance, turns = (            planner.plan(                target,                start,                blocked            )        )        print_route(            "RETURN ROUTE",            target,            start,            blocked,            return_route,            distance,            turns        )        # ==============================================        # 8. ЕДЕМ ОБРАТНО        # ==============================================        node.get_logger().info(            "RETURN_MOVEMENT_START"        )        motion.start(            return_route        )        wait_robot(            node,            motion        )        node.get_logger().info(            "RETURN_MOVEMENT_STOP"        )        node.get_logger().info(            "MODULE_B_DONE"        )        print()        print("========================================")        print("MODULE B COMPLETE")        print(            "Robot returned to the start marker."        )        print("========================================")        print()    except KeyboardInterrupt:        pass    finally:        # На всякий случай        # всегда останавливаем робот.        motion.stop()        node.destroy_node()        rclpy.shutdown()
if __name__ == "__main__":    main()

Что происходит после запуска

Весь пример можно свести к следующей последовательности:

робот для сбора клубники          ↓     START = 0          ↓         BFS          ↓   маршрут к растениям          ↓       движение          ↓     TARGET = 22          ↓   сохранение LaserScan          ↓  появление препятствия          ↓ сравнение двух сканов          ↓
определение занятой клетки          ↓ blocked = {marker}          ↓     повторный BFS          ↓   обход препятствия          ↓
возвращение к START

Конечно, это пока только навигационная часть будущего робота-сборщика. Здесь ещё нет распознавания ягод, определения спелости и управления манипулятором.

Но уже на этом этапе отдельно отрабатываются планирование маршрута, управление мобильной платформой, работа с одометрией и лидаром, обнаружение препятствия и перепланирование.

Следующим этапом эту систему можно связать с техническим зрением и манипулятором:

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

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.