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

Как собирают сейчас
Есть условно три уровня:
ручной сбор ↓ механизация и помощь сборщику ↓ автономный роботизированный сбор
При ручном сборе человек сам определяет зрелость, выбирает ягоду и выполняет манипуляцию. Есть и промежуточные системы: например, роботы могут не срывать ягоду, а возить лотки и тем самым сокращать непроизводительные перемещения работников. В одном полевом исследовании такой подход уменьшил непроизводительное время сборщиков примерно на 60% при соотношении один робот на трёх работников. Реальный сборщик имеет производительность около 8 кг/час в среднем за 12 смену.
А существуют уже и специализированные роботизированные системы. Например, Dogtooth Technologies описывает роботов для выращиваемой на столах клубники, которые перемещаются вдоль рядов, находят зрелые ягоды, собирают их и выполняют контроль качества. Компания заявляет для своего пятого поколения производительность до 200 кг в день.

Упрощаем задачу
Пока реализовать полный цикл с мобильной платформой мы не в силах, поэтому пробуем создать управление манипулятором для этой задачи.
Схема имеет следующий вид:
есть клубника ? ↓ X, Y, Z манипулятор ↓ подвести захват ↓ сжать захват
На этом этапе система ещё не ищет клубнику камерой. Координаты точки задаются вручную. Это позволяет сначала разобраться с управлением манипулятором, а уже затем подключать компьютерное зрение.
Первый способ управления — непосредственно суставами
И вот здесь появляется первый наш код.
Манипулятор имеет шесть управляемых суставов (joint):
joint1 joint2 joint3 joint4 joint5 joint6
Каждый joint — это угол, указанный в радианах.
При этом важно понимать, что поворот одного сустава влияет на положение всех звеньев, расположенных после него.
Например, если повернуть joint1, фактически относительно основания повернётся почти вся рука. Если изменить joint6, движение в основном затронет уже конечную часть манипулятора.
Поэтому положение захвата определяется сразу совокупностью значений всех шести суставов.
Отправляем положение суставов из Python
Для первого эксперимента я использовал следующий небольшой ROS2-скрипт:
import rclpy from rclpy.node import Node from rclpy.action import ActionClient from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint from control_msgs.action import GripperCommand def main(): rclpy.init() node = Node("joint") arm = node.create_publisher( JointTrajectory, "/RMC1/arm95/arm_joint_trajectory_controller/joint_trajectory", 10 ) gripper = ActionClient( node, GripperCommand, "/RMC1/arm95/gripper_controller/gripper_cmd" ) msg = JointTrajectory() msg.joint_names = ["joint1", "joint2", "joint3", "joint4", "joint5", "joint6"] point = JointTrajectoryPoint() point.positions = [0.1, 0.3, -0.3, 0.0, 0.0, 0.0] point.time_from_start.sec = 3 msg.points = [point] rclpy.spin_once(node, timeout_sec=1) arm.publish(msg) rclpy.spin_once(node, timeout_sec=3) gripper.wait_for_server() goal = GripperCommand.Goal() goal.command.position = 1.0 gripper.send_goal_async(goal) rclpy.spin_once(node, timeout_sec=1) rclpy.shutdown() if __name__ == "__main__": main()
Разберём эту программу подробно.
Подключаем ROS 2
Начинается программа с импортов:
import rclpy from rclpy.node import Node from rclpy.action import ActionClient
rclpy — Python-библиотека ROS 2. Через неё программа может создавать ROS-ноды, публиковать сообщения, подписываться на топики и работать с actions.
Node понадобится для создания нашей собственной ноды:
node = Node("joint")
А ActionClient используется для управления захватом.
Следующие импорты относятся уже непосредственно к управлению манипулятором:
from trajectory_msgs.msg import JointTrajectory from trajectory_msgs.msg import JointTrajectoryPoint
Здесь используются два типа сообщений.
JointTrajectory описывает траекторию движения суставов целиком.
JointTrajectoryPoint описывает отдельную точку этой траектории — то есть конкретный набор положений суставов в определённый момент времени.
Это различие станет понятнее чуть ниже.
Запускаем ROS2
В начале функции main() вызывается:
rclpy.init()
Этой командой инициализируется ROS 2 для нашей Python-программы.
Затем создаётся нода:
node = Node("joint")
Теперь наша программа появляется в ROS-графе как отдельный узел с именем joint.
Создаём канал управления рукой
Следующий фрагмент:
arm = node.create_publisher( JointTrajectory, "/arm_joint_trajectory_controller/joint_trajectory", 10 )
создаёт publisher.
Publisher можно представить как передатчик сообщений.
Наша нода будет отправлять сообщения типа JointTrajectory в топик /arm_joint_trajectory_controller/joint_trajectory
Этот топик слушает контроллер манипулятора, то есть непосредственно двигателями Python-программа не управляет. Она сообщает контроллеру желаемое положение суставов, а уже контроллер выполняет необходимое движение.
Создаём сообщение JointTrajectory
Следующая строка:
msg = JointTrajectory()
создаёт пустое сообщение с траекторией.
Теперь необходимо сообщить контроллеру, какие суставы входят в эту траекторию.
Для этого записываем:
msg.joint_names = [ "joint1", "joint2", "joint3", "joint4", "joint5", "joint6" ]
Получается список из шести суставов.
Сам по себе этот список ещё не заставляет руку двигаться
Задаём положение руки
Теперь создаём точку траектории:
point = JointTrajectoryPoint()
Именно здесь появляется самая важная строка программы:
point.positions = [ 0.1, 0.3, -0.3, 0.0, 0.0, 0.0 ]
Эти числа соответствуют суставам, которые мы перечислили выше.
ROS сопоставляет два массива по порядку, при этом знаки имеют значение. Таким образом, строка:
point.positions = [0.1, 0.3, -0.3, 0.0, 0.0, 0.0]
является практически непосредственным описанием того, какую конфигурацию должна принять рука.
Почему это называется траекторией, если точка всего одна?
Название JointTrajectory сначала может немного запутать. В нашем примере мы фактически передаём только конечную точку, но ROS позволяет передавать несколько таких точек. Для каждой точки можно определить собственные положения суставов и время. Мы же пока используем максимально простой вариант.
Задаём время движения
Следующая строка:
point.time_from_start.sec = 3
говорит контроллеру, через какое время от начала траектории должна быть достигнута эта точка.
В нашем случае:
t = 0 секунд текущее положение ↓ движение t = 3 секунды [0.1, 0.3, -0.3, 0, 0, 0]
Таким образом, мы задаём не только конечное положение суставов, но и временную характеристику движения.
Добавляем точку в траекторию
До этого момента point существовал отдельно от msg.
Теперь соединяем их:
msg.points = [point]
И сообщение JointTrajectory становится полностью сформированным.
Внутри него условно находится:
JointTrajectory Суставы: joint1 joint2 joint3 joint4 joint5 joint6 + Точка: [0.1, 0.3, -0.3, 0, 0, 0] + Время: 3 секунды
Теперь это сообщение уже можно отправлять контроллеру.
Отправляем команду
Перед публикацией в тестовом коде вызывается:
rclpy.spin_once(node, timeout_sec=1)
Это даёт ноде возможность обработать события ROS2 перед отправкой команды.
После этого выполняется главное действие:
arm.publish(msg)
Сообщение отправляется в топик контроллера.
Именно в этот момент наша команда уходит из Python-программы:
point.positions │ ▼ JointTrajectory │ ▼ publish() │ ▼ ROS 2 │ ▼ joint trajectory controller │ ▼ манипулятор
После публикации программа оставляет ноду активной ещё некоторое время:
rclpy.spin_once(node, timeout_sec=3)
В данном простом тесте это удобно, потому что мы задали движение продолжительностью три секунды.
Однако здесь есть важное упрощение: мы просто ждём три секунды. В полноценной программе лучше получать от контроллера информацию о фактическом завершении движения.
Теперь закрываем захват
До этого момента мы управляли только шестью суставами самой руки. Но для сбора клубники этого недостаточно. После того как рука подошла к ягоде, необходимо управлять ещё и захватом. Захват в нашей системе имеет собственный контроллер, поэтому для него создаётся отдельный клиент.
Обратите внимание на важное отличие:
Рука управляется через JointTrajectory, а захват — через GripperCommand. То есть программно это два отдельных исполнительных механизма.
Почему для захвата используется ActionClient?
Для руки в нашем простом примере мы просто публикуем сообщение:
arm.publish(msg)
Для захвата используется ROS2 Action.
Action подходит для команды, выполнение которой занимает некоторое время и для которой можно получить информацию о принятии и результате выполнения.
Сначала программа ждёт доступности контроллера:
gripper.wait_for_server()
Затем создаёт команду:
goal = GripperCommand.Goal()
И задаёт положение захвата:
goal.command.position = 0.0
Для используемой конфигурации:
0.04 │ └── захват открыт 0.00 │ └── захват закрыт
Что в итоге делает вся программа?
Если убрать весь служебный код ROS2, алгоритм оказывается очень маленьким:
1. Создать ROS-ноду 2. Задать: joint1 = 0.1 joint2 = 0.3 joint3 = -0.3 joint4 = 0 joint5 = 0 joint6 = 0 3. Дать руке 3 секунды на движение 4. Закрыть захват
Именно поэтому управление через joint оказалось удобным первым этапом проекта.
Мы пока вообще не решаем сложную задачу: Где находится клубника и как автоматически подвести к ней захват? Вместо этого сначала проверяем более фундаментальные вещи.
Только после проверки этого уровня имеет смысл переходить к следующей задаче — определению положения ягоды и автоматическому расчёту необходимых движений манипулятора.
Это ещё не автономный сбор клубники, но уже необходимый нижний уровень будущей системы: мы научились программно задавать конфигурацию манипулятора и отдельно управлять его захватом.
Заключение
Разрабатываемая система представляет собой основу робота-манипулятора для автоматизации сбора.
На текущем этапе управление уже может осуществляться на уровне отдельных суставов, а наличие настроенного MoveIt позволяет перейти к планированию движения рабочего органа в декартовых координатах.
Следующим важным этапом является объединение системы технического зрения и манипулятора. Камера должна определять положение ягоды, после чего его координаты будут преобразовываться в систему координат робота и передаваться системе планирования движения.
В результате робот должен получить возможность самостоятельно подъехать к требуемой рабочей зоне, обнаружить объект, рассчитать движение манипулятора, выполнить захват и продолжить выполнение задания.
Комментарии (6)

wowa144 Автор
16.09.2026 07:05
Тест реального робота на клубничной ферме В качестве дополнения можно создать простой интерфейс, используя какой-нибудь фреймворк интерфейса и ROS2. Конкретно тут покажу, как взаимодействовать с элементами интерфейса, получая данные из топиков.
1. Подключение библиотек, данные роботов и запуск ROS2
В первой части подключаются необходимые библиотеки и создаётся структура, в которой будут храниться последние данные от двух роботов. Для каждого РМК сохраняются координаты
x,y, угол поворотаyaw, напряжение батареи и последнее сообщение лидара.После этого инициализируется ROS 2 и создаётся узел
fms_visualizer. Функцияquaternion_to_yaw()нужна потому, что ориентация робота в сообщении одометрии приходит в виде кватерниона, а для отображения на плоской карте удобнее использовать обычный угол поворота.import tkinter as tk import math import rclpy from rclpy.node import Node from rclpy.qos import qos_profile_sensor_data from nav_msgs.msg import Odometry from sensor_msgs.msg import LaserScan from std_msgs.msg import Float32 GRID_SIZE = 5 CELL_SIZE = 100 MAP_OFFSET = 50 robots = { "РМК-1": { "x": 0.0, "y": 0.0, "yaw": 0.0, "battery": None, "scan": None }, "РМК-2": { "x": 0.0, "y": 0.0, "yaw": 0.0, "battery": None, "scan": None } } rclpy.init() node = Node("fms_visualizer") 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)Таким образом, на этом этапе у программы уже есть ROS-узел и место, куда в дальнейшем будут записываться данные от каждого робота.
2. Получение одометрии, лидара и состояния батареи
Следующая часть отвечает за получение информации из ROS2.
Функция
update_odometry()сохраняет положение и ориентацию выбранного робота. Для каждого РМК сделаны отдельные callback-функции, поскольку данные приходят из разных топиков.Аналогично принимаются данные лидара и напряжение аккумулятора.
def update_odometry(name, msg): robots[name]["x"] = msg.pose.pose.position.x robots[name]["y"] = msg.pose.pose.position.y robots[name]["yaw"] = quaternion_to_yaw(msg.pose.pose.orientation) def odom_rmc1(msg): update_odometry("РМК-1", msg) def odom_rmc2(msg): update_odometry("РМК-2", msg) def battery_rmc1(msg): robots["РМК-1"]["battery"] = msg.data def battery_rmc2(msg): robots["РМК-2"]["battery"] = msg.data def scan_rmc1(msg): robots["РМК-1"]["scan"] = msg def scan_rmc2(msg): robots["РМК-2"]["scan"] = msg node.create_subscription(Odometry, "/RMC1/odometry", odom_rmc1, 10) node.create_subscription(LaserScan, "/RMC1/scan", scan_rmc1, qos_profile_sensor_data) node.create_subscription(Float32, "/RMC1/odrive_voltage", battery_rmc1, 10) node.create_subscription(Odometry, "/RMC2/odometry", odom_rmc2, 10) node.create_subscription(LaserScan, "/RMC2/scan", scan_rmc2, qos_profile_sensor_data) node.create_subscription(Float32, "/RMC2/odrive_voltage", battery_rmc2, 10)В результате визуализатор одновременно подписан на шесть топиков:
При этом интерфейсу не нужно постоянно обращаться к ROS-топикам напрямую. Callback-функции обновляют словарь
robots, а графическая часть программы уже берёт актуальные значения из него.3. Отрисовка робота и данных лидара
Теперь можно перейти к визуализации полученных данных. В этой части покажу как это можно сделать с canvas tkinter.
Робот отображается кругом, а его направление — стрелкой. Координаты из ROS сначала преобразуются в координаты
Canvas.Для лидара выполняется дополнительное преобразование. Каждый луч сначала задан относительно самого робота. Поэтому координаты точки необходимо повернуть на текущий
yawробота и перенести в мировую систему координат.def draw_robot(name): x = robots[name]["x"] y = robots[name]["y"] yaw = robots[name]["yaw"] canvas_x, canvas_y = world_to_canvas(x, y) radius = 15 canvas.coords( robot_body, canvas_x - radius, canvas_y - radius, canvas_x + radius, canvas_y + radius ) arrow_length = 35 arrow_x = canvas_x + arrow_length * math.cos(yaw) arrow_y = canvas_y - arrow_length * math.sin(yaw) canvas.coords( robot_arrow, canvas_x, canvas_y, arrow_x, arrow_y ) def draw_lidar(name): canvas.delete("lidar") scan = robots[name]["scan"] if scan is None: return robot_x = robots[name]["x"] robot_y = robots[name]["y"] robot_yaw = robots[name]["yaw"] for i in range(0, len(scan.ranges), 2): distance = scan.ranges[i] if not math.isfinite(distance): continue if distance < scan.range_min: continue if distance > scan.range_max: continue angle = scan.angle_min + i * scan.angle_increment local_x = distance * math.cos(angle) local_y = distance * 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) ) canvas_x, canvas_y = world_to_canvas(world_x, world_y) radius = 2 canvas.create_oval( canvas_x - radius, canvas_y - radius, canvas_x + radius, canvas_y + radius, fill="red", outline="red", tags="lidar" )Для уменьшения количества отображаемых точек берётся каждый второй луч:
for i in range(0, len(scan.ranges), 2):Значения
inf,nan, а также измерения за пределами рабочего диапазона лидара отбрасываются.4. Обновление интерфейса и создание окна
Следующая часть связывает данные ROS с Tkinter.
update_gui()определяет выбранного пользователем робота, выводит его координаты, угол и напряжение батареи, а затем обновляет изображение робота и лидара.Отдельная функция
ros_spin()периодически вызываетrclpy.spin_once(). Это позволяет обрабатывать ROS-сообщения и одновременно не блокировать главный цикл Tkinter.def update_gui(): name = selected_robot.get() x = robots[name]["x"] y = robots[name]["y"] yaw = robots[name]["yaw"] yaw_degrees = math.degrees(yaw) position_label.config( text=( f"{name}\n\n" f"Одометрия:\n" f"x = {x:.2f} м\n" f"y = {y:.2f} м\n" f"yaw = {yaw_degrees:.1f}°" ) ) battery = robots[name]["battery"] if battery is None: battery_label.config(text="Батарея: нет данных") else: battery_label.config(text=f"Батарея: {battery:.2f} V") draw_lidar(name) draw_robot(name) root.after(100, update_gui) def ros_spin(): rclpy.spin_once(node, timeout_sec=0) root.after(10, ros_spin) def close_program(): node.destroy_node() rclpy.shutdown() root.destroy() root = tk.Tk() root.title("FMS — визуализация РМК") root.geometry("900x700") canvas = tk.Canvas( root, width=600, height=600, bg="white" ) canvas.pack( side="left", padx=20, pady=20 ) right_panel = tk.Frame(root) right_panel.pack( side="left", padx=20, pady=20, anchor="n" ) tk.Label( right_panel, text="Выберите РМК:", font=("Arial", 14) ).pack( anchor="w", pady=(20, 5) ) selected_robot = tk.StringVar(value="РМК-2") robot_menu = tk.OptionMenu( right_panel, selected_robot, "РМК-1", "РМК-2" ) robot_menu.config( font=("Arial", 14), width=10 ) robot_menu.pack( anchor="w", pady=5 ) position_label = tk.Label( right_panel, text="Ожидание одометрии...", font=("Arial", 14), justify="left" ) position_label.pack( anchor="w", pady=30 ) battery_label = tk.Label( right_panel, text="Батарея: нет данных", font=("Arial", 14) ) battery_label.pack( anchor="w", pady=10 )Важный момент здесь — совместная работа двух циклов событий.
ROS проверяется каждые 10 мс:
root.after(10, ros_spin)а графика обновляется раз в 100 мс:
root.after(100, update_gui)Благодаря этому для простой диагностической программы не требуется создавать отдельный поток для ROS.
5. Карта, преобразование координат и запуск программы
Последняя часть создаёт поле размером 5×5 клеток.
Каждая клетка получает номер от
0до24. После этого определяется функция преобразования мировых координат робота в пиксельные координатыCanvas.Также создаются два графических объекта: круг робота и стрелка его направления. В самом конце запускаются обработка ROS, обновление интерфейса и основной цикл Tkinter.
for y in range(GRID_SIZE): for x in range(GRID_SIZE): x1 = MAP_OFFSET + x * CELL_SIZE y1 = MAP_OFFSET + (GRID_SIZE - 1 - y) * CELL_SIZE x2 = x1 + CELL_SIZE y2 = y1 + CELL_SIZE canvas.create_rectangle(x1,y1,x2,y2,outline="gray") cell = y * GRID_SIZE + x canvas.create_text( x1 + 15, y1 + 15, text=str(cell), fill="gray" ) def world_to_canvas(x, y): canvas_x = MAP_OFFSET + CELL_SIZE / 2 + x * CELL_SIZE canvas_y = MAP_OFFSET + CELL_SIZE * 4.5 - y * CELL_SIZE return canvas_x, canvas_y robot_body = canvas.create_oval(0,0,0,0,fill="blue") robot_arrow = canvas.create_line(0,0,0,0,width=4,fill="blue",arrow=tk.LAST) root.protocol("WM_DELETE_WINDOW", close_program) ros_spin() update_gui() root.mainloop()Здесь есть одна особенность: система координат ROS и система координат Tkinter направлены по-разному. В ROS положительное направление
Yна нашей карте идёт вверх, а уCanvasкоординатаYувеличивается вниз. Поэтому при преобразовании координат используется:canvas_y = MAP_OFFSET + CELL_SIZE * 4.5 - y * CELL_SIZEВ результате координаты робота, полученные из одометрии, можно непосредственно показать на нашей сетке 5×5.

wowa144 Автор
16.09.2026 07:05При этом если мы хотим вводить координаты вручную, то:
import tkinter as tk import math import rclpy from rclpy.node import Node from rclpy.qos import qos_profile_sensor_data from nav_msgs.msg import Odometry from sensor_msgs.msg import LaserScan from std_msgs.msg import Float32 GRID_SIZE = 5 CELL_SIZE = 100 MAP_OFFSET = 50 def input_cell(name): while True: try: cell = int(input(f"Стартовая клетка {name} (0-24): ")) if 0 <= cell <= 24: return cell except ValueError: pass print("Введите число от 0 до 24") def input_direction(name): while True: direction = input( f"Ориентация {name} (W/A/S/D): " ).strip().lower() if direction in ("w", "a", "s", "d"): return direction print("Введите W, A, S или D") rmc1_cell = input_cell("РМК-1") rmc1_direction = input_direction("РМК-1") rmc2_cell = input_cell("РМК-2") rmc2_direction = input_direction("РМК-2") robots = { "РМК-1": { "x": 0.0, "y": 0.0, "yaw": 0.0, "battery": None, "scan": None, "start_cell": rmc1_cell, "direction": rmc1_direction }, "РМК-2": { "x": 0.0, "y": 0.0, "yaw": 0.0, "battery": None, "scan": None, "start_cell": rmc2_cell, "direction": rmc2_direction } } rclpy.init() node = Node("fms_visualizer") 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 update_odometry(name, msg): robots[name]["x"] = msg.pose.pose.position.x robots[name]["y"] = msg.pose.pose.position.y robots[name]["yaw"] = quaternion_to_yaw( msg.pose.pose.orientation ) def odom_rmc1(msg): update_odometry("РМК-1", msg) def odom_rmc2(msg): update_odometry("РМК-2", msg) def battery_rmc1(msg): robots["РМК-1"]["battery"] = msg.data def battery_rmc2(msg): robots["РМК-2"]["battery"] = msg.data def scan_rmc1(msg): robots["РМК-1"]["scan"] = msg def scan_rmc2(msg): robots["РМК-2"]["scan"] = msg node.create_subscription( Odometry, "/RMC1/odometry", odom_rmc1, 10 ) node.create_subscription( LaserScan, "/RMC1/scan", scan_rmc1, qos_profile_sensor_data ) node.create_subscription( Float32, "/RMC1/odrive_voltage", battery_rmc1, 10 ) node.create_subscription( Odometry, "/RMC2/odometry", odom_rmc2, 10 ) node.create_subscription( LaserScan, "/RMC2/scan", scan_rmc2, qos_profile_sensor_data ) node.create_subscription( Float32, "/RMC2/odrive_voltage", battery_rmc2, 10 ) def cell_to_world(cell): x = cell % GRID_SIZE y = cell // GRID_SIZE return x, y def direction_angle(direction): if direction == "w": return 0.0 if direction == "a": return math.pi / 2 if direction == "s": return math.pi if direction == "d": return -math.pi / 2 def robot_world_position(name): start_cell = robots[name]["start_cell"] direction = robots[name]["direction"] start_x, start_y = cell_to_world(start_cell) odom_x = robots[name]["x"] odom_y = robots[name]["y"] angle = direction_angle(direction) dx = ( odom_x * math.cos(angle) - odom_y * math.sin(angle) ) dy = ( odom_x * math.sin(angle) + odom_y * math.cos(angle) ) world_x = start_x + dx world_y = start_y + dy return world_x, world_y def robot_world_yaw(name): yaw = robots[name]["yaw"] direction = robots[name]["direction"] return yaw + direction_angle(direction) def world_to_canvas(x, y): canvas_x = ( MAP_OFFSET + CELL_SIZE * 4.5 - y * CELL_SIZE ) canvas_y = ( MAP_OFFSET + CELL_SIZE * 4.5 - x * CELL_SIZE ) return canvas_x, canvas_y def draw_robot(name): x, y = robot_world_position(name) yaw = robot_world_yaw(name) canvas_x, canvas_y = world_to_canvas(x, y) radius = 15 canvas.coords( robot_body, canvas_x - radius, canvas_y - radius, canvas_x + radius, canvas_y + radius ) arrow_length = 35 arrow_x = ( canvas_x - arrow_length * math.sin(yaw) ) arrow_y = ( canvas_y - arrow_length * math.cos(yaw) ) canvas.coords( robot_arrow, canvas_x, canvas_y, arrow_x, arrow_y ) def draw_lidar(name): canvas.delete("lidar") scan = robots[name]["scan"] if scan is None: return robot_x, robot_y = robot_world_position(name) robot_yaw = robot_world_yaw(name) for i in range(0, len(scan.ranges), 2): distance = scan.ranges[i] if not math.isfinite(distance): continue if distance < scan.range_min: continue if distance > scan.range_max: continue angle = ( scan.angle_min + i * scan.angle_increment ) local_x = distance * math.cos(angle) local_y = distance * 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) ) canvas_x, canvas_y = world_to_canvas( world_x, world_y ) radius = 2 canvas.create_oval( canvas_x - radius, canvas_y - radius, canvas_x + radius, canvas_y + radius, fill="red", outline="red", tags="lidar" ) def update_gui(): name = selected_robot.get() x = robots[name]["x"] y = robots[name]["y"] yaw = robots[name]["yaw"] start_cell = robots[name]["start_cell"] direction = robots[name]["direction"].upper() yaw_degrees = math.degrees(yaw) position_label.config( text=( f"{name}\n\n" f"Стартовая клетка: {start_cell}\n" f"Ориентация: {direction}\n\n" f"Одометрия:\n" f"x = {x:.2f} м\n" f"y = {y:.2f} м\n" f"yaw = {yaw_degrees:.1f}°" ) ) battery = robots[name]["battery"] if battery is None: battery_label.config( text="Батарея: нет данных" ) else: battery_label.config( text=f"Батарея: {battery:.2f} V" ) draw_lidar(name) draw_robot(name) root.after(100, update_gui) def ros_spin(): rclpy.spin_once( node, timeout_sec=0 ) root.after( 10, ros_spin ) def close_program(): node.destroy_node() rclpy.shutdown() root.destroy() root = tk.Tk() root.title("FMS — визуализация РМК") root.geometry("900x700") canvas = tk.Canvas( root, width=600, height=600, bg="white" ) canvas.pack( side="left", padx=20, pady=20 ) right_panel = tk.Frame(root) right_panel.pack( side="left", padx=20, pady=20, anchor="n" ) tk.Label( right_panel, text="Выберите РМК:", font=("Arial", 14) ).pack( anchor="w", pady=(20, 5) ) selected_robot = tk.StringVar( value="РМК-2" ) robot_menu = tk.OptionMenu( right_panel, selected_robot, "РМК-1", "РМК-2" ) robot_menu.config( font=("Arial", 14), width=10 ) robot_menu.pack( anchor="w", pady=5 ) position_label = tk.Label( right_panel, text="Ожидание одометрии...", font=("Arial", 14), justify="left" ) position_label.pack( anchor="w", pady=30 ) battery_label = tk.Label( right_panel, text="Батарея: нет данных", font=("Arial", 14) ) battery_label.pack( anchor="w", pady=10 ) for row in range(GRID_SIZE): for column in range(GRID_SIZE): x1 = MAP_OFFSET + column * CELL_SIZE y1 = MAP_OFFSET + row * CELL_SIZE x2 = x1 + CELL_SIZE y2 = y1 + CELL_SIZE canvas.create_rectangle( x1, y1, x2, y2, outline="gray" ) cell = ( (GRID_SIZE - 1 - column) * GRID_SIZE + (GRID_SIZE - 1 - row) ) canvas.create_text( x1 + 15, y1 + 15, text=str(cell), fill="gray" ) robot_body = canvas.create_oval( 0, 0, 0, 0, fill="blue" ) robot_arrow = canvas.create_line( 0, 0, 0, 0, width=4, fill="blue", arrow=tk.LAST ) root.protocol( "WM_DELETE_WINDOW", close_program ) ros_spin() update_gui() root.mainloop()
Sencis
16.09.2026 07:05Ок, я python не знаю( и никогда на нем ничего не кодил, только на C/C++. Робот похоже на самодельных серводвижках с as5600 не на китайских сервах, что хорошо значит по координатам будет работать плавно если грамотно всё сделать.
Sencis
Вот скажите Владимир, этот код который вы выложили, вы хотя-бы в симуляторе проверили? Или на вашем самодельном манипуляторе? Если да, почему не добавить в статью gif анимации/видео как оно работает. Если нет и это просто сгенерированный LLM код, оторванный от реальности не выкладывайте его, LLM сочинят всякий бред который зачастую выглядит очень правдоподобно.
wowa144 Автор
Добрый день!
Тестировали в симуляторе, работает. Немного позже приложу доказательство. На самом деле код очень простой и это скорее начало пути для управления манипулятором, в будущих статьях покажем работу и в симуляторе, и на реальном прототипе в теплице. Решение разработано несколько месяцев назад, только сейчас руки доходят начинать описывать.
Sencis
Хорошо, хотелось-бы видеть больше тестов на практике, особенно на настоящем манипуляторе.