init
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
reports/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pt
|
||||||
|
!module3/models/latest.pt
|
||||||
|
module3/runs/
|
||||||
|
module3/predictions/
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# mvch — подготовка к ЧВТ-2026
|
||||||
|
|
||||||
|
Репозиторий для самостоятельного изучения и тренировок по модулям ЧВТ.
|
||||||
|
|
||||||
|
- `module2/module_b.py` — модуль **Б**, навигация РМК-2 по ArUco.
|
||||||
|
- `module3/` — модуль **В**, датасет и распознавание инструментов.
|
||||||
|
- `module4/` — модуль **Г**, веб-FMS для двух роверов на React/Bun.
|
||||||
|
- `module5/` — модуль **Д**, единый сценарий RMC1 + RMC2.
|
||||||
|
|
||||||
|
Все актуальные модули находятся в `main`. Ветки `module2`–`module5` сохранены
|
||||||
|
как промежуточные этапы разработки.
|
||||||
|
|
||||||
|
## Запуск в Ubuntu VM
|
||||||
|
|
||||||
|
Нужен установленный официальный симулятор, ROS 2 Jazzy, `rclpy`, `tf2_ros`.
|
||||||
|
Дополнительные pip-пакеты и сборка colcon самого решения не нужны.
|
||||||
|
|
||||||
|
Терминал 1 — штатный симулятор (один экземпляр):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
ros2 launch ar_webots_fms_ros2 module2.launch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Терминал 2 — решение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/mvch
|
||||||
|
git pull --ff-only
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
python3 module2/module_b.py --target 14 --sim
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Робот должен стоять **центром над известной меткой**. Начальная ориентация любая:
|
||||||
|
она определяется по TF метки. Программа ждёт odometry, lidar, TF и ArUco.
|
||||||
|
2. Программа печатает `ROUTE`. Эксперт фиксирует маршрут; **Enter** разрешает движение.
|
||||||
|
3. После `TARGET_REACHED` робот стоит. Выставьте препятствие штатным инструментом симулятора.
|
||||||
|
4. **Enter** строит и выводит обратный маршрут с учётом видимого препятствия.
|
||||||
|
5. Ещё **Enter** разрешает возврат. Успех — `MISSION_FINISHED`.
|
||||||
|
|
||||||
|
Если за время подтверждения маршрут изменился, программа покажет новый и снова дождётся Enter.
|
||||||
|
Время паузы между этапами не входит в `movement_seconds`.
|
||||||
|
|
||||||
|
Команды в том же терминале: `stop`, `resume`, `return`, `go`, `quit`.
|
||||||
|
Ctrl+C немедленно инициирует остановку и завершение с ненулевым кодом.
|
||||||
|
Для внешнего интерфейса: `/mvch/command` (`std_msgs/String`), `/mvch/emergency_stop`
|
||||||
|
(`std_msgs/Bool`, true фиксирует стоп), `/mvch/status` (JSON в `std_msgs/String`).
|
||||||
|
После потери датчиков программа останавливается и требует `resume`.
|
||||||
|
|
||||||
|
Автоматическая тренировка без подтверждений (только симулятор):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 module2/module_b.py --target 14 --sim --auto --return-delay 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Цель координатами и конечная ориентация, в системе **поля**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 module2/module_b.py --xy -2 2 --target-yaw 1.57 --sim
|
||||||
|
```
|
||||||
|
|
||||||
|
На физическом роботе `--sim` не указывать. Перед переносом обязательно проверить
|
||||||
|
размеры корпуса, тормозной путь, TF, шаг и ориентации графа, датчики и аппаратную
|
||||||
|
аварийную остановку. Испытания в Webots не подтверждают готовность к реальному полю.
|
||||||
|
У штатного Webots-драйвера нет таймаута последней команды: при SIGKILL/обрыве питания
|
||||||
|
процесса программная остановка не гарантируется; остановить симулятор вручную.
|
||||||
|
|
||||||
|
## Как устроен код
|
||||||
|
|
||||||
|
Весь модуль находится в одном файле `module2/module_b.py`: сверху параметры поля и
|
||||||
|
скоростей, затем геометрия и Дейкстра, ниже ROS-узел, безопасность и логи.
|
||||||
|
|
||||||
|
Читайте в таком порядке: `Field.route` → `localize` → `scan_points` → `plan` → `drive` → `tick`.
|
||||||
|
|
||||||
|
Координаты штатного поля: `0=(0,0)`, `6=(-1,0)`, `1=(0,1)`.
|
||||||
|
Поворот `yaw` каждой метки — ориентация её TF-системы относительно поля.
|
||||||
|
Измеренное положение метки в odom сравнивается с её известным положением на поле;
|
||||||
|
так получаем преобразование `map_from_odom`. Между метками движение идёт по одометрии.
|
||||||
|
У каждой промежуточной точки нужна свежая метка и попадание в допуск; «успех по таймауту» отсутствует.
|
||||||
|
|
||||||
|
Лидар переводится через TF с учётом `angle_min`, `angle_increment` и времени скана.
|
||||||
|
У текущего симулятора `laser_merged` повёрнут относительно корпуса на **120°**.
|
||||||
|
Планировщик исключает рёбра, проходящие слишком близко к точкам препятствий.
|
||||||
|
Он не знает ID или место будущего препятствия заранее и сам ничего не спавнит.
|
||||||
|
Если преграда появилась во время движения, робот тормозит, возвращается задним ходом
|
||||||
|
к предыдущему маркеру по своему ребру и публикует новый маршрут `ROUTE_REPLANNED`.
|
||||||
|
Если отступить безопасно нельзя, он стоит и завершает попытку по таймауту.
|
||||||
|
|
||||||
|
Размеры сетки, шаг и известные занятые клетки задаются константами `ROWS`, `COLS`,
|
||||||
|
`MARKER_SPACING`, `BLOCKED_MARKERS` в начале скрипта. Все длины — метры.
|
||||||
|
|
||||||
|
## Проверки и логи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 module2/module_b.py --target 14 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`reports/module_b_*.jsonl` содержат маршрут, движение, ошибки, препятствия,
|
||||||
|
точность попадания по меткам и время. Логи исключены из Git.
|
||||||
|
|
||||||
|
Условия финального КЗ: один запускаемый скрипт решения; маршруты в терминале и файле;
|
||||||
|
явные `movement_start` / `movement_stop`; подтверждение эксперта перед каждым этапом.
|
||||||
|
Фактические баллы определяют эксперты по заезду, а не наличие функций в коде.
|
||||||
|
|
||||||
|
## Обновление VM с хоста
|
||||||
|
|
||||||
|
GitHub: `git@github.com:mavxa/mvch.git` (приватный).
|
||||||
|
Для текущей VM используется локальное зеркало хостового Git без передачи ключей GitHub.
|
||||||
|
На хосте запущен read-only Git daemon только для этого репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git daemon --reuseaddr --export-all --listen=127.0.0.1 --port=9418 \
|
||||||
|
--base-path=/home/mavxa/zed/chvtFinal /home/mavxa/zed/chvtFinal/mvch
|
||||||
|
```
|
||||||
|
|
||||||
|
В VM с QEMU user networking хост доступен как `10.0.2.2`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --branch module2 git://10.0.2.2:9418/mvch ~/mvch
|
||||||
|
cd ~/mvch
|
||||||
|
git pull --ff-only
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH VM: `ssh -p 2222 -o HostKeyAlias=chvt-3735b9fd mavxa@127.0.0.1`.
|
||||||
|
Проброс действует до выключения VM. Восстановить на хосте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh -c qemu:///system qemu-monitor-command ubuntu24.04-chvt \
|
||||||
|
--hmp 'hostfwd_add hostnet0 tcp:127.0.0.1:2222-:22'
|
||||||
|
```
|
||||||
|
|
||||||
|
Пароли и ключи в репозитории не хранятся.
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Модуль Б: один запуск, маршрут туда, пауза эксперта, маршрут обратно.
|
||||||
|
|
||||||
|
python3 module_b.py --target 14 --sim
|
||||||
|
Команды в терминале: Enter / go, stop, resume, return, quit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import heapq
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Все параметры модуля Б находятся здесь. Перед реальным полем меняются в одном месте.
|
||||||
|
ROWS = 6
|
||||||
|
COLS = 6
|
||||||
|
MARKER_SPACING = 1.0
|
||||||
|
BLOCKED_MARKERS = set()
|
||||||
|
|
||||||
|
ROBOT_NAMESPACE = "RMC2"
|
||||||
|
MARKER_PREFIX = "aruco_"
|
||||||
|
# RMC2.proto: край корпуса находится примерно в 0.373 м от base_link.
|
||||||
|
# Четыре сантиметра запаса оставляют проходимым узкий коридор у marker 33.
|
||||||
|
ROBOT_HALF_LENGTH = 0.375
|
||||||
|
ROBOT_HALF_WIDTH = 0.375
|
||||||
|
SAFETY_MARGIN = 0.04
|
||||||
|
ROUTE_CLEARANCE = 0.44
|
||||||
|
|
||||||
|
MAX_LINEAR = 0.25
|
||||||
|
MAX_ANGULAR = 0.45
|
||||||
|
LINEAR_ACCEL = 0.35
|
||||||
|
ANGULAR_ACCEL = 0.80
|
||||||
|
POSITION_TOLERANCE = 0.025
|
||||||
|
YAW_TOLERANCE = 0.045
|
||||||
|
MOVE_YAW_TOLERANCE = 0.14
|
||||||
|
SENSOR_TIMEOUT = 1.5
|
||||||
|
MARKER_TIMEOUT = 0.8
|
||||||
|
WAYPOINT_TIMEOUT = 50.0
|
||||||
|
MISSION_TIMEOUT = 330.0
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(angle):
|
||||||
|
return math.atan2(math.sin(angle), math.cos(angle))
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, low, high):
|
||||||
|
return max(low, min(high, value))
|
||||||
|
|
||||||
|
|
||||||
|
def compose(a, b):
|
||||||
|
"""Переносит позу b из системы a в её родительскую систему."""
|
||||||
|
x, y, yaw = a
|
||||||
|
c, s = math.cos(yaw), math.sin(yaw)
|
||||||
|
return x + c*b[0] - s*b[1], y + s*b[0] + c*b[1], wrap(yaw+b[2])
|
||||||
|
|
||||||
|
|
||||||
|
def inverse(pose):
|
||||||
|
x, y, yaw = pose
|
||||||
|
c, s = math.cos(yaw), math.sin(yaw)
|
||||||
|
return -c*x-s*y, s*x-c*y, -yaw
|
||||||
|
|
||||||
|
|
||||||
|
def distance(a, b):
|
||||||
|
return math.hypot(a[0]-b[0], a[1]-b[1])
|
||||||
|
|
||||||
|
|
||||||
|
def segment_distance(point, a, b):
|
||||||
|
dx, dy = b[0]-a[0], b[1]-a[1]
|
||||||
|
length2 = dx*dx+dy*dy
|
||||||
|
if length2 == 0:
|
||||||
|
return distance(point, a)
|
||||||
|
t = clamp(((point[0]-a[0])*dx+(point[1]-a[1])*dy)/length2, 0, 1)
|
||||||
|
return distance(point, (a[0]+t*dx, a[1]+t*dy))
|
||||||
|
|
||||||
|
|
||||||
|
class Field:
|
||||||
|
"""Регулярная сетка ArUco и кратчайший путь по ней."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.poses = {}
|
||||||
|
self.graph = {}
|
||||||
|
for row in range(ROWS):
|
||||||
|
for col in range(COLS):
|
||||||
|
marker = row*COLS+col
|
||||||
|
self.poses[marker] = (-row*MARKER_SPACING, col*MARKER_SPACING, 0.0)
|
||||||
|
self.graph[marker] = [
|
||||||
|
r*COLS+c for r, c in
|
||||||
|
((row+1, col), (row, col+1), (row-1, col), (row, col-1))
|
||||||
|
if 0 <= r < ROWS and 0 <= c < COLS
|
||||||
|
]
|
||||||
|
|
||||||
|
def nearest(self, x, y):
|
||||||
|
return min(self.poses, key=lambda marker: distance((x, y), self.poses[marker]))
|
||||||
|
|
||||||
|
def route(self, start, goal, obstacle_points=(), forbidden_edges=()):
|
||||||
|
"""Дейкстра: кратчайший по длине свободный путь."""
|
||||||
|
if start not in self.poses or goal not in self.poses:
|
||||||
|
raise ValueError("ID старта или цели отсутствует в графе")
|
||||||
|
if start in BLOCKED_MARKERS or goal in BLOCKED_MARKERS:
|
||||||
|
raise ValueError("Старт или цель заблокированы")
|
||||||
|
|
||||||
|
forbidden = {frozenset(edge) for edge in forbidden_edges}
|
||||||
|
costs = {start: 0.0}
|
||||||
|
parents = {start: None}
|
||||||
|
queue_path = [(0.0, start)]
|
||||||
|
while queue_path:
|
||||||
|
cost, current = heapq.heappop(queue_path)
|
||||||
|
if cost > costs[current]:
|
||||||
|
continue
|
||||||
|
if current == goal:
|
||||||
|
path = []
|
||||||
|
while current is not None:
|
||||||
|
path.append(current)
|
||||||
|
current = parents[current]
|
||||||
|
return path[::-1]
|
||||||
|
for neighbor in self.graph[current]:
|
||||||
|
edge = frozenset((current, neighbor))
|
||||||
|
if neighbor in BLOCKED_MARKERS or edge in forbidden:
|
||||||
|
continue
|
||||||
|
a, b = self.poses[current], self.poses[neighbor]
|
||||||
|
if any(segment_distance(p, a, b) < ROUTE_CLEARANCE for p in obstacle_points):
|
||||||
|
continue
|
||||||
|
new_cost = cost+distance(a, b)
|
||||||
|
if new_cost < costs.get(neighbor, math.inf):
|
||||||
|
costs[neighbor] = new_cost
|
||||||
|
parents[neighbor] = current
|
||||||
|
heapq.heappush(queue_path, (new_cost, neighbor))
|
||||||
|
raise ValueError(f"Нет безопасного маршрута {start} -> {goal}")
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
target = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
target.add_argument("--target", type=int, help="ID целевой метки")
|
||||||
|
target.add_argument("--xy", nargs=2, type=float, help="Координаты цели на карте, метры")
|
||||||
|
parser.add_argument("--sim", action="store_true", help="Часы Webots /clock")
|
||||||
|
parser.add_argument("--target-yaw", type=float, help="Ориентация на цели в системе поля, рад")
|
||||||
|
parser.add_argument("--auto", action="store_true", help="Только тренировка: без команд эксперта")
|
||||||
|
parser.add_argument("--return-delay", type=float, default=5.0, help="Пауза --auto, секунд симуляции")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Показать путь без ROS и без движения")
|
||||||
|
parser.add_argument("--start", type=int, default=0, help="Старт только для --dry-run")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.auto and not args.sim:
|
||||||
|
parser.error("--auto разрешён только с --sim")
|
||||||
|
if args.return_delay < 0 or not math.isfinite(args.return_delay):
|
||||||
|
parser.error("--return-delay должен быть конечным неотрицательным числом")
|
||||||
|
if args.target_yaw is not None and not math.isfinite(args.target_yaw):
|
||||||
|
parser.error("--target-yaw должен быть конечным числом")
|
||||||
|
if args.xy and not all(math.isfinite(v) for v in args.xy):
|
||||||
|
parser.error("--xy должен содержать конечные числа")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
field = Field()
|
||||||
|
target = args.target if args.target is not None else field.nearest(*args.xy)
|
||||||
|
if target not in field.poses or target in BLOCKED_MARKERS:
|
||||||
|
raise SystemExit("Целевая метка отсутствует в графе или заблокирована")
|
||||||
|
if args.dry_run:
|
||||||
|
print("ROUTE", field.route(args.start, target), "TARGET", field.poses[target])
|
||||||
|
return
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from geometry_msgs.msg import Twist
|
||||||
|
from nav_msgs.msg import Odometry
|
||||||
|
from rclpy.clock import Clock, ClockType
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from rclpy.signals import SignalHandlerOptions
|
||||||
|
from rclpy.time import Time
|
||||||
|
from sensor_msgs.msg import LaserScan
|
||||||
|
from std_msgs.msg import Bool, String
|
||||||
|
from tf2_ros import Buffer, TransformListener, TransformException
|
||||||
|
|
||||||
|
def yaw(q):
|
||||||
|
return math.atan2(2*(q.w*q.z+q.x*q.y), 1-2*(q.y*q.y+q.z*q.z))
|
||||||
|
|
||||||
|
def tf_pose(transform):
|
||||||
|
t = transform.transform
|
||||||
|
return t.translation.x, t.translation.y, yaw(t.rotation)
|
||||||
|
|
||||||
|
class Mission(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("mvch_module_b")
|
||||||
|
self.set_parameters([rclpy.parameter.Parameter("use_sim_time", value=args.sim)])
|
||||||
|
self.cfg = {
|
||||||
|
"max_linear": MAX_LINEAR, "max_angular": MAX_ANGULAR,
|
||||||
|
"linear_accel": LINEAR_ACCEL, "angular_accel": ANGULAR_ACCEL,
|
||||||
|
"position_tolerance": POSITION_TOLERANCE,
|
||||||
|
"yaw_tolerance": YAW_TOLERANCE,
|
||||||
|
"move_yaw_tolerance": MOVE_YAW_TOLERANCE,
|
||||||
|
"sensor_timeout": SENSOR_TIMEOUT, "marker_timeout": MARKER_TIMEOUT,
|
||||||
|
"waypoint_timeout": WAYPOINT_TIMEOUT, "mission_timeout": MISSION_TIMEOUT,
|
||||||
|
}
|
||||||
|
self.robot = {
|
||||||
|
"namespace": ROBOT_NAMESPACE, "marker_prefix": MARKER_PREFIX,
|
||||||
|
"clearance": ROUTE_CLEARANCE, "half_length": ROBOT_HALF_LENGTH,
|
||||||
|
"half_width": ROBOT_HALF_WIDTH, "safety_margin": SAFETY_MARGIN,
|
||||||
|
}
|
||||||
|
ns = ROBOT_NAMESPACE.strip("/")
|
||||||
|
self.base, self.odom_frame = f"{ns}/base_link", f"{ns}/odom"
|
||||||
|
self.buffer = Buffer()
|
||||||
|
self.listener = TransformListener(self.buffer, self)
|
||||||
|
self.pub = self.create_publisher(Twist, f"/{ns}/cmd_vel", 1)
|
||||||
|
self.status_pub = self.create_publisher(String, "/mvch/status", 10)
|
||||||
|
self.create_subscription(Odometry, f"/{ns}/odometry", self.on_odom, qos_profile_sensor_data)
|
||||||
|
self.create_subscription(LaserScan, f"/{ns}/scan", self.on_scan, qos_profile_sensor_data)
|
||||||
|
self.create_subscription(String, f"/{ns}/aruco_id", self.on_marker, 10)
|
||||||
|
self.create_subscription(Bool, "/mvch/emergency_stop", self.on_stop, 10)
|
||||||
|
self.create_subscription(String, "/mvch/command", lambda m: self.commands.put(m.data), 10)
|
||||||
|
self.commands = queue.Queue()
|
||||||
|
self.state, self.leg = "WAIT_SENSORS", "outbound"
|
||||||
|
self.odom = self.map_from_odom = self.pose = None
|
||||||
|
self.odom_wall = self.scan_wall = 0.0
|
||||||
|
self.scan = None
|
||||||
|
self.points, self.body_points = [], []
|
||||||
|
self.lidar_tf_wall = 0.0
|
||||||
|
self.lidar_tf_error = "TF лидара ещё не получен"
|
||||||
|
self.marker = None
|
||||||
|
self.marker_wall = self.marker_stamp = 0.0
|
||||||
|
self.marker_distance = math.inf
|
||||||
|
self.start = self.current = None
|
||||||
|
self.start_yaw = 0.0
|
||||||
|
self.goal, self.route, self.index = target, [], 0
|
||||||
|
self.forbidden = set()
|
||||||
|
self.paused = False
|
||||||
|
self.terminal = False
|
||||||
|
self.failed = False
|
||||||
|
self.v = self.w = 0.0
|
||||||
|
self.last_sim = self.now()
|
||||||
|
self.last_wall = self.clock_wall = time.monotonic()
|
||||||
|
self.last_clock = self.last_sim
|
||||||
|
self.leg_started = self.waypoint_started = self.last_sim
|
||||||
|
self.total_time = 0.0
|
||||||
|
self.last_status = 0.0
|
||||||
|
self.wait_reason = ""
|
||||||
|
log_dir = Path(__file__).parent / "reports"
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
self.log_path = log_dir / f"module_b_{datetime.now():%Y%m%d_%H%M%S_%f}.jsonl"
|
||||||
|
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
||||||
|
# Таймер на монотонных часах: остановка сработает даже при паузе /clock.
|
||||||
|
self.timer = self.create_timer(0.05, self.tick, clock=Clock(clock_type=ClockType.STEADY_TIME))
|
||||||
|
self.log("READY", target=target, config="constants in module_b.py", sim=args.sim,
|
||||||
|
instructions="Enter/go: старт; return: построить возврат; stop/resume; quit")
|
||||||
|
|
||||||
|
def now(self):
|
||||||
|
return self.get_clock().now().nanoseconds / 1e9
|
||||||
|
|
||||||
|
def log(self, event, **data):
|
||||||
|
item = dict(time=datetime.now().isoformat(timespec="milliseconds"),
|
||||||
|
sim_time=round(self.now(), 3), event=event, leg=self.leg, **data)
|
||||||
|
line = json.dumps(item, ensure_ascii=False)
|
||||||
|
print(line, flush=True)
|
||||||
|
self.log_file.write(line+"\n")
|
||||||
|
|
||||||
|
def on_odom(self, msg):
|
||||||
|
p = msg.pose.pose
|
||||||
|
pose = p.position.x, p.position.y, yaw(p.orientation)
|
||||||
|
if all(math.isfinite(v) for v in pose):
|
||||||
|
self.odom, self.odom_wall = pose, time.monotonic()
|
||||||
|
|
||||||
|
def on_scan(self, msg):
|
||||||
|
# Пустой/повреждённый скан не считается свободным пространством.
|
||||||
|
if not msg.ranges or msg.angle_increment == 0:
|
||||||
|
return
|
||||||
|
if not any(math.isfinite(r) and msg.range_min <= r <= msg.range_max for r in msg.ranges):
|
||||||
|
return
|
||||||
|
self.scan, self.scan_wall = msg, time.monotonic()
|
||||||
|
|
||||||
|
def on_marker(self, msg):
|
||||||
|
match = re.fullmatch(re.escape(MARKER_PREFIX)+r"(\d+)", msg.data.strip())
|
||||||
|
if not match and msg.data.strip().isdigit():
|
||||||
|
marker = int(msg.data.strip())
|
||||||
|
elif match:
|
||||||
|
marker = int(match[1])
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
if marker in field.poses:
|
||||||
|
self.marker, self.marker_wall = marker, time.monotonic()
|
||||||
|
|
||||||
|
def on_stop(self, msg):
|
||||||
|
if msg.data:
|
||||||
|
self.paused = True
|
||||||
|
self.stop()
|
||||||
|
self.log("EMERGENCY_STOP")
|
||||||
|
# false сам по себе не разрешает движение: требуется resume.
|
||||||
|
|
||||||
|
def localize(self):
|
||||||
|
if self.odom is None or self.marker is None:
|
||||||
|
return
|
||||||
|
frame = MARKER_PREFIX+str(self.marker)
|
||||||
|
try:
|
||||||
|
transform = self.buffer.lookup_transform(self.odom_frame, frame, Time())
|
||||||
|
stamp = transform.header.stamp.sec+transform.header.stamp.nanosec/1e9
|
||||||
|
if not 0 <= self.now()-stamp < self.cfg["marker_timeout"]:
|
||||||
|
return
|
||||||
|
if time.monotonic()-self.marker_wall > self.cfg["sensor_timeout"]:
|
||||||
|
return
|
||||||
|
odom_marker = tf_pose(transform)
|
||||||
|
self.marker_distance = distance(odom_marker, self.odom)
|
||||||
|
if stamp <= self.marker_stamp:
|
||||||
|
return
|
||||||
|
correction = compose(field.poses[self.marker], inverse(odom_marker))
|
||||||
|
if self.map_from_odom is not None:
|
||||||
|
old_pose = compose(self.map_from_odom, self.odom)
|
||||||
|
new_pose = compose(correction, self.odom)
|
||||||
|
if distance(old_pose, new_pose) > 0.4 or abs(wrap(old_pose[2]-new_pose[2])) > 0.6:
|
||||||
|
return # Не принимаем скачок распознавания; таймаут остановит у метки.
|
||||||
|
self.map_from_odom, self.marker_stamp = correction, stamp
|
||||||
|
except TransformException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def scan_points(self):
|
||||||
|
scan = self.scan
|
||||||
|
try:
|
||||||
|
# В этом симуляторе laser_merged повёрнут на 120 градусов!
|
||||||
|
mount = tf_pose(self.buffer.lookup_transform(self.base, scan.header.frame_id, Time()))
|
||||||
|
# Последний TF достаточно свежий: scan проверяется отдельно по wall-clock.
|
||||||
|
# Запрос строго по stamp иногда попадает между двумя odom TF и кратко падает.
|
||||||
|
at_scan = tf_pose(self.buffer.lookup_transform(self.odom_frame, self.base, Time()))
|
||||||
|
except TransformException as error:
|
||||||
|
self.lidar_tf_error = str(error)
|
||||||
|
return False
|
||||||
|
scan_in_map = compose(compose(self.map_from_odom, at_scan), mount)
|
||||||
|
body_from_map = inverse(self.pose)
|
||||||
|
points = {}
|
||||||
|
for i, r in enumerate(scan.ranges):
|
||||||
|
if not math.isfinite(r) or not scan.range_min <= r <= scan.range_max:
|
||||||
|
continue
|
||||||
|
angle = scan.angle_min+i*scan.angle_increment
|
||||||
|
p = compose(scan_in_map, (r*math.cos(angle), r*math.sin(angle), 0))[:2]
|
||||||
|
b = compose(body_from_map, (*p, 0))
|
||||||
|
if abs(b[0]) < self.robot["half_length"] and abs(b[1]) < self.robot["half_width"]:
|
||||||
|
continue # собственный корпус, не внешнее препятствие
|
||||||
|
points[(round(p[0]/0.04), round(p[1]/0.04))] = p
|
||||||
|
self.points = list(points.values())
|
||||||
|
self.body_points = [compose(body_from_map, (*p, 0)) for p in self.points]
|
||||||
|
self.lidar_tf_wall = time.monotonic()
|
||||||
|
self.lidar_tf_error = ""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def plan(self):
|
||||||
|
self.stop()
|
||||||
|
try:
|
||||||
|
self.route = field.route(
|
||||||
|
self.current, self.goal, self.points, self.forbidden
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
self.abort(str(error))
|
||||||
|
return
|
||||||
|
self.index = 1
|
||||||
|
self.state = "WAIT_GO"
|
||||||
|
direction = "К ЦЕЛИ" if self.leg == "outbound" else "НА СТАРТ"
|
||||||
|
print(f"\nМАРШРУТ {direction}: {' -> '.join(map(str, self.route))}", flush=True)
|
||||||
|
print("Ровер пока стоит. Эксперт проверяет маршрут; Enter/go разрешает движение.\n",
|
||||||
|
flush=True)
|
||||||
|
self.log("ROUTE", markers=self.route, goal=self.goal)
|
||||||
|
self.log("WAIT_GO", message="Эксперт фиксирует маршрут. Enter/go разрешает движение.")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.v = self.w = 0.0
|
||||||
|
self.pub.publish(Twist())
|
||||||
|
|
||||||
|
def abort(self, reason):
|
||||||
|
self.stop()
|
||||||
|
self.failed = self.terminal = True
|
||||||
|
self.state = "FAILED"
|
||||||
|
self.log("FAILED", reason=reason, current=self.current)
|
||||||
|
|
||||||
|
def command(self, command):
|
||||||
|
command = command.strip().lower()
|
||||||
|
if command in ("stop", "s"):
|
||||||
|
self.paused = True
|
||||||
|
self.stop()
|
||||||
|
self.log("EMERGENCY_STOP")
|
||||||
|
elif command == "resume":
|
||||||
|
self.paused = False
|
||||||
|
self.log("RESUME")
|
||||||
|
elif command in ("quit", "q"):
|
||||||
|
self.abort("Остановлено оператором")
|
||||||
|
elif self.state == "WAIT_RETURN" and command in ("", "return", "go"):
|
||||||
|
self.leg, self.goal = "return", self.start
|
||||||
|
self.forbidden.clear()
|
||||||
|
self.plan()
|
||||||
|
elif self.state == "WAIT_GO" and command in ("", "go") and not self.paused:
|
||||||
|
# Новая преграда за время паузы: сначала показать новый маршрут.
|
||||||
|
try:
|
||||||
|
updated = field.route(
|
||||||
|
self.current, self.goal, self.points, self.forbidden
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
self.abort(str(error))
|
||||||
|
return
|
||||||
|
if updated != self.route:
|
||||||
|
self.plan()
|
||||||
|
return
|
||||||
|
self.state = "DRIVE"
|
||||||
|
self.leg_started = self.waypoint_started = self.now()
|
||||||
|
self.log("movement_start", markers=self.route)
|
||||||
|
|
||||||
|
def blocked_motion(self, v, w):
|
||||||
|
margin = self.robot["safety_margin"]
|
||||||
|
# Тормозной путь + время реакции; размеры считаются от центра корпуса.
|
||||||
|
braking = v*v/(2*self.cfg["linear_accel"])+abs(v)*0.25
|
||||||
|
front = self.robot["half_length"]+margin+braking
|
||||||
|
width = self.robot["half_width"]+margin
|
||||||
|
if abs(v) > 0.001:
|
||||||
|
direction = 1 if v > 0 else -1
|
||||||
|
return any(0 < direction*x < front and abs(y) < width for x, y, _ in self.body_points)
|
||||||
|
if abs(w) > 0.001:
|
||||||
|
return any(math.hypot(x, y) < self.robot["clearance"] for x, y, _ in self.body_points)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send(self, v, w, dt):
|
||||||
|
if self.blocked_motion(v, w):
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
self.v += clamp(v-self.v, -self.cfg["linear_accel"]*dt, self.cfg["linear_accel"]*dt)
|
||||||
|
self.w += clamp(w-self.w, -self.cfg["angular_accel"]*dt, self.cfg["angular_accel"]*dt)
|
||||||
|
# Поворот на месте и аварийная остановка не должны оставлять линейный хвост.
|
||||||
|
if v == 0:
|
||||||
|
self.v = 0.0
|
||||||
|
if self.blocked_motion(self.v, self.w):
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
msg = Twist()
|
||||||
|
msg.linear.x, msg.angular.z = float(self.v), float(self.w)
|
||||||
|
self.pub.publish(msg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def drive(self, dt):
|
||||||
|
if self.now()-self.waypoint_started > self.cfg["waypoint_timeout"]:
|
||||||
|
self.abort("Таймаут точки: проверить камеру, препятствие и положение робота")
|
||||||
|
return
|
||||||
|
if self.state == "ALIGN":
|
||||||
|
desired = args.target_yaw if self.leg == "outbound" else self.start_yaw
|
||||||
|
error = wrap(desired-self.pose[2])
|
||||||
|
if abs(error) <= self.cfg["yaw_tolerance"]:
|
||||||
|
self.arrived()
|
||||||
|
else:
|
||||||
|
self.send(0, clamp(1.4*error, -self.cfg["max_angular"], self.cfg["max_angular"]), dt)
|
||||||
|
return
|
||||||
|
if self.index >= len(self.route) and self.state != "BACKTRACK":
|
||||||
|
if self.leg == "return" or args.target_yaw is not None:
|
||||||
|
self.state = "ALIGN"
|
||||||
|
else:
|
||||||
|
self.arrived()
|
||||||
|
return
|
||||||
|
marker = self.current if self.state == "BACKTRACK" else self.route[self.index]
|
||||||
|
target_pose = field.poses[marker]
|
||||||
|
dx, dy = target_pose[0]-self.pose[0], target_pose[1]-self.pose[1]
|
||||||
|
d = math.hypot(dx, dy)
|
||||||
|
fresh = self.marker == marker and 0 <= self.now()-self.marker_stamp < self.cfg["marker_timeout"]
|
||||||
|
if d < self.cfg["position_tolerance"] and fresh and self.marker_distance < 0.04:
|
||||||
|
self.stop()
|
||||||
|
if self.state == "BACKTRACK":
|
||||||
|
self.log("BACKTRACK_DONE", marker=marker)
|
||||||
|
try:
|
||||||
|
self.route = field.route(
|
||||||
|
self.current, self.goal, self.points, self.forbidden
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
self.abort(str(error))
|
||||||
|
return
|
||||||
|
self.index, self.state = 1, "DRIVE"
|
||||||
|
self.log("ROUTE_REPLANNED", markers=self.route)
|
||||||
|
else:
|
||||||
|
self.current = marker
|
||||||
|
self.index += 1
|
||||||
|
self.log("WAYPOINT", marker=marker, pose=list(self.pose), marker_error=self.marker_distance)
|
||||||
|
self.waypoint_started = self.now()
|
||||||
|
return
|
||||||
|
if d < self.cfg["position_tolerance"] and not fresh:
|
||||||
|
self.stop() # Без свежей метки достижение НЕ засчитываем.
|
||||||
|
return
|
||||||
|
error = wrap(math.atan2(dy, dx)-self.pose[2])
|
||||||
|
# Если следующая метка сзади, едем к ней задним ходом. Это не даёт
|
||||||
|
# роботу разворачиваться корпусом вплотную к стеллажам у marker 33.
|
||||||
|
reverse = self.state == "BACKTRACK" or abs(error) > math.pi/2
|
||||||
|
if reverse:
|
||||||
|
error = wrap(error-math.pi)
|
||||||
|
v = min(self.cfg["max_linear"], 0.8*d)
|
||||||
|
if reverse:
|
||||||
|
v = -min(v, 0.12)
|
||||||
|
if abs(error) > self.cfg["move_yaw_tolerance"]:
|
||||||
|
v = 0
|
||||||
|
w = clamp(1.5*error, -self.cfg["max_angular"], self.cfg["max_angular"])
|
||||||
|
if not self.send(v, w, dt) and self.state == "DRIVE":
|
||||||
|
edge = (self.current, marker)
|
||||||
|
self.forbidden.add(edge)
|
||||||
|
self.log("OBSTACLE", edge=edge, pose=list(self.pose))
|
||||||
|
# Вернуться по уже пройденному ребру, затем ехать по новому графу.
|
||||||
|
self.state = "BACKTRACK"
|
||||||
|
self.waypoint_started = self.now()
|
||||||
|
self.log("BACKTRACK", marker=self.current)
|
||||||
|
|
||||||
|
def arrived(self):
|
||||||
|
self.stop()
|
||||||
|
elapsed = self.now()-self.leg_started
|
||||||
|
self.total_time += elapsed
|
||||||
|
self.log("movement_stop", marker=self.goal, seconds=round(elapsed, 3))
|
||||||
|
if self.leg == "outbound":
|
||||||
|
self.state = "WAIT_RETURN"
|
||||||
|
self.return_wait = self.now()
|
||||||
|
self.log("TARGET_REACHED", marker=self.goal,
|
||||||
|
message="Выставьте препятствие. return/Enter строит обратный маршрут.")
|
||||||
|
else:
|
||||||
|
self.state, self.terminal = "FINISHED", True
|
||||||
|
self.log("MISSION_FINISHED", marker=self.start, movement_seconds=round(self.total_time, 3))
|
||||||
|
|
||||||
|
def tick(self):
|
||||||
|
wall, now = time.monotonic(), self.now()
|
||||||
|
dt = clamp(now-self.last_sim, 0, 0.15)
|
||||||
|
if now < self.last_sim-0.01:
|
||||||
|
self.abort("Часы симуляции сброшены; запустите новую попытку")
|
||||||
|
return
|
||||||
|
if now != self.last_clock:
|
||||||
|
self.clock_wall, self.last_clock = wall, now
|
||||||
|
self.last_sim = now
|
||||||
|
self.localize()
|
||||||
|
if self.map_from_odom is not None and self.odom is not None:
|
||||||
|
self.pose = compose(self.map_from_odom, self.odom)
|
||||||
|
reasons = []
|
||||||
|
if self.pose is None:
|
||||||
|
reasons.append("нет локализации")
|
||||||
|
if self.scan is None:
|
||||||
|
reasons.append("нет scan")
|
||||||
|
if wall-self.odom_wall >= SENSOR_TIMEOUT:
|
||||||
|
reasons.append(f"odometry старше {wall-self.odom_wall:.2f}с")
|
||||||
|
if wall-self.scan_wall >= SENSOR_TIMEOUT:
|
||||||
|
reasons.append(f"scan старше {wall-self.scan_wall:.2f}с")
|
||||||
|
if wall-self.clock_wall >= SENSOR_TIMEOUT:
|
||||||
|
reasons.append(f"clock старше {wall-self.clock_wall:.2f}с")
|
||||||
|
healthy = not reasons
|
||||||
|
if healthy and not self.scan_points():
|
||||||
|
# Один краткий промах TF не должен ставить миссию на вечную паузу.
|
||||||
|
if wall-self.lidar_tf_wall >= SENSOR_TIMEOUT:
|
||||||
|
reasons.append("TF лидара: "+self.lidar_tf_error)
|
||||||
|
healthy = False
|
||||||
|
if not healthy:
|
||||||
|
self.stop()
|
||||||
|
if self.state != "WAIT_SENSORS" and not self.paused:
|
||||||
|
self.paused = True
|
||||||
|
self.log("SENSOR_STOP", reason="; ".join(reasons),
|
||||||
|
message="После восстановления введите resume")
|
||||||
|
while not self.commands.empty():
|
||||||
|
command = self.commands.get_nowait()
|
||||||
|
if healthy or command.strip() in ("stop", "quit", "q", "s"):
|
||||||
|
self.command(command)
|
||||||
|
if not healthy or self.paused or self.terminal:
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
if wall-self.last_status > 1:
|
||||||
|
msg = String()
|
||||||
|
msg.data = json.dumps(dict(state=self.state, leg=self.leg, pose=self.pose,
|
||||||
|
current=self.current, route=self.route))
|
||||||
|
self.status_pub.publish(msg)
|
||||||
|
self.last_status = wall
|
||||||
|
if self.state == "WAIT_SENSORS":
|
||||||
|
if self.marker_distance > 0.04:
|
||||||
|
return
|
||||||
|
self.start = self.current = self.marker
|
||||||
|
self.start_yaw = self.pose[2]
|
||||||
|
self.log("LOCALIZED", marker=self.start, pose=list(self.pose))
|
||||||
|
self.plan()
|
||||||
|
elif self.state == "WAIT_GO":
|
||||||
|
self.stop()
|
||||||
|
if args.auto:
|
||||||
|
self.command("go")
|
||||||
|
elif self.state == "WAIT_RETURN":
|
||||||
|
self.stop()
|
||||||
|
if args.auto and now-self.return_wait >= args.return_delay:
|
||||||
|
self.command("return")
|
||||||
|
else:
|
||||||
|
if self.total_time+now-self.leg_started > self.cfg["mission_timeout"]:
|
||||||
|
self.abort("Превышено время миссии")
|
||||||
|
else:
|
||||||
|
self.drive(dt)
|
||||||
|
|
||||||
|
rclpy.init(signal_handler_options=SignalHandlerOptions.NO)
|
||||||
|
node = Mission()
|
||||||
|
quitting = threading.Event()
|
||||||
|
signal.signal(signal.SIGINT, lambda *_: quitting.set())
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: quitting.set())
|
||||||
|
|
||||||
|
def read_commands():
|
||||||
|
for line in sys.stdin:
|
||||||
|
node.commands.put(line)
|
||||||
|
|
||||||
|
threading.Thread(target=read_commands, daemon=True).start()
|
||||||
|
try:
|
||||||
|
while rclpy.ok() and not quitting.is_set() and not node.terminal:
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.05)
|
||||||
|
if quitting.is_set() and not node.terminal:
|
||||||
|
node.abort("SIGINT/SIGTERM")
|
||||||
|
except Exception as error:
|
||||||
|
node.abort(f"{type(error).__name__}: {error}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Перед закрытием соединения несколько раз отправить настоящий нулевой Twist.
|
||||||
|
for _ in range(5):
|
||||||
|
node.stop()
|
||||||
|
time.sleep(0.04)
|
||||||
|
node.log_file.close()
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
if node.failed:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Модуль В: датасет и YOLO
|
||||||
|
|
||||||
|
Камера: `/RMC1/arm95/camera_gripper/image_color` (`1600x1200`).
|
||||||
|
|
||||||
|
Основной зачетный скрипт: `module3.py`. Он показывает обработанный видеопоток,
|
||||||
|
определяет координаты выбранной детали относительно `Base_link`, захватывает ее,
|
||||||
|
возвращает ARM95 в исходное положение с деталью, кладет деталь обратно на
|
||||||
|
освободившееся место и снова возвращает руку в исходное положение.
|
||||||
|
После первого безопасного подхода координаты повторно уточняются по близкому
|
||||||
|
кадру, поэтому рука не едет к детали только по одному дальнему измерению.
|
||||||
|
Если в кадре несколько деталей одного класса, скрипт выбирает ближайшую к базе.
|
||||||
|
|
||||||
|
## Запуск задания
|
||||||
|
|
||||||
|
В VM один раз создайте окружение с доступом к системным пакетам ROS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/scripts/mvch/module3
|
||||||
|
sudo apt install python3-venv
|
||||||
|
python3 -m venv --system-site-packages .venv
|
||||||
|
.venv/bin/pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
.venv/bin/pip install -r requirements.txt tqdm ultralytics-thop
|
||||||
|
```
|
||||||
|
|
||||||
|
Если окружение уже обновило NumPy до 2.x и `cv_bridge` ругается на ABI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pip install --force-reinstall "numpy<2" "opencv-python<4.12" lark
|
||||||
|
```
|
||||||
|
|
||||||
|
`lark` — зависимость ROS 2 Launch/MoveIt, а не часть алгоритма распознавания.
|
||||||
|
Ограничение `numpy<2` нужно потому, что бинарный `cv_bridge` в этом образе ROS
|
||||||
|
собран с NumPy 1.x.
|
||||||
|
|
||||||
|
Если venv уже был создан без `--system-site-packages`, пересоздавать его не
|
||||||
|
нужно. Включите системные зависимости ROS и заново активируйте окружение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's/include-system-site-packages = false/include-system-site-packages = true/' .venv/pyvenv.cfg
|
||||||
|
deactivate
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Запустите симулятор модуля В, затем в новом терминале:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
cd ~/scripts/mvch
|
||||||
|
module3/.venv/bin/python module3/module3.py --target 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Цели: `1/hammer`, `2/wrench`, `3/pliers`. После того как эксперт зафиксировал
|
||||||
|
видеопоток, нажмите `G` или `Space` в окне либо введите `g` и
|
||||||
|
нажмите Enter в терминале. `Esc` останавливает скрипт до начала движения.
|
||||||
|
|
||||||
|
Сначала обязательно проверьте только координаты:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
module3/.venv/bin/python module3/module3.py --target hammer --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
В режиме `--dry-run` подтверждать запуск клавишей не нужно: скрипт завершится
|
||||||
|
после первой валидной детекции и вывода рассчитанных координат.
|
||||||
|
|
||||||
|
Для проверки через SSH без окна:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
module3/.venv/bin/python module3/module3.py --target hammer --dry-run --no-window
|
||||||
|
```
|
||||||
|
|
||||||
|
Геометрию реального стенда нельзя брать из симулятора вслепую. Перед зачетом
|
||||||
|
измерьте и подстройте `--plane-z`, `--pick-z` и `--approach-z`. По умолчанию
|
||||||
|
деталь возвращается туда, откуда была взята; другую свободную точку можно задать
|
||||||
|
через `--drop-x X --drop-y Y`.
|
||||||
|
|
||||||
|
События распознавания, координаты, построение движений и состояния схвата
|
||||||
|
одновременно выводятся в терминал и сохраняются в `module3/logs/*.log`.
|
||||||
|
Туда же пишутся контрольные кадры `*_gripped.jpg`, `*_with_detail.jpg` и
|
||||||
|
`*_finished.jpg`.
|
||||||
|
|
||||||
|
## Что размечать
|
||||||
|
|
||||||
|
Классы строго в таком написании:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hammer
|
||||||
|
pliers
|
||||||
|
wrench
|
||||||
|
```
|
||||||
|
|
||||||
|
Рамкой выделяйте изображение инструмента на карточке, а класс задавайте по
|
||||||
|
картинке. Используйте одинаковый стиль разметки в симуляторе и на реальном поле.
|
||||||
|
Размечайте каждый видимый экземпляр: в штатной сцене две карточки `pliers`, одна
|
||||||
|
`hammer` и одна `wrench`. Классы `box` и `background` не создавайте.
|
||||||
|
|
||||||
|
В Roboflow создайте `Object Detection` project, загрузите `dataset/raw/*.jpg`,
|
||||||
|
разметьте кадры, сделайте split 70/20/10 и экспортируйте в формате YOLO11.
|
||||||
|
Распакуйте экспорт в `module3/training`: там должны появиться `data.yaml` и
|
||||||
|
каталоги `train`, `valid`, `test`. Не переставляйте номера классов в `data.yaml`:
|
||||||
|
они назначаются Roboflow и должны совпадать с ID внутри label-файлов.
|
||||||
|
|
||||||
|
Локально в `dataset/raw` могут лежать собранные кадры, но Git их намеренно
|
||||||
|
игнорирует: после клонирования датасет нужно перенести отдельно или собрать
|
||||||
|
заново. Расположение объектов менять не требуется: дополнительные повороты и
|
||||||
|
сдвиги выполняются аугментациями во время обучения.
|
||||||
|
|
||||||
|
## Сбор кадров из симулятора
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
python3 module3/capture_dataset.py --count 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Во время сбора плавно меняйте положение RMC1/манипулятора. Скрипт пропускает почти
|
||||||
|
одинаковые кадры. Для одиночного снимка используйте `--count 1 --min-change 0`.
|
||||||
|
|
||||||
|
## Установка для обучения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd module3
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Ultralytics ставит PyTorch как зависимость. Для конкретной NVIDIA CUDA или AMD
|
||||||
|
ROCm лучше сначала установить подходящую сборку PyTorch, затем `requirements.txt`.
|
||||||
|
|
||||||
|
## Обучение
|
||||||
|
|
||||||
|
Если часть объектов была размечена полигонами, один раз приведите экспорт к
|
||||||
|
обычным bounding boxes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python prepare_dataset.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Для теста используются максимум 30 эпох и early stopping после 7 эпох без
|
||||||
|
улучшения validation metrics.
|
||||||
|
|
||||||
|
Автовыбор: NVIDIA CUDA / AMD ROCm, а если GPU недоступен — CPU:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python train.py --device auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Только CPU (подойдёт на AMD без настроенного ROCm):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python train.py --device cpu --batch 4 --workers 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Принудительно NVIDIA CUDA или настроенный AMD ROCm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python train.py --device cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
Лучшие веса сохраняются в `training/runs/tools_yolo11n/weights/best.pt` и
|
||||||
|
автоматически копируются в `models/latest.pt` для простого запуска проверки.
|
||||||
|
|
||||||
|
## Проверка на фотографии
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_model.py training/test/images/example.jpg --device cpu
|
||||||
|
```
|
||||||
|
|
||||||
|
С `--show` откроется окно. Независимо от окна размеченная фотография сохраняется
|
||||||
|
в `predictions/`: на ней видны bbox, названия классов и confidence.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Сохраняет непохожие кадры с камеры захвата RMC1 для разметки."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--topic",
|
||||||
|
default="/RMC1/arm95/camera_gripper/image_color",
|
||||||
|
help="ROS-топик sensor_msgs/Image",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", default="dataset/raw", help="Каталог для JPG")
|
||||||
|
parser.add_argument("--count", type=int, default=30, help="Сколько кадров сохранить")
|
||||||
|
parser.add_argument("--interval", type=float, default=0.5, help="Минимум секунд между JPG")
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-change",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="Минимальное среднее изменение пикселя; 0 сохраняет одинаковые кадры",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=float, default=180.0, help="Общий таймаут")
|
||||||
|
parser.add_argument("--jpeg-quality", type=int, default=95)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.count < 1 or args.interval < 0 or args.timeout <= 0:
|
||||||
|
parser.error("count >= 1, interval >= 0, timeout > 0")
|
||||||
|
if not 1 <= args.jpeg_quality <= 100 or args.min_change < 0:
|
||||||
|
parser.error("jpeg-quality должен быть 1..100, min-change >= 0")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import rclpy
|
||||||
|
from cv_bridge import CvBridge
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from sensor_msgs.msg import Image
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Нет ROS/OpenCV зависимости: {error}. Запускайте после source ROS 2 в VM."
|
||||||
|
)
|
||||||
|
|
||||||
|
output = Path(args.output).expanduser()
|
||||||
|
if not output.is_absolute():
|
||||||
|
output = HERE / output
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
class Collector(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("mvch_dataset_collector")
|
||||||
|
self.bridge = CvBridge()
|
||||||
|
self.saved = 0
|
||||||
|
self.previous = None
|
||||||
|
self.last_save = 0.0
|
||||||
|
self.create_subscription(Image, args.topic, self.on_image, qos_profile_sensor_data)
|
||||||
|
|
||||||
|
def on_image(self, message):
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self.last_save < args.interval:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
frame = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
|
||||||
|
except Exception as error:
|
||||||
|
self.get_logger().error(f"Не удалось преобразовать кадр: {error}")
|
||||||
|
return
|
||||||
|
preview = cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY), (64, 48))
|
||||||
|
if self.previous is not None:
|
||||||
|
change = float(np.mean(cv2.absdiff(preview, self.previous)))
|
||||||
|
if change < args.min_change:
|
||||||
|
return
|
||||||
|
filename = output / f"frame_{datetime.now():%Y%m%d_%H%M%S_%f}_{self.saved:03d}.jpg"
|
||||||
|
if not cv2.imwrite(
|
||||||
|
str(filename), frame, [cv2.IMWRITE_JPEG_QUALITY, args.jpeg_quality]
|
||||||
|
):
|
||||||
|
self.get_logger().error(f"Не удалось записать {filename}")
|
||||||
|
return
|
||||||
|
self.previous = preview
|
||||||
|
self.last_save = now
|
||||||
|
self.saved += 1
|
||||||
|
print(f"[{self.saved}/{args.count}] {filename}", flush=True)
|
||||||
|
|
||||||
|
rclpy.init()
|
||||||
|
node = Collector()
|
||||||
|
started = time.monotonic()
|
||||||
|
print(f"Камера: {args.topic}")
|
||||||
|
print("Плавно двигайте RMC1 или манипулятор, чтобы ракурсы менялись.")
|
||||||
|
try:
|
||||||
|
while rclpy.ok() and node.saved < args.count:
|
||||||
|
if time.monotonic() - started >= args.timeout:
|
||||||
|
break
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.2)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
saved = node.saved
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
print(f"Готово: сохранено {saved} кадров в {output}")
|
||||||
|
if saved == 0:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
raw
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
hammer
|
||||||
|
pliers
|
||||||
|
wrench
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
path: .
|
||||||
|
train: train/images
|
||||||
|
val: valid/images
|
||||||
|
test: test/images
|
||||||
|
|
||||||
|
names:
|
||||||
|
0: hammer
|
||||||
|
1: pliers
|
||||||
|
2: wrench
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Модуль В: распознать деталь, взять ARM95 и вернуть на полку."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
TARGETS = {"1": "hammer", "2": "wrench", "3": "pliers"}
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog:
|
||||||
|
def __init__(self):
|
||||||
|
directory = HERE / "logs"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = directory / f"module3_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||||
|
|
||||||
|
def write(self, event, **fields):
|
||||||
|
record = {"time": datetime.now().isoformat(timespec="milliseconds"), "event": event}
|
||||||
|
record.update(fields)
|
||||||
|
line = json.dumps(record, ensure_ascii=False)
|
||||||
|
print(line, flush=True)
|
||||||
|
with self.path.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write(line + "\n")
|
||||||
|
|
||||||
|
def image_path(self, suffix):
|
||||||
|
return self.path.with_name(f"{self.path.stem}_{suffix}.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def local_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else HERE / path
|
||||||
|
|
||||||
|
|
||||||
|
def target_name(value):
|
||||||
|
value = value.strip().lower()
|
||||||
|
return TARGETS.get(value, value)
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--target", help="1/hammer, 2/wrench или 3/pliers")
|
||||||
|
parser.add_argument("--weights", default="models/latest.pt")
|
||||||
|
parser.add_argument("--topic", default="/RMC1/arm95/camera_gripper/image_color")
|
||||||
|
parser.add_argument("--conf", type=float, default=0.20)
|
||||||
|
parser.add_argument("--imgsz", type=int, default=512)
|
||||||
|
parser.add_argument("--device", default="cpu")
|
||||||
|
parser.add_argument("--timeout", type=float, default=60.0)
|
||||||
|
parser.add_argument(
|
||||||
|
"--plane-z",
|
||||||
|
type=float,
|
||||||
|
default=0.128,
|
||||||
|
help="Высота верхней грани детали относительно Base_link, м",
|
||||||
|
)
|
||||||
|
parser.add_argument("--pick-z", type=float, default=0.210)
|
||||||
|
parser.add_argument("--approach-z", type=float, default=0.38)
|
||||||
|
parser.add_argument("--drop-x", type=float, help="Точка возврата; по умолчанию исходная")
|
||||||
|
parser.add_argument("--drop-y", type=float, help="Точка возврата; по умолчанию исходная")
|
||||||
|
parser.add_argument("--no-window", action="store_true", help="Не открывать видеопоток")
|
||||||
|
parser.add_argument("--auto-start", action="store_true", help="Не ждать клавишу G/Enter")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Только распознавание и координаты")
|
||||||
|
parser.add_argument("--plan-only", action="store_true", help="Спланировать подход, не двигать руку")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not 0.0 <= args.conf <= 1.0:
|
||||||
|
parser.error("--conf должен быть от 0 до 1")
|
||||||
|
if args.timeout <= 0 or args.approach_z <= args.pick_z:
|
||||||
|
parser.error("timeout > 0, approach-z должен быть выше pick-z")
|
||||||
|
if (args.drop_x is None) != (args.drop_y is None):
|
||||||
|
parser.error("--drop-x и --drop-y задаются вместе")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_vector(vector, quaternion):
|
||||||
|
"""Поворот вектора кватернионом geometry_msgs без внешней математики."""
|
||||||
|
x, y, z = vector
|
||||||
|
qx, qy, qz, qw = quaternion
|
||||||
|
tx = 2.0 * (qy * z - qz * y)
|
||||||
|
ty = 2.0 * (qz * x - qx * z)
|
||||||
|
tz = 2.0 * (qx * y - qy * x)
|
||||||
|
return (
|
||||||
|
x + qw * tx + qy * tz - qz * ty,
|
||||||
|
y + qw * ty + qz * tx - qx * tz,
|
||||||
|
z + qw * tz + qx * ty - qy * tx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_half_turn(angle, reference=math.pi / 2):
|
||||||
|
"""Выбирает эквивалентный yaw (схват симметричен через 180 градусов)."""
|
||||||
|
choices = (angle - math.pi, angle, angle + math.pi)
|
||||||
|
return min(choices, key=lambda item: abs(item - reference))
|
||||||
|
|
||||||
|
|
||||||
|
def card_short_axis(frame, xyxy, cv2, np):
|
||||||
|
"""Находит направление короткой стороны светлой карточки внутри YOLO bbox."""
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
x1, y1, x2, y2 = (int(round(value)) for value in xyxy)
|
||||||
|
x1, y1 = max(0, x1), max(0, y1)
|
||||||
|
x2, y2 = min(width, x2), min(height, y2)
|
||||||
|
crop = frame[y1:y2, x1:x2]
|
||||||
|
if crop.size == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
|
||||||
|
mask = cv2.inRange(hsv, (0, 0, 105), (180, 115, 255))
|
||||||
|
kernel = np.ones((5, 5), np.uint8)
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
|
||||||
|
crop_area = crop.shape[0] * crop.shape[1]
|
||||||
|
candidates = [contour for contour in contours if cv2.contourArea(contour) > crop_area * 0.12]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
rectangle = cv2.minAreaRect(max(candidates, key=cv2.contourArea))
|
||||||
|
points = cv2.boxPoints(rectangle)
|
||||||
|
edges = [points[(index + 1) % 4] - points[index] for index in range(4)]
|
||||||
|
short_edge = min(edges, key=lambda edge: float(np.linalg.norm(edge)))
|
||||||
|
length = float(np.linalg.norm(short_edge))
|
||||||
|
if length < 2.0:
|
||||||
|
return None
|
||||||
|
return float(short_edge[0] / length), float(short_edge[1] / length)
|
||||||
|
|
||||||
|
|
||||||
|
class Detection:
|
||||||
|
def __init__(self, name, confidence, xyxy, short_axis):
|
||||||
|
self.name = name
|
||||||
|
self.confidence = confidence
|
||||||
|
self.xyxy = xyxy
|
||||||
|
self.short_axis = short_axis
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center(self):
|
||||||
|
x1, y1, x2, y2 = self.xyxy
|
||||||
|
return (x1 + x2) / 2.0, (y1 + y2) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_targets(result, frame, requested, cv2, np):
|
||||||
|
found = []
|
||||||
|
if result.boxes is None:
|
||||||
|
return found
|
||||||
|
for box in result.boxes:
|
||||||
|
class_id = int(box.cls.item())
|
||||||
|
name = str(result.names[class_id]).lower()
|
||||||
|
if name != requested:
|
||||||
|
continue
|
||||||
|
confidence = float(box.conf.item())
|
||||||
|
xyxy = tuple(float(value) for value in box.xyxy[0])
|
||||||
|
found.append(Detection(name, confidence, xyxy, card_short_axis(frame, xyxy, cv2, np)))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def choose_target(node, detections, plane_z):
|
||||||
|
"""При одинаковых классах выбирает ближайшую к базе доступную деталь."""
|
||||||
|
reachable = []
|
||||||
|
for detection in detections:
|
||||||
|
try:
|
||||||
|
x, y, _ = estimate_pose(node, detection, plane_z)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
reachable.append((math.hypot(x, y), -detection.confidence, detection))
|
||||||
|
if reachable:
|
||||||
|
return min(reachable, key=lambda item: (item[0], item[1]))[2]
|
||||||
|
return max(detections, key=lambda item: item.confidence, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def card_near_center(frame, requested, cv2, np):
|
||||||
|
"""На близком кадре находит светлую коробку около центра камеры."""
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
|
||||||
|
mask = cv2.inRange(hsv, (0, 0, 115), (180, 110, 255))
|
||||||
|
kernel = np.ones((7, 7), np.uint8)
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for contour in contours:
|
||||||
|
rectangle = cv2.minAreaRect(contour)
|
||||||
|
rect_width, rect_height = rectangle[1]
|
||||||
|
rect_area = rect_width * rect_height
|
||||||
|
if rect_area < 2000 or min(rect_width, rect_height) < 20:
|
||||||
|
continue
|
||||||
|
aspect = max(rect_width, rect_height) / min(rect_width, rect_height)
|
||||||
|
if not 1.2 <= aspect <= 4.0:
|
||||||
|
continue
|
||||||
|
center_x, center_y = rectangle[0]
|
||||||
|
distance = math.hypot(center_x - width / 2.0, center_y - height / 2.0)
|
||||||
|
candidates.append((distance, rectangle))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError("На близком кадре не найдена коробка")
|
||||||
|
distance, rectangle = min(candidates, key=lambda item: item[0])
|
||||||
|
if distance > min(width, height) * 0.30:
|
||||||
|
raise RuntimeError(f"Коробка слишком далеко от центра кадра: {distance:.0f} px")
|
||||||
|
|
||||||
|
points = cv2.boxPoints(rectangle)
|
||||||
|
edges = [points[(index + 1) % 4] - points[index] for index in range(4)]
|
||||||
|
short_edge = min(edges, key=lambda edge: float(np.linalg.norm(edge)))
|
||||||
|
length = float(np.linalg.norm(short_edge))
|
||||||
|
short_axis = float(short_edge[0] / length), float(short_edge[1] / length)
|
||||||
|
x1, y1 = np.min(points, axis=0)
|
||||||
|
x2, y2 = np.max(points, axis=0)
|
||||||
|
return Detection(requested, 1.0, (float(x1), float(y1), float(x2), float(y2)), short_axis)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_pose(node, detection, plane_z):
|
||||||
|
if node.camera_info is None:
|
||||||
|
raise RuntimeError("Нет CameraInfo")
|
||||||
|
frame = node.camera_frame or node.camera_info.header.frame_id
|
||||||
|
transform = node.tf_buffer.lookup_transform(
|
||||||
|
"Base_link", frame, node.Time(), timeout=node.Duration(seconds=1.0)
|
||||||
|
)
|
||||||
|
translation = transform.transform.translation
|
||||||
|
rotation = transform.transform.rotation
|
||||||
|
quaternion = (rotation.x, rotation.y, rotation.z, rotation.w)
|
||||||
|
|
||||||
|
u, v = detection.center
|
||||||
|
matrix = node.camera_info.k
|
||||||
|
fx, fy, cx, cy = matrix[0], matrix[4], matrix[2], matrix[5]
|
||||||
|
ray_camera = ((u - cx) / fx, (v - cy) / fy, 1.0)
|
||||||
|
ray_base = rotate_vector(ray_camera, quaternion)
|
||||||
|
if abs(ray_base[2]) < 1e-6:
|
||||||
|
raise RuntimeError("Луч камеры параллелен полке")
|
||||||
|
distance = (plane_z - translation.z) / ray_base[2]
|
||||||
|
if distance <= 0:
|
||||||
|
raise RuntimeError("Полка находится позади камеры")
|
||||||
|
|
||||||
|
x = translation.x + distance * ray_base[0]
|
||||||
|
y = translation.y + distance * ray_base[1]
|
||||||
|
if math.hypot(x, y) > 0.9:
|
||||||
|
raise RuntimeError(f"Координаты вне рабочей зоны: x={x:.3f}, y={y:.3f}")
|
||||||
|
|
||||||
|
yaw = math.pi / 2
|
||||||
|
if detection.short_axis is not None:
|
||||||
|
vx, vy = detection.short_axis
|
||||||
|
axis_base = rotate_vector((vx, vy, 0.0), quaternion)
|
||||||
|
short_angle = math.atan2(axis_base[1], axis_base[0])
|
||||||
|
yaw = normalize_half_turn(short_angle - math.pi / 2)
|
||||||
|
return x, y, yaw
|
||||||
|
|
||||||
|
|
||||||
|
def build_vision_node(args, imports):
|
||||||
|
Node, CvBridge, Image, CameraInfo, qos, Buffer, TransformListener, Duration, Time = imports
|
||||||
|
|
||||||
|
class VisionNode(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("mvch_module3_vision")
|
||||||
|
self.Duration = Duration
|
||||||
|
self.Time = Time
|
||||||
|
self.bridge = CvBridge()
|
||||||
|
self.frame = None
|
||||||
|
self.camera_info = None
|
||||||
|
self.camera_frame = None
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.tf_buffer = Buffer()
|
||||||
|
self.tf_listener = TransformListener(self.tf_buffer, self)
|
||||||
|
self.create_subscription(Image, args.topic, self.on_image, qos)
|
||||||
|
info_topic = args.topic.rsplit("/", 1)[0] + "/camera_info"
|
||||||
|
self.create_subscription(CameraInfo, info_topic, self.on_info, qos)
|
||||||
|
|
||||||
|
def on_image(self, message):
|
||||||
|
try:
|
||||||
|
frame = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
|
||||||
|
except Exception as error:
|
||||||
|
self.get_logger().error(f"Ошибка кадра: {error}")
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
self.frame = frame
|
||||||
|
self.camera_frame = message.header.frame_id
|
||||||
|
|
||||||
|
def on_info(self, message):
|
||||||
|
self.camera_info = message
|
||||||
|
|
||||||
|
def latest_frame(self):
|
||||||
|
with self.lock:
|
||||||
|
return None if self.frame is None else self.frame.copy()
|
||||||
|
|
||||||
|
return VisionNode()
|
||||||
|
|
||||||
|
|
||||||
|
class Arm:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
moveit,
|
||||||
|
params_file,
|
||||||
|
PoseStamped,
|
||||||
|
quaternion_from_euler,
|
||||||
|
event_log,
|
||||||
|
plan_only=False,
|
||||||
|
):
|
||||||
|
self.robot = moveit(
|
||||||
|
node_name="mvch_module3_moveit",
|
||||||
|
name_space="/RMC1/arm95",
|
||||||
|
launch_params_filepaths=[params_file],
|
||||||
|
config_dict=None,
|
||||||
|
)
|
||||||
|
self.arm = self.robot.get_planning_component("arm95_group")
|
||||||
|
self.gripper = self.robot.get_planning_component("gripper")
|
||||||
|
self.PoseStamped = PoseStamped
|
||||||
|
self.quaternion_from_euler = quaternion_from_euler
|
||||||
|
self.log = event_log
|
||||||
|
self.plan_only = plan_only
|
||||||
|
|
||||||
|
def execute(self, component, stage):
|
||||||
|
plan = component.plan()
|
||||||
|
if not plan:
|
||||||
|
raise RuntimeError(f"MoveIt не построил траекторию: {stage}")
|
||||||
|
self.log.write("PLAN_READY", stage=stage)
|
||||||
|
if not self.plan_only:
|
||||||
|
self.robot.execute(plan.trajectory, controllers=[])
|
||||||
|
time.sleep(0.4)
|
||||||
|
self.log.write("MOTION_DONE", stage=stage)
|
||||||
|
|
||||||
|
def named(self, component, state, stage):
|
||||||
|
component.set_start_state_to_current_state()
|
||||||
|
component.set_goal_state(configuration_name=state)
|
||||||
|
self.execute(component, stage)
|
||||||
|
|
||||||
|
def pose(self, x, y, z, yaw, stage):
|
||||||
|
goal = self.PoseStamped()
|
||||||
|
goal.header.frame_id = "Base_link"
|
||||||
|
qx, qy, qz, qw = self.quaternion_from_euler(math.pi, 0.0, yaw)
|
||||||
|
goal.pose.orientation.x = qx
|
||||||
|
goal.pose.orientation.y = qy
|
||||||
|
goal.pose.orientation.z = qz
|
||||||
|
goal.pose.orientation.w = qw
|
||||||
|
goal.pose.position.x = x
|
||||||
|
goal.pose.position.y = y
|
||||||
|
goal.pose.position.z = z
|
||||||
|
self.arm.set_start_state_to_current_state()
|
||||||
|
self.arm.set_goal_state(pose_stamped_msg=goal, pose_link="gripper_base")
|
||||||
|
self.execute(self.arm, stage)
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
self.named(self.gripper, "open", "открыть схват")
|
||||||
|
if not self.plan_only:
|
||||||
|
self.log.write("GRIPPER", state="open")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.named(self.gripper, "closed", "закрыть схват")
|
||||||
|
if not self.plan_only:
|
||||||
|
self.log.write("GRIPPER", state="closed")
|
||||||
|
|
||||||
|
def initial(self, stage="исходное положение"):
|
||||||
|
self.named(self.arm, "table_pos", stage)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_trigger(args, node, model, requested, cv2, np, event_log):
|
||||||
|
started = time.monotonic()
|
||||||
|
last_detection = None
|
||||||
|
last_frame_time = 0.0
|
||||||
|
last_status_time = 0.0
|
||||||
|
frame_count = 0
|
||||||
|
best_classes = {}
|
||||||
|
terminal_trigger = threading.Event()
|
||||||
|
target_visible = threading.Event()
|
||||||
|
window = not args.no_window and bool(os.environ.get("DISPLAY"))
|
||||||
|
if not args.no_window and not window:
|
||||||
|
print("[WARN] DISPLAY не найден, работаю без окна")
|
||||||
|
|
||||||
|
def read_terminal_command():
|
||||||
|
while not terminal_trigger.is_set():
|
||||||
|
try:
|
||||||
|
command = input().strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
return
|
||||||
|
if command not in ("", "g", "go"):
|
||||||
|
print("[WAIT] Команда не распознана. Введите g и нажмите Enter.")
|
||||||
|
continue
|
||||||
|
if not target_visible.is_set():
|
||||||
|
print(f"[WAIT] Класс {requested} пока не найден, команда не принята.")
|
||||||
|
continue
|
||||||
|
terminal_trigger.set()
|
||||||
|
|
||||||
|
if not args.auto_start and not args.dry_run and sys.stdin.isatty():
|
||||||
|
threading.Thread(target=read_terminal_command, daemon=True).start()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"[READY] Dry-run: жду первое распознавание класса {requested}.")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[READY] Цель: {requested}. После фиксации нажмите G в окне "
|
||||||
|
"или введите g + Enter в терминале."
|
||||||
|
)
|
||||||
|
while time.monotonic() - started < args.timeout:
|
||||||
|
frame = node.latest_frame()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
frame_count += 1
|
||||||
|
result = model.predict(
|
||||||
|
source=frame,
|
||||||
|
conf=args.conf,
|
||||||
|
imgsz=args.imgsz,
|
||||||
|
device=args.device,
|
||||||
|
verbose=False,
|
||||||
|
)[0]
|
||||||
|
if result.boxes is not None:
|
||||||
|
for box in result.boxes:
|
||||||
|
name = str(result.names[int(box.cls.item())]).lower()
|
||||||
|
confidence = float(box.conf.item())
|
||||||
|
best_classes[name] = max(best_classes.get(name, 0.0), confidence)
|
||||||
|
detections = find_targets(result, frame, requested, cv2, np)
|
||||||
|
last_detection = choose_target(node, detections, args.plane_z)
|
||||||
|
if last_detection is None:
|
||||||
|
target_visible.clear()
|
||||||
|
else:
|
||||||
|
target_visible.set()
|
||||||
|
annotated = result.plot(labels=True, conf=True, line_width=2)
|
||||||
|
if last_detection is not None:
|
||||||
|
u, v = (int(value) for value in last_detection.center)
|
||||||
|
cv2.circle(annotated, (u, v), 7, (0, 255, 255), -1)
|
||||||
|
if last_detection.short_axis is not None:
|
||||||
|
vx, vy = last_detection.short_axis
|
||||||
|
cv2.line(
|
||||||
|
annotated,
|
||||||
|
(u - int(vx * 45), v - int(vy * 45)),
|
||||||
|
(u + int(vx * 45), v + int(vy * 45)),
|
||||||
|
(255, 255, 0),
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if last_detection is not None:
|
||||||
|
if now - last_frame_time > 0.8:
|
||||||
|
event_log.write(
|
||||||
|
"DETECTION",
|
||||||
|
target=last_detection.name,
|
||||||
|
confidence=round(last_detection.confidence, 4),
|
||||||
|
center=[round(value, 1) for value in last_detection.center],
|
||||||
|
)
|
||||||
|
last_frame_time = now
|
||||||
|
elif now - last_status_time > 5.0:
|
||||||
|
event_log.write(
|
||||||
|
"VISION_WAIT",
|
||||||
|
target=requested,
|
||||||
|
frames=frame_count,
|
||||||
|
seen={name: round(value, 3) for name, value in sorted(best_classes.items())},
|
||||||
|
)
|
||||||
|
last_status_time = now
|
||||||
|
|
||||||
|
key = -1
|
||||||
|
if window:
|
||||||
|
cv2.imshow("CHVT module 3 - G: start, ESC: exit", annotated)
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == 27:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
if last_detection is not None and (
|
||||||
|
args.dry_run
|
||||||
|
or args.auto_start
|
||||||
|
or terminal_trigger.is_set()
|
||||||
|
or key in (ord("g"), ord("G"), 13, 32)
|
||||||
|
):
|
||||||
|
return last_detection
|
||||||
|
if frame_count == 0:
|
||||||
|
raise RuntimeError(f"Нет кадров с топика {args.topic} за {args.timeout:.0f} с")
|
||||||
|
seen = ", ".join(f"{name}={value:.2f}" for name, value in sorted(best_classes.items()))
|
||||||
|
raise RuntimeError(
|
||||||
|
f"За {args.timeout:.0f} с класс {requested} не найден; "
|
||||||
|
f"обработано кадров: {frame_count}; распознано: {seen or 'ничего'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_detection(node, model, requested, args, cv2, np):
|
||||||
|
time.sleep(0.7)
|
||||||
|
frame = node.latest_frame()
|
||||||
|
if frame is None:
|
||||||
|
raise RuntimeError("Нет свежего кадра после возврата руки")
|
||||||
|
result = model.predict(
|
||||||
|
source=frame,
|
||||||
|
conf=args.conf,
|
||||||
|
imgsz=args.imgsz,
|
||||||
|
device=args.device,
|
||||||
|
verbose=False,
|
||||||
|
)[0]
|
||||||
|
detections = find_targets(result, frame, requested, cv2, np)
|
||||||
|
detection = choose_target(node, detections, args.plane_z)
|
||||||
|
if detection is None:
|
||||||
|
raise RuntimeError(f"После установки руки не найден класс {requested}")
|
||||||
|
return detection
|
||||||
|
|
||||||
|
|
||||||
|
def save_snapshot(node, event_log, suffix, cv2):
|
||||||
|
time.sleep(0.5)
|
||||||
|
frame = node.latest_frame()
|
||||||
|
if frame is None:
|
||||||
|
event_log.write("SNAPSHOT_FAILED", stage=suffix, reason="нет кадра")
|
||||||
|
return
|
||||||
|
path = event_log.image_path(suffix)
|
||||||
|
if cv2.imwrite(str(path), frame):
|
||||||
|
event_log.write("SNAPSHOT", stage=suffix, path=str(path))
|
||||||
|
else:
|
||||||
|
event_log.write("SNAPSHOT_FAILED", stage=suffix, reason="ошибка записи")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
requested = target_name(args.target or input("Цель (1 hammer, 2 wrench, 3 pliers): "))
|
||||||
|
if requested not in TARGETS.values():
|
||||||
|
raise SystemExit(f"Неизвестная цель: {requested}")
|
||||||
|
weights = local_path(args.weights)
|
||||||
|
if not weights.is_file():
|
||||||
|
raise SystemExit(f"Не найдены веса: {weights}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
if int(np.__version__.split(".", 1)[0]) >= 2:
|
||||||
|
raise SystemExit(
|
||||||
|
"NumPy 2.x несовместим с cv_bridge из ROS Jazzy. "
|
||||||
|
"Выполните: python3 -m pip install --force-reinstall 'numpy<2'"
|
||||||
|
)
|
||||||
|
import rclpy
|
||||||
|
import yaml
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from cv_bridge import CvBridge
|
||||||
|
from geometry_msgs.msg import PoseStamped
|
||||||
|
from moveit.planning import MoveItPy
|
||||||
|
from moveit_configs_utils import MoveItConfigsBuilder
|
||||||
|
from rclpy.duration import Duration
|
||||||
|
from rclpy.executors import SingleThreadedExecutor
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from rclpy.time import Time
|
||||||
|
from sensor_msgs.msg import CameraInfo, Image
|
||||||
|
from tf2_ros import Buffer, TransformListener
|
||||||
|
from tf_transformations import quaternion_from_euler
|
||||||
|
from ultralytics import YOLO
|
||||||
|
except ImportError as error:
|
||||||
|
if error.name == "transforms3d":
|
||||||
|
raise SystemExit(
|
||||||
|
"Venv не видит системные зависимости ROS. Включите их: "
|
||||||
|
"sed -i 's/include-system-site-packages = false/"
|
||||||
|
"include-system-site-packages = true/' .venv/pyvenv.cfg"
|
||||||
|
)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Не удалось импортировать {error.name}: {error}. "
|
||||||
|
"Выполните pip install -r requirements.txt после source ROS"
|
||||||
|
)
|
||||||
|
|
||||||
|
event_log = EventLog()
|
||||||
|
event_log.write("START", target=requested, log=str(event_log.path))
|
||||||
|
rclpy.init()
|
||||||
|
imports = (
|
||||||
|
Node,
|
||||||
|
CvBridge,
|
||||||
|
Image,
|
||||||
|
CameraInfo,
|
||||||
|
qos_profile_sensor_data,
|
||||||
|
Buffer,
|
||||||
|
TransformListener,
|
||||||
|
Duration,
|
||||||
|
Time,
|
||||||
|
)
|
||||||
|
node = build_vision_node(args, imports)
|
||||||
|
executor = SingleThreadedExecutor()
|
||||||
|
executor.add_node(node)
|
||||||
|
spin_thread = threading.Thread(target=executor.spin, daemon=True)
|
||||||
|
spin_thread.start()
|
||||||
|
model = YOLO(str(weights))
|
||||||
|
moveit_active = False
|
||||||
|
exit_code = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
detection = wait_for_trigger(args, node, model, requested, cv2, np, event_log)
|
||||||
|
if args.dry_run:
|
||||||
|
x, y, yaw = estimate_pose(node, detection, args.plane_z)
|
||||||
|
event_log.write(
|
||||||
|
"DRY_RUN",
|
||||||
|
x=round(x, 4),
|
||||||
|
y=round(y, 4),
|
||||||
|
plane_z=args.plane_z,
|
||||||
|
yaw=round(yaw, 4),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
package_dir = Path(get_package_share_directory("ar_webots_fms_ros2"))
|
||||||
|
moveit_config = (
|
||||||
|
MoveItConfigsBuilder("arm95")
|
||||||
|
.robot_description(file_path=str(package_dir / "resource/urdf/arm95_webots.urdf"))
|
||||||
|
.trajectory_execution(
|
||||||
|
file_path=str(package_dir / "resource/config/moveit_controllers.yaml")
|
||||||
|
)
|
||||||
|
.moveit_cpp(file_path=str(package_dir / "resource/config/moveit_cpp.yaml"))
|
||||||
|
.to_moveit_configs()
|
||||||
|
.to_dict()
|
||||||
|
)
|
||||||
|
moveit_config["use_sim_time"] = True
|
||||||
|
# Wildcard нужен из-за namespace /RMC1/arm95: обычный config_dict
|
||||||
|
# записывает параметры только для имени узла без namespace.
|
||||||
|
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as stream:
|
||||||
|
yaml.safe_dump({"/**": {"ros__parameters": moveit_config}}, stream)
|
||||||
|
params_file = stream.name
|
||||||
|
try:
|
||||||
|
arm = Arm(
|
||||||
|
MoveItPy,
|
||||||
|
params_file,
|
||||||
|
PoseStamped,
|
||||||
|
quaternion_from_euler,
|
||||||
|
event_log,
|
||||||
|
args.plan_only,
|
||||||
|
)
|
||||||
|
moveit_active = True
|
||||||
|
finally:
|
||||||
|
Path(params_file).unlink(missing_ok=True)
|
||||||
|
arm.open()
|
||||||
|
arm.initial("установить исходное положение")
|
||||||
|
detection = fresh_detection(node, model, requested, args, cv2, np)
|
||||||
|
x, y, yaw = estimate_pose(node, detection, args.plane_z)
|
||||||
|
drop_x = x if args.drop_x is None else args.drop_x
|
||||||
|
drop_y = y if args.drop_y is None else args.drop_y
|
||||||
|
event_log.write(
|
||||||
|
"TARGET",
|
||||||
|
target=requested,
|
||||||
|
confidence=round(detection.confidence, 4),
|
||||||
|
x=round(x, 4),
|
||||||
|
y=round(y, 4),
|
||||||
|
yaw=round(yaw, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
arm.pose(x, y, args.approach_z, yaw, "подход над деталью")
|
||||||
|
if args.plan_only:
|
||||||
|
print("[PLAN-ONLY] Подход построен, движения не было")
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
close_frame = node.latest_frame()
|
||||||
|
if close_frame is None:
|
||||||
|
raise RuntimeError("Нет близкого кадра для уточнения")
|
||||||
|
close_detection = card_near_center(close_frame, requested, cv2, np)
|
||||||
|
close_x, close_y, close_yaw = estimate_pose(node, close_detection, args.plane_z)
|
||||||
|
correction = math.hypot(close_x - x, close_y - y)
|
||||||
|
if correction > 0.12:
|
||||||
|
raise RuntimeError(f"Слишком большая поправка координат: {correction:.3f} м")
|
||||||
|
x, y, yaw = close_x, close_y, close_yaw
|
||||||
|
event_log.write(
|
||||||
|
"TARGET_REFINED",
|
||||||
|
method="card_near_center",
|
||||||
|
correction=round(correction, 4),
|
||||||
|
x=round(x, 4),
|
||||||
|
y=round(y, 4),
|
||||||
|
yaw=round(yaw, 4),
|
||||||
|
)
|
||||||
|
arm.pose(x, y, args.approach_z, yaw, "точный подход над деталью")
|
||||||
|
arm.pose(x, y, args.pick_z, yaw, "опустить схват")
|
||||||
|
arm.close()
|
||||||
|
save_snapshot(node, event_log, "gripped", cv2)
|
||||||
|
arm.pose(x, y, args.approach_z, yaw, "поднять деталь")
|
||||||
|
arm.initial("исходное положение с деталью")
|
||||||
|
save_snapshot(node, event_log, "with_detail", cv2)
|
||||||
|
|
||||||
|
arm.pose(drop_x, drop_y, args.approach_z, yaw, "подход к пустому месту")
|
||||||
|
arm.pose(drop_x, drop_y, args.pick_z, yaw, "опустить деталь")
|
||||||
|
arm.open()
|
||||||
|
arm.pose(drop_x, drop_y, args.approach_z, yaw, "отойти от детали")
|
||||||
|
arm.initial("исходное положение без детали")
|
||||||
|
save_snapshot(node, event_log, "finished", cv2)
|
||||||
|
event_log.write("DONE", message="Деталь возвращена, манипулятор в исходном положении")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[STOP] Остановлено оператором")
|
||||||
|
exit_code = 130
|
||||||
|
except Exception as error:
|
||||||
|
event_log.write("FAILED", reason=str(error))
|
||||||
|
exit_code = 1
|
||||||
|
finally:
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
executor.shutdown()
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
if moveit_active:
|
||||||
|
# MoveItPy в поставке Jazzy падает в C++-деструкторе после штатной
|
||||||
|
# работы. Все движения и логи уже завершены, поэтому не вызываем
|
||||||
|
# проблемный teardown при выходе единственного зачетного скрипта.
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
os._exit(exit_code)
|
||||||
|
if exit_code:
|
||||||
|
raise SystemExit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Преобразует polygon-разметку Roboflow в обычные YOLO bounding boxes."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--dataset", default="training", help="Каталог Roboflow export")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def convert_line(line, filename):
|
||||||
|
values = line.split()
|
||||||
|
if len(values) == 5:
|
||||||
|
return line.strip(), False
|
||||||
|
if len(values) < 7 or (len(values) - 1) % 2:
|
||||||
|
raise ValueError(f"Некорректная строка в {filename}: {line.strip()}")
|
||||||
|
|
||||||
|
class_id = int(values[0])
|
||||||
|
coordinates = [float(value) for value in values[1:]]
|
||||||
|
xs = coordinates[0::2]
|
||||||
|
ys = coordinates[1::2]
|
||||||
|
x1, x2 = min(xs), max(xs)
|
||||||
|
y1, y2 = min(ys), max(ys)
|
||||||
|
box = (class_id, (x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1)
|
||||||
|
return f"{box[0]} {box[1]:.6f} {box[2]:.6f} {box[3]:.6f} {box[4]:.6f}", True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
dataset = Path(args.dataset).expanduser()
|
||||||
|
if not dataset.is_absolute():
|
||||||
|
dataset = HERE / dataset
|
||||||
|
|
||||||
|
files = sorted(dataset.glob("*/labels/*.txt"))
|
||||||
|
if not files:
|
||||||
|
raise SystemExit(f"Не найдены labels в {dataset}")
|
||||||
|
|
||||||
|
converted = 0
|
||||||
|
objects = 0
|
||||||
|
for filename in files:
|
||||||
|
output = []
|
||||||
|
for line in filename.read_text().splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
result, changed = convert_line(line, filename)
|
||||||
|
output.append(result)
|
||||||
|
converted += int(changed)
|
||||||
|
objects += 1
|
||||||
|
filename.write_text("\n".join(output) + "\n")
|
||||||
|
|
||||||
|
print(f"Готово: файлов={len(files)}, объектов={objects}, polygon->bbox={converted}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
numpy>=1.23.5,<2
|
||||||
|
opencv-python>=4.6,<4.12
|
||||||
|
lark>=1.1
|
||||||
|
transforms3d>=0.4
|
||||||
|
ultralytics==8.3.0
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Рисует bbox, название класса и confidence модели на фотографии."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def local_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else HERE / path
|
||||||
|
|
||||||
|
|
||||||
|
def select_device(requested, torch):
|
||||||
|
if requested == "auto":
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return 0
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
return "cpu"
|
||||||
|
if requested in ("cuda", "gpu"):
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise SystemExit("CUDA/ROCm не найден. Используйте --device cpu.")
|
||||||
|
return 0
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("source", help="Фотография для проверки")
|
||||||
|
parser.add_argument("--weights", default="models/latest.pt")
|
||||||
|
parser.add_argument("--output", default="predictions", help="Каталог результата")
|
||||||
|
parser.add_argument("--conf", type=float, default=0.35)
|
||||||
|
parser.add_argument("--imgsz", type=int, default=512)
|
||||||
|
parser.add_argument("--device", default="auto", help="auto, cpu, cuda или номер GPU")
|
||||||
|
parser.add_argument("--show", action="store_true", help="Открыть окно с результатом")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
from ultralytics import YOLO
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit(f"Нет зависимости {error.name}. Выполните: pip install -r requirements.txt")
|
||||||
|
|
||||||
|
source = local_path(args.source)
|
||||||
|
weights = local_path(args.weights)
|
||||||
|
output = local_path(args.output)
|
||||||
|
if not source.is_file():
|
||||||
|
raise SystemExit(f"Не найдена фотография: {source}")
|
||||||
|
if not weights.is_file():
|
||||||
|
raise SystemExit(f"Не найдены веса: {weights}")
|
||||||
|
if not 0 <= args.conf <= 1:
|
||||||
|
raise SystemExit("--conf должен быть от 0 до 1")
|
||||||
|
|
||||||
|
model = YOLO(str(weights))
|
||||||
|
result = model.predict(
|
||||||
|
source=str(source),
|
||||||
|
conf=args.conf,
|
||||||
|
imgsz=args.imgsz,
|
||||||
|
device=select_device(args.device, torch),
|
||||||
|
verbose=False,
|
||||||
|
)[0]
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination = output / f"{source.stem}_detected.jpg"
|
||||||
|
annotated = result.plot(labels=True, conf=True, line_width=2)
|
||||||
|
if not cv2.imwrite(str(destination), annotated):
|
||||||
|
raise SystemExit(f"Не удалось сохранить {destination}")
|
||||||
|
|
||||||
|
if result.boxes is None or len(result.boxes) == 0:
|
||||||
|
print("Объекты не найдены")
|
||||||
|
else:
|
||||||
|
for box in result.boxes:
|
||||||
|
class_id = int(box.cls.item())
|
||||||
|
confidence = float(box.conf.item())
|
||||||
|
x1, y1, x2, y2 = (round(float(v), 1) for v in box.xyxy[0])
|
||||||
|
print(f"{result.names[class_id]}: {confidence:.3f}, bbox=({x1}, {y1}, {x2}, {y2})")
|
||||||
|
print(f"Изображение с классами: {destination}")
|
||||||
|
|
||||||
|
if args.show:
|
||||||
|
cv2.imshow("Module 3 detection - press any key", annotated)
|
||||||
|
cv2.waitKey(0)
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Обучение YOLO11 на размеченном датасете инструментов."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
TRAINING = HERE / "training"
|
||||||
|
|
||||||
|
|
||||||
|
def local_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else HERE / path
|
||||||
|
|
||||||
|
|
||||||
|
def select_device(requested, torch):
|
||||||
|
if requested == "cpu":
|
||||||
|
return "cpu"
|
||||||
|
if requested in ("cuda", "gpu"):
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise SystemExit("CUDA/ROCm не найден. Используйте --device cpu или установите PyTorch для GPU.")
|
||||||
|
return 0
|
||||||
|
if requested == "auto":
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return 0 # NVIDIA CUDA и AMD ROCm используют этот интерфейс PyTorch.
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
return "cpu"
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--data", default="training/data.yaml", help="data.yaml из Roboflow")
|
||||||
|
parser.add_argument("--model", default="yolo11n.pt", help="Стартовые веса Ultralytics")
|
||||||
|
parser.add_argument("--epochs", type=int, default=30)
|
||||||
|
parser.add_argument(
|
||||||
|
"--patience",
|
||||||
|
type=int,
|
||||||
|
default=7,
|
||||||
|
help="Остановиться после N эпох без улучшения validation metrics",
|
||||||
|
)
|
||||||
|
parser.add_argument("--imgsz", type=int, default=512)
|
||||||
|
parser.add_argument("--batch", type=int, default=8)
|
||||||
|
parser.add_argument("--workers", type=int, default=4)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
default="auto",
|
||||||
|
help="auto, cpu, cuda или номер GPU (например 0)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--name", default="tools_yolo11n")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from ultralytics import YOLO
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit(f"Нет зависимости {error.name}. Выполните: pip install -r requirements.txt")
|
||||||
|
|
||||||
|
data = local_path(args.data)
|
||||||
|
if not data.is_file():
|
||||||
|
raise SystemExit(f"Не найден датасет: {data}\nРаспакуйте YOLO11 export из Roboflow в module3/dataset.")
|
||||||
|
device = select_device(args.device, torch)
|
||||||
|
if device == 0:
|
||||||
|
backend = "ROCm" if getattr(torch.version, "hip", None) else "CUDA"
|
||||||
|
print(f"Устройство: {backend} GPU — {torch.cuda.get_device_name(0)}")
|
||||||
|
else:
|
||||||
|
print(f"Устройство: {device}")
|
||||||
|
|
||||||
|
model = YOLO(args.model)
|
||||||
|
result = model.train(
|
||||||
|
data=str(data),
|
||||||
|
epochs=args.epochs,
|
||||||
|
patience=args.patience,
|
||||||
|
imgsz=args.imgsz,
|
||||||
|
batch=args.batch,
|
||||||
|
workers=args.workers,
|
||||||
|
device=device,
|
||||||
|
project=str(TRAINING / "runs"),
|
||||||
|
name=args.name,
|
||||||
|
seed=42,
|
||||||
|
deterministic=True,
|
||||||
|
hsv_h=0.0,
|
||||||
|
hsv_s=0.0,
|
||||||
|
hsv_v=0.0,
|
||||||
|
degrees=12.0,
|
||||||
|
translate=0.05,
|
||||||
|
scale=0.15,
|
||||||
|
shear=2.0,
|
||||||
|
fliplr=0.5,
|
||||||
|
flipud=0.5,
|
||||||
|
)
|
||||||
|
save_dir = Path(result.save_dir)
|
||||||
|
best_weights = save_dir / "weights" / "best.pt"
|
||||||
|
model_copy = HERE / "models" / "latest.pt"
|
||||||
|
model_copy.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(best_weights, model_copy)
|
||||||
|
print(f"Лучшие веса: {best_weights}")
|
||||||
|
print(f"Модель для test_model.py: {model_copy}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
*.zip
|
||||||
|
.venv
|
||||||
|
runs
|
||||||
|
*.cache
|
||||||
|
test
|
||||||
|
valid
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# chvt-2026 > v0.1.0
|
||||||
|
https://universe.roboflow.com/jons-workspace-8viwr/chvt-2026
|
||||||
|
|
||||||
|
Provided by a Roboflow user
|
||||||
|
License: CC BY 4.0
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
chvt-2026 - v1 v0.1.0
|
||||||
|
==============================
|
||||||
|
|
||||||
|
This dataset was exported via roboflow.com on September 5, 2026 at 7:40 PM GMT
|
||||||
|
|
||||||
|
Roboflow is an end-to-end computer vision platform that helps you
|
||||||
|
* collaborate with your team on computer vision projects
|
||||||
|
* collect & organize images
|
||||||
|
* understand and search unstructured image data
|
||||||
|
* annotate, and create datasets
|
||||||
|
* export, train, and deploy computer vision models
|
||||||
|
* use active learning to improve your dataset over time
|
||||||
|
|
||||||
|
For state of the art Computer Vision training notebooks you can use with this dataset,
|
||||||
|
visit https://github.com/roboflow/notebooks
|
||||||
|
|
||||||
|
To find over 100k other datasets and pre-trained models, visit https://universe.roboflow.com
|
||||||
|
|
||||||
|
The dataset includes 40 images.
|
||||||
|
Chvt-2026 are annotated in YOLOv11 format.
|
||||||
|
|
||||||
|
The following pre-processing was applied to each image:
|
||||||
|
* Auto-orientation of pixel data (with EXIF-orientation stripping)
|
||||||
|
* Resize to 512x512 (Stretch)
|
||||||
|
|
||||||
|
No image augmentation techniques were applied.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
train: train/images
|
||||||
|
val: valid/images
|
||||||
|
test: test/images
|
||||||
|
|
||||||
|
names:
|
||||||
|
0: hammer
|
||||||
|
1: pliers
|
||||||
|
2: wrench
|
||||||
|
|
||||||
|
# nc: 3
|
||||||
|
# names: ['hammer', 'pliers', 'wrench']
|
||||||
|
|
||||||
|
# roboflow:
|
||||||
|
# workspace: jons-workspace-8viwr
|
||||||
|
# project: chvt-2026
|
||||||
|
# version: 1
|
||||||
|
# license: CC BY 4.0
|
||||||
|
# url: https://universe.roboflow.com/jons-workspace-8viwr/chvt-2026/dataset/1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# пайторч без куда для амд
|
||||||
|
```
|
||||||
|
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
```
|
||||||
|
|
||||||
|
# база
|
||||||
|
```
|
||||||
|
pip install -r ../requirements.txt
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
0 0.663086 0.459961 0.060547 0.111328
|
||||||
|
1 0.326172 0.395508 0.046875 0.115234
|
||||||
|
2 0.494141 0.460938 0.074219 0.070312
|
||||||
|
1 0.609375 0.326172 0.070312 0.093750
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.326172 0.395508 0.046875 0.115234
|
||||||
|
1 0.608398 0.327148 0.068359 0.095703
|
||||||
|
0 0.663086 0.459961 0.064453 0.111328
|
||||||
|
2 0.494141 0.460938 0.074219 0.070312
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.330078 0.379883 0.046875 0.111328
|
||||||
|
1 0.619141 0.338867 0.066406 0.099609
|
||||||
|
2 0.495117 0.460938 0.076172 0.070312
|
||||||
|
0 0.663086 0.475586 0.068359 0.107422
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2 0.494141 0.458008 0.078125 0.064453
|
||||||
|
1 0.3359375 0.3671875 0.0546875 0.11328125
|
||||||
|
1 0.625000 0.348633 0.062500 0.103516
|
||||||
|
0 0.663086 0.488281 0.072266 0.105469
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.336914 0.359375 0.056641 0.109375
|
||||||
|
2 0.495117 0.458008 0.080078 0.060547
|
||||||
|
1 0.628906 0.353516 0.062500 0.105469
|
||||||
|
0 0.662109 0.496094 0.074219 0.101562
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.340820 0.351562 0.056641 0.109375
|
||||||
|
2 0.495117 0.457031 0.080078 0.058594
|
||||||
|
1 0.632812 0.360352 0.062500 0.107422
|
||||||
|
0 0.662109 0.503906 0.074219 0.101562
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.344727 0.343750 0.060547 0.109375
|
||||||
|
2 0.496094 0.457031 0.082031 0.054688
|
||||||
|
1 0.635742 0.365234 0.056641 0.109375
|
||||||
|
0 0.660156 0.510742 0.074219 0.099609
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.349609 0.334961 0.066406 0.107422
|
||||||
|
1 0.638672 0.373047 0.054688 0.109375
|
||||||
|
2 0.496094 0.457031 0.082031 0.050781
|
||||||
|
0 0.660156 0.518555 0.074219 0.091797
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.348633 0.334961 0.064453 0.107422
|
||||||
|
1 0.639648 0.373047 0.056641 0.109375
|
||||||
|
2 0.496094 0.457031 0.082031 0.050781
|
||||||
|
0 0.658203 0.520508 0.078125 0.095703
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2 0.496094 0.457031 0.082031 0.050781
|
||||||
|
0 0.658203 0.520508 0.078125 0.095703
|
||||||
|
1 0.639648 0.373047 0.056641 0.109375
|
||||||
|
1 0.348633 0.334961 0.064453 0.107422
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.349609 0.335938 0.062500 0.105469
|
||||||
|
2 0.496094 0.457031 0.082031 0.050781
|
||||||
|
1 0.639648 0.371094 0.056641 0.109375
|
||||||
|
0 0.660156 0.517578 0.074219 0.093750
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.345703 0.342773 0.058594 0.107422
|
||||||
|
1 0.636719 0.367188 0.054688 0.109375
|
||||||
|
2 0.496094 0.456055 0.082031 0.052734
|
||||||
|
0 0.661133 0.511719 0.076172 0.097656
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.340820 0.350586 0.056641 0.107422
|
||||||
|
1 0.632812 0.360352 0.062500 0.107422
|
||||||
|
2 0.495117 0.458008 0.080078 0.060547
|
||||||
|
0 0.662109 0.505859 0.074219 0.097656
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.329102 0.379883 0.048828 0.111328
|
||||||
|
1 0.616211 0.334961 0.068359 0.099609
|
||||||
|
2 0.495117 0.458984 0.076172 0.070312
|
||||||
|
0 0.664062 0.473633 0.070312 0.107422
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2 0.494141 0.459961 0.074219 0.072266
|
||||||
|
1 0.328125 0.388672 0.046875 0.113281
|
||||||
|
1 0.614258 0.332031 0.072266 0.097656
|
||||||
|
0 0.663086 0.467773 0.068359 0.107422
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2 0.494141 0.460938 0.074219 0.074219
|
||||||
|
1 0.608398 0.325195 0.072266 0.095703
|
||||||
|
1 0.325195 0.398438 0.044922 0.113281
|
||||||
|
0 0.663086 0.458984 0.060547 0.109375
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.32421875 0.40625 0.046875 0.1171875
|
||||||
|
1 0.602539 0.319336 0.072266 0.091797
|
||||||
|
2 0.493164 0.460938 0.072266 0.074219
|
||||||
|
0 0.664062 0.449219 0.058594 0.109375
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.322266 0.416016 0.042969 0.113281
|
||||||
|
1 0.597656 0.315430 0.074219 0.087891
|
||||||
|
2 0.494141 0.461914 0.070312 0.080078
|
||||||
|
0 0.663086 0.441406 0.056641 0.113281
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.319336 0.458984 0.041016 0.113281
|
||||||
|
2 0.492188 0.464844 0.062500 0.089844
|
||||||
|
1 0.573242 0.296875 0.083984 0.078125
|
||||||
|
0 0.656250 0.406250 0.046875 0.117188
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.319336 0.462891 0.041016 0.113281
|
||||||
|
1 0.569336 0.295898 0.080078 0.076172
|
||||||
|
2 0.491211 0.464844 0.060547 0.089844
|
||||||
|
0 0.657227 0.401367 0.044922 0.115234
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.320312 0.463867 0.042969 0.115234
|
||||||
|
1 0.569336 0.293945 0.080078 0.076172
|
||||||
|
2 0.491211 0.464844 0.060547 0.089844
|
||||||
|
0 0.656250 0.400391 0.042969 0.117188
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.321289 0.454102 0.041016 0.115234
|
||||||
|
1 0.576172 0.296875 0.082031 0.078125
|
||||||
|
2 0.494141 0.463867 0.062500 0.091797
|
||||||
|
0 0.660156 0.407227 0.046875 0.115234
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.321289 0.442383 0.041016 0.115234
|
||||||
|
1 0.584961 0.301758 0.080078 0.083984
|
||||||
|
0 0.664062 0.416992 0.050781 0.115234
|
||||||
|
2 0.495117 0.461914 0.064453 0.087891
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.326172 0.419922 0.042969 0.117188
|
||||||
|
1 0.603516 0.311523 0.078125 0.091797
|
||||||
|
2 0.500977 0.462891 0.068359 0.078125
|
||||||
|
0 0.669922 0.438477 0.058594 0.115234
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.330078 0.407227 0.046875 0.119141
|
||||||
|
1 0.615234 0.318359 0.074219 0.097656
|
||||||
|
2 0.502930 0.461914 0.072266 0.080078
|
||||||
|
0 0.674805 0.450195 0.060547 0.111328
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.350586 0.360352 0.056641 0.115234
|
||||||
|
2 0.512695 0.465820 0.080078 0.064453
|
||||||
|
1 0.653320 0.357422 0.064453 0.113281
|
||||||
|
0 0.680664 0.506836 0.076172 0.103516
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.353516 0.355469 0.058594 0.117188
|
||||||
|
1 0.655273 0.363281 0.060547 0.113281
|
||||||
|
2 0.514648 0.465820 0.080078 0.060547
|
||||||
|
0 0.681641 0.514648 0.078125 0.103516
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
1 0.353516 0.355469 0.058594 0.117188
|
||||||
|
1 0.656250 0.363281 0.062500 0.113281
|
||||||
|
2 0.513672 0.465820 0.082031 0.060547
|
||||||
|
0 0.681641 0.517578 0.078125 0.105469
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Модуль Г — веб-интерфейс FMS
|
||||||
|
|
||||||
|
Одностраничная панель управления двумя роверами. Стек: React, TypeScript, Vite,
|
||||||
|
Tailwind CSS 4, Bun и небольшой ROS 2-мост на Python.
|
||||||
|
|
||||||
|
## Что реализовано
|
||||||
|
|
||||||
|
- выбор RMC1 или RMC2;
|
||||||
|
- положение, скорость и история движения обоих роверов;
|
||||||
|
- ArUco-граф 6 × 6 с координатами маркеров 0–35;
|
||||||
|
- карта занятости RMC1 из `/map`;
|
||||||
|
- облако точек `/RMC*/scan`, повернутое и перенесённое вместе с ровером;
|
||||||
|
- план Nav2 из `/RMC*/plan` и текущая целевая точка;
|
||||||
|
- ручное управление кнопками и клавишами W/A/S/D, для RMC1 также Q/E;
|
||||||
|
- watchdog: после отпускания клавиши или потери окна скорость обнуляется;
|
||||||
|
- установка текущей позиции через `/RMC*/initialpose` без движения ровера;
|
||||||
|
- автономная цель через Nav2, с простым контроллером по одометрии как fallback;
|
||||||
|
- независимый аварийный стоп с отменой активной Nav2-задачи;
|
||||||
|
- схват RMC1 и лифт RMC2;
|
||||||
|
- вольтаж из стандартных ROS-топиков, если такой топик доступен в окружении;
|
||||||
|
- WebSocket-телеметрия с частотой 5 Гц;
|
||||||
|
- mock-режим для проверки вебки без ROS/Webots.
|
||||||
|
|
||||||
|
## Запуск на VM
|
||||||
|
|
||||||
|
Нужен Bun и уже собранное ROS 2-окружение соревнования.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/scripts/mvch/module4
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
В каждом ROS-терминале сначала подключить Jazzy и workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Запустить карту с двумя роверами:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch ar_webots_fms_ros2 module5.launch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Для полноценной автономной навигации RMC1 запустить Nav2:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch ar_nav_ros2 bringup_launch.py namespace:=/RMC1 robot_type:=rmc1
|
||||||
|
```
|
||||||
|
|
||||||
|
Если Nav2 в образе не установлен или action недоступен, ROS-мост автоматически
|
||||||
|
использует простой контроллер по одометрии. Он доводит оба ровера до x/y/yaw, но
|
||||||
|
не строит обход сложных препятствий, поэтому Nav2 для RMC1 предпочтительнее.
|
||||||
|
|
||||||
|
3. В третьем, также ROS-sourced терминале запустить FMS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/scripts/mvch/module4
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть <http://localhost:3000>. Сервер Bun сам запускает
|
||||||
|
`server/ros_bridge.py`; отдельно стартовать Python-файл не нужно.
|
||||||
|
|
||||||
|
Для разработки с Vite HMR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Страница будет на <http://localhost:5173>, API — на порту 3001.
|
||||||
|
|
||||||
|
## Быстрая проверка без симулятора
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
MOCK_ROS=1 bun run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock не используется на соревновании. Он нужен только для проверки кнопок,
|
||||||
|
WebSocket и компоновки интерфейса.
|
||||||
|
|
||||||
|
## ROS-топики
|
||||||
|
|
||||||
|
| Назначение | RMC1 | RMC2 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Одометрия | `/RMC1/odometry` | `/RMC2/odometry` |
|
||||||
|
| Лидар | `/RMC1/scan` | `/RMC2/scan` |
|
||||||
|
| Ручная скорость | `/RMC1/cmd_vel` | `/RMC2/cmd_vel` |
|
||||||
|
| Начальная позиция | `/RMC1/initialpose` | `/RMC2/initialpose` |
|
||||||
|
| Цель | `/RMC1/goal_pose` | `/RMC2/goal_pose` |
|
||||||
|
| План | `/RMC1/plan` | `/RMC2/plan` |
|
||||||
|
| Рабочий инструмент | `.../gripper_trajectory_controller/joint_trajectory` | `/RMC2/lift` |
|
||||||
|
|
||||||
|
Дополнительно используются `/map`, `/RMC2/aruco_id` и
|
||||||
|
`/RMC2/lift_status`.
|
||||||
|
|
||||||
|
Симулятор из открытого пакета не публикует напряжение аккумуляторов. Мост уже
|
||||||
|
подписан на `/RMC*/battery_state` (`sensor_msgs/BatteryState`) и
|
||||||
|
`/RMC*/battery_voltage` (`std_msgs/Float64`). Если на площадке топика тоже не
|
||||||
|
будет, интерфейс честно покажет `нет топика`, а не фиктивное значение.
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
- `GET /api/health` — состояние Bun и ROS-моста;
|
||||||
|
- `GET /api/state` — последний снимок телеметрии;
|
||||||
|
- `POST /api/command` — команда управления;
|
||||||
|
- `WS /ws` — телеметрия в реальном времени.
|
||||||
|
|
||||||
|
Пример остановки RMC1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/command \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"type":"emergency","robot":"RMC1","active":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run check
|
||||||
|
bun test
|
||||||
|
bun run build
|
||||||
|
python3 -m py_compile server/ros_bridge.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "mvch-module4-fms",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vite": "^6.4.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
|
||||||
|
|
||||||
|
"@swc/core": ["@swc/core@1.16.2", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.28" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.16.2", "@swc/core-darwin-x64": "1.16.2", "@swc/core-linux-arm-gnueabihf": "1.16.2", "@swc/core-linux-arm64-gnu": "1.16.2", "@swc/core-linux-arm64-musl": "1.16.2", "@swc/core-linux-ppc64-gnu": "1.16.2", "@swc/core-linux-s390x-gnu": "1.16.2", "@swc/core-linux-x64-gnu": "1.16.2", "@swc/core-linux-x64-musl": "1.16.2", "@swc/core-win32-arm64-msvc": "1.16.2", "@swc/core-win32-ia32-msvc": "1.16.2", "@swc/core-win32-x64-msvc": "1.16.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw=="],
|
||||||
|
|
||||||
|
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.16.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA=="],
|
||||||
|
|
||||||
|
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.16.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.16.2", "", { "os": "linux", "cpu": "arm" }, "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.16.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.16.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.16.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.16.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.16.2", "", { "os": "win32", "cpu": "x64" }, "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ=="],
|
||||||
|
|
||||||
|
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||||
|
|
||||||
|
"@swc/types": ["@swc/types@0.1.28", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<title>MVCH FMS - модуль Г</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "mvch-module4-fms",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run scripts/dev.ts",
|
||||||
|
"mock": "MOCK_ROS=1 bun run scripts/dev.ts",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"start": "bun run server/index.ts",
|
||||||
|
"run": "bun run build && bun run start",
|
||||||
|
"check": "tsc -b",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vite": "^6.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const bun = process.execPath
|
||||||
|
const env = { ...process.env, NODE_ENV: 'development' }
|
||||||
|
const backend = Bun.spawn([bun, 'run', 'server/index.ts'], { env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
|
||||||
|
const frontend = Bun.spawn([bun, 'x', 'vite'], { env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
|
||||||
|
|
||||||
|
async function stop(): Promise<void> {
|
||||||
|
backend.kill()
|
||||||
|
frontend.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', stop)
|
||||||
|
process.on('SIGTERM', stop)
|
||||||
|
|
||||||
|
const result = await Promise.race([backend.exited, frontend.exited])
|
||||||
|
await stop()
|
||||||
|
process.exit(result)
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Command, Pose, RobotId } from '../src/types'
|
||||||
|
|
||||||
|
const ROBOTS: RobotId[] = ['RMC1', 'RMC2']
|
||||||
|
|
||||||
|
function isRobot(value: unknown): value is RobotId {
|
||||||
|
return typeof value === 'string' && ROBOTS.includes(value as RobotId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPose(value: unknown): value is Pose {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const pose = value as Record<string, unknown>
|
||||||
|
return isFiniteNumber(pose.x) && isFiniteNumber(pose.y) && isFiniteNumber(pose.yaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCommand(value: unknown): value is Command {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const command = value as Record<string, unknown>
|
||||||
|
if (!isRobot(command.robot) || typeof command.type !== 'string') return false
|
||||||
|
|
||||||
|
switch (command.type) {
|
||||||
|
case 'manual':
|
||||||
|
return isFiniteNumber(command.x) && isFiniteNumber(command.y) && isFiniteNumber(command.yaw)
|
||||||
|
case 'stop':
|
||||||
|
case 'cancel_goal':
|
||||||
|
return true
|
||||||
|
case 'emergency':
|
||||||
|
return typeof command.active === 'boolean'
|
||||||
|
case 'set_pose':
|
||||||
|
case 'set_goal':
|
||||||
|
return isPose(command.pose)
|
||||||
|
case 'lift':
|
||||||
|
return command.robot === 'RMC2' && (command.height === 0 || command.height === 0.1)
|
||||||
|
case 'gripper':
|
||||||
|
return command.robot === 'RMC1' && (command.state === 'open' || command.state === 'closed')
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import type { Server, ServerWebSocket } from 'bun'
|
||||||
|
import type { Command, FmsState } from '../src/types'
|
||||||
|
import { isCommand } from './commands'
|
||||||
|
import { applyMockCommand, initialState, tickMock } from './state'
|
||||||
|
|
||||||
|
type WsData = { connectedAt: number }
|
||||||
|
|
||||||
|
const moduleDir = dirname(dirname(import.meta.path))
|
||||||
|
const port = Number(process.env.PORT ?? (process.env.NODE_ENV === 'development' ? 3001 : 3000))
|
||||||
|
const mock = process.env.MOCK_ROS === '1'
|
||||||
|
let state = initialState(mock ? 'mock' : 'ros')
|
||||||
|
let bridge: Bun.Subprocess<'pipe', 'pipe', 'inherit'> | null = null
|
||||||
|
|
||||||
|
function json(data: unknown, status = 200): Response {
|
||||||
|
return Response.json(data, { status, headers: { 'cache-control': 'no-store' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToBridge(command: Command): boolean {
|
||||||
|
if (mock) {
|
||||||
|
applyMockCommand(state, command)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!bridge?.stdin) return false
|
||||||
|
bridge.stdin.write(`${JSON.stringify(command)}\n`)
|
||||||
|
bridge.stdin.flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(server: Server<WsData>): void {
|
||||||
|
server.publish('telemetry', JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBridge(server: Server<WsData>): Promise<void> {
|
||||||
|
if (!bridge?.stdout) return
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let pending = ''
|
||||||
|
for await (const chunk of bridge.stdout) {
|
||||||
|
pending += decoder.decode(chunk, { stream: true })
|
||||||
|
const lines = pending.split('\n')
|
||||||
|
pending = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const next = JSON.parse(line) as FmsState
|
||||||
|
if (next.type === 'state') {
|
||||||
|
state = next
|
||||||
|
state.mode = 'ros'
|
||||||
|
state.bridgeOnline = true
|
||||||
|
state.bridgeError = null
|
||||||
|
broadcast(server)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`[ROS bridge] invalid JSON: ${line.slice(0, 180)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.bridgeOnline = false
|
||||||
|
state.bridgeError = `ROS bridge stopped (code ${await bridge.exited})`
|
||||||
|
broadcast(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBridge(server: Server<WsData>): void {
|
||||||
|
if (mock) {
|
||||||
|
setInterval(() => {
|
||||||
|
tickMock(state)
|
||||||
|
broadcast(server)
|
||||||
|
}, 100)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge = Bun.spawn(['python3', join(moduleDir, 'server', 'ros_bridge.py')], {
|
||||||
|
cwd: moduleDir,
|
||||||
|
stdin: 'pipe',
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'inherit',
|
||||||
|
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
||||||
|
})
|
||||||
|
void readBridge(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve<WsData>({
|
||||||
|
port,
|
||||||
|
hostname: '0.0.0.0',
|
||||||
|
async fetch(request, server) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === '/ws') {
|
||||||
|
return server.upgrade(request, { data: { connectedAt: Date.now() } })
|
||||||
|
? undefined
|
||||||
|
: new Response('WebSocket upgrade failed', { status: 400 })
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/health') {
|
||||||
|
return json({ ok: true, mode: state.mode, bridgeOnline: state.bridgeOnline, timestamp: Date.now() })
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/state') return json(state)
|
||||||
|
if (url.pathname === '/api/command' && request.method === 'POST') {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return json({ ok: false, error: 'invalid JSON' }, 400)
|
||||||
|
}
|
||||||
|
if (!isCommand(body)) return json({ ok: false, error: 'invalid command' }, 400)
|
||||||
|
if (!sendToBridge(body)) return json({ ok: false, error: 'ROS bridge is offline' }, 503)
|
||||||
|
broadcast(server)
|
||||||
|
return json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
|
||||||
|
if (relative.includes('..')) return new Response('Bad path', { status: 400 })
|
||||||
|
const file = Bun.file(join(moduleDir, 'dist', relative))
|
||||||
|
if (await file.exists()) return new Response(file)
|
||||||
|
const fallback = Bun.file(join(moduleDir, 'dist', 'index.html'))
|
||||||
|
return (await fallback.exists()) ? new Response(fallback) : new Response('Run bun run build first', { status: 404 })
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
open(ws) {
|
||||||
|
ws.subscribe('telemetry')
|
||||||
|
ws.send(JSON.stringify(state))
|
||||||
|
},
|
||||||
|
message(ws: ServerWebSocket<WsData>, message) {
|
||||||
|
try {
|
||||||
|
const command: unknown = JSON.parse(String(message))
|
||||||
|
if (isCommand(command)) sendToBridge(command)
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'invalid command' }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
startBridge(server)
|
||||||
|
console.log(`MVCH FMS: http://0.0.0.0:${port} (${mock ? 'mock' : 'ROS'})`)
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small JSON <-> ROS 2 bridge for the module G web interface.
|
||||||
|
|
||||||
|
stdin: one JSON command per line
|
||||||
|
stdout: one JSON state per line (only machine-readable data)
|
||||||
|
stderr: diagnostics
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from builtin_interfaces.msg import Duration
|
||||||
|
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
||||||
|
from nav_msgs.msg import OccupancyGrid, Odometry, Path
|
||||||
|
from rclpy.action import ActionClient
|
||||||
|
from rclpy.executors import ExternalShutdownException
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy
|
||||||
|
from sensor_msgs.msg import BatteryState, LaserScan
|
||||||
|
from std_msgs.msg import Float64, String
|
||||||
|
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nav2_msgs.action import NavigateToPose
|
||||||
|
except ImportError:
|
||||||
|
NavigateToPose = None
|
||||||
|
|
||||||
|
|
||||||
|
ROBOTS = ("RMC1", "RMC2")
|
||||||
|
MANUAL_TIMEOUT = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
def empty_robot():
|
||||||
|
return {
|
||||||
|
"online": False,
|
||||||
|
"lastMessageMs": None,
|
||||||
|
"pose": {"x": 0.0, "y": 0.0, "yaw": 0.0},
|
||||||
|
"velocity": {"x": 0.0, "y": 0.0, "yaw": 0.0},
|
||||||
|
"battery": None,
|
||||||
|
"lidar": [],
|
||||||
|
"trail": [],
|
||||||
|
"plan": [],
|
||||||
|
"goal": None,
|
||||||
|
"emergency": False,
|
||||||
|
"arucoId": None,
|
||||||
|
"liftStatus": None,
|
||||||
|
"gripper": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def yaw_from_quaternion(q):
|
||||||
|
siny = 2.0 * (q.w * q.z + q.x * q.y)
|
||||||
|
cosy = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
|
||||||
|
return math.atan2(siny, cosy)
|
||||||
|
|
||||||
|
|
||||||
|
def quaternion_from_yaw(yaw):
|
||||||
|
return 0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_angle(angle):
|
||||||
|
return math.atan2(math.sin(angle), math.cos(angle))
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, limit):
|
||||||
|
return max(-limit, min(limit, value))
|
||||||
|
|
||||||
|
|
||||||
|
class FmsBridge(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("mvch_fms_bridge")
|
||||||
|
self.commands = queue.Queue()
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.state = {
|
||||||
|
"type": "state",
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
"mode": "ros",
|
||||||
|
"bridgeOnline": True,
|
||||||
|
"bridgeError": None,
|
||||||
|
"map": None,
|
||||||
|
"robots": {robot: empty_robot() for robot in ROBOTS},
|
||||||
|
}
|
||||||
|
self.last_seen = {robot: 0.0 for robot in ROBOTS}
|
||||||
|
self.last_manual = {robot: 0.0 for robot in ROBOTS}
|
||||||
|
self.last_sent_zero = {robot: True for robot in ROBOTS}
|
||||||
|
self.pose_offsets = {robot: {"x": 0.0, "y": 0.0, "yaw": 0.0} for robot in ROBOTS}
|
||||||
|
self.raw_pose = {robot: {"x": 0.0, "y": 0.0, "yaw": 0.0} for robot in ROBOTS}
|
||||||
|
self.goal_handles = {robot: None for robot in ROBOTS}
|
||||||
|
self.simple_goals = {robot: None for robot in ROBOTS}
|
||||||
|
|
||||||
|
sensor_qos = QoSProfile(
|
||||||
|
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||||
|
durability=DurabilityPolicy.VOLATILE,
|
||||||
|
history=HistoryPolicy.KEEP_LAST,
|
||||||
|
depth=5,
|
||||||
|
)
|
||||||
|
map_qos = QoSProfile(
|
||||||
|
reliability=ReliabilityPolicy.RELIABLE,
|
||||||
|
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||||
|
history=HistoryPolicy.KEEP_LAST,
|
||||||
|
depth=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cmd_publishers = {}
|
||||||
|
self.pose_publishers = {}
|
||||||
|
self.goal_publishers = {}
|
||||||
|
self.nav_clients = {}
|
||||||
|
for robot in ROBOTS:
|
||||||
|
self.cmd_publishers[robot] = self.create_publisher(Twist, f"/{robot}/cmd_vel", 10)
|
||||||
|
self.pose_publishers[robot] = self.create_publisher(
|
||||||
|
PoseWithCovarianceStamped, f"/{robot}/initialpose", 10
|
||||||
|
)
|
||||||
|
self.goal_publishers[robot] = self.create_publisher(PoseStamped, f"/{robot}/goal_pose", 10)
|
||||||
|
if NavigateToPose is not None:
|
||||||
|
self.nav_clients[robot] = ActionClient(
|
||||||
|
self, NavigateToPose, f"/{robot}/navigate_to_pose"
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
Odometry,
|
||||||
|
f"/{robot}/odometry",
|
||||||
|
lambda msg, r=robot: self.on_odometry(r, msg),
|
||||||
|
sensor_qos,
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
LaserScan,
|
||||||
|
f"/{robot}/scan",
|
||||||
|
lambda msg, r=robot: self.on_scan(r, msg),
|
||||||
|
sensor_qos,
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
Path,
|
||||||
|
f"/{robot}/plan",
|
||||||
|
lambda msg, r=robot: self.on_path(r, msg),
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
BatteryState,
|
||||||
|
f"/{robot}/battery_state",
|
||||||
|
lambda msg, r=robot: self.on_battery(r, msg.voltage),
|
||||||
|
sensor_qos,
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
Float64,
|
||||||
|
f"/{robot}/battery_voltage",
|
||||||
|
lambda msg, r=robot: self.on_battery(r, msg.data),
|
||||||
|
sensor_qos,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.create_subscription(OccupancyGrid, "/map", self.on_map, map_qos)
|
||||||
|
self.create_subscription(OccupancyGrid, "/RMC1/map", self.on_map, map_qos)
|
||||||
|
self.create_subscription(String, "/RMC2/aruco_id", self.on_aruco, sensor_qos)
|
||||||
|
self.create_subscription(String, "/RMC2/lift_status", self.on_lift_status, 10)
|
||||||
|
self.lift_publisher = self.create_publisher(Float64, "/RMC2/lift", 10)
|
||||||
|
self.gripper_publisher = self.create_publisher(
|
||||||
|
JointTrajectory,
|
||||||
|
"/RMC1/arm95/gripper_trajectory_controller/joint_trajectory",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.create_timer(0.05, self.process_commands_and_watchdog)
|
||||||
|
self.create_timer(0.2, self.emit_state)
|
||||||
|
|
||||||
|
def mark_seen(self, robot):
|
||||||
|
self.last_seen[robot] = time.monotonic()
|
||||||
|
|
||||||
|
def on_odometry(self, robot, msg):
|
||||||
|
raw = {
|
||||||
|
"x": float(msg.pose.pose.position.x),
|
||||||
|
"y": float(msg.pose.pose.position.y),
|
||||||
|
"yaw": yaw_from_quaternion(msg.pose.pose.orientation),
|
||||||
|
}
|
||||||
|
with self.lock:
|
||||||
|
self.raw_pose[robot] = raw
|
||||||
|
offset = self.pose_offsets[robot]
|
||||||
|
pose = {
|
||||||
|
"x": raw["x"] + offset["x"],
|
||||||
|
"y": raw["y"] + offset["y"],
|
||||||
|
"yaw": raw["yaw"] + offset["yaw"],
|
||||||
|
}
|
||||||
|
rover = self.state["robots"][robot]
|
||||||
|
rover["pose"] = pose
|
||||||
|
rover["velocity"] = {
|
||||||
|
"x": float(msg.twist.twist.linear.x),
|
||||||
|
"y": float(msg.twist.twist.linear.y),
|
||||||
|
"yaw": float(msg.twist.twist.angular.z),
|
||||||
|
}
|
||||||
|
trail = rover["trail"]
|
||||||
|
if not trail or math.hypot(pose["x"] - trail[-1]["x"], pose["y"] - trail[-1]["y"]) > 0.04:
|
||||||
|
trail.append({"x": pose["x"], "y": pose["y"]})
|
||||||
|
del trail[:-250]
|
||||||
|
self.mark_seen(robot)
|
||||||
|
|
||||||
|
def on_scan(self, robot, msg):
|
||||||
|
with self.lock:
|
||||||
|
pose = self.state["robots"][robot]["pose"]
|
||||||
|
points = []
|
||||||
|
stride = max(1, len(msg.ranges) // 180)
|
||||||
|
for index in range(0, len(msg.ranges), stride):
|
||||||
|
distance = float(msg.ranges[index])
|
||||||
|
if not math.isfinite(distance) or distance < msg.range_min or distance > msg.range_max:
|
||||||
|
continue
|
||||||
|
angle = msg.angle_min + index * msg.angle_increment + pose["yaw"]
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"x": pose["x"] + math.cos(angle) * distance,
|
||||||
|
"y": pose["y"] + math.sin(angle) * distance,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.state["robots"][robot]["lidar"] = points[:200]
|
||||||
|
self.mark_seen(robot)
|
||||||
|
|
||||||
|
def on_path(self, robot, msg):
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"][robot]["plan"] = [
|
||||||
|
{"x": float(p.pose.position.x), "y": float(p.pose.position.y)}
|
||||||
|
for p in msg.poses[:: max(1, len(msg.poses) // 200)]
|
||||||
|
][:200]
|
||||||
|
|
||||||
|
def on_map(self, msg):
|
||||||
|
width, height = int(msg.info.width), int(msg.info.height)
|
||||||
|
if not width or not height:
|
||||||
|
return
|
||||||
|
step = max(1, math.ceil(max(width, height) / 120))
|
||||||
|
sampled = []
|
||||||
|
for y in range(0, height, step):
|
||||||
|
for x in range(0, width, step):
|
||||||
|
sampled.append(int(msg.data[y * width + x]))
|
||||||
|
with self.lock:
|
||||||
|
self.state["map"] = {
|
||||||
|
"width": math.ceil(width / step),
|
||||||
|
"height": math.ceil(height / step),
|
||||||
|
"resolution": float(msg.info.resolution) * step,
|
||||||
|
"origin": {
|
||||||
|
"x": float(msg.info.origin.position.x),
|
||||||
|
"y": float(msg.info.origin.position.y),
|
||||||
|
},
|
||||||
|
"data": sampled,
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_battery(self, robot, value):
|
||||||
|
if math.isfinite(float(value)):
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"][robot]["battery"] = round(float(value), 2)
|
||||||
|
|
||||||
|
def on_aruco(self, msg):
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"]["RMC2"]["arucoId"] = str(msg.data)
|
||||||
|
self.mark_seen("RMC2")
|
||||||
|
|
||||||
|
def on_lift_status(self, msg):
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"]["RMC2"]["liftStatus"] = str(msg.data)
|
||||||
|
|
||||||
|
def publish_twist(self, robot, x=0.0, y=0.0, yaw=0.0):
|
||||||
|
msg = Twist()
|
||||||
|
msg.linear.x = max(-0.7, min(0.7, float(x)))
|
||||||
|
msg.linear.y = max(-0.7, min(0.7, float(y))) if robot == "RMC1" else 0.0
|
||||||
|
msg.angular.z = max(-1.2, min(1.2, float(yaw)))
|
||||||
|
self.cmd_publishers[robot].publish(msg)
|
||||||
|
|
||||||
|
def process_commands_and_watchdog(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
command = self.commands.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
self.handle_command(command)
|
||||||
|
except Exception as error:
|
||||||
|
print(f"command error: {error}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with self.lock:
|
||||||
|
emergency = {r: self.state["robots"][r]["emergency"] for r in ROBOTS}
|
||||||
|
simple_goals = deepcopy(self.simple_goals)
|
||||||
|
for robot in ROBOTS:
|
||||||
|
stale = now - self.last_manual[robot] > MANUAL_TIMEOUT
|
||||||
|
if emergency[robot]:
|
||||||
|
self.publish_twist(robot)
|
||||||
|
self.last_sent_zero[robot] = True
|
||||||
|
elif simple_goals[robot] is not None:
|
||||||
|
self.drive_simple_goal(robot, simple_goals[robot])
|
||||||
|
elif stale and not self.last_sent_zero[robot]:
|
||||||
|
self.publish_twist(robot)
|
||||||
|
self.last_sent_zero[robot] = True
|
||||||
|
|
||||||
|
def handle_command(self, command):
|
||||||
|
kind = command.get("type")
|
||||||
|
robot = command.get("robot")
|
||||||
|
if robot not in ROBOTS:
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
emergency = self.state["robots"][robot]["emergency"]
|
||||||
|
|
||||||
|
if kind == "manual" and not emergency:
|
||||||
|
self.cancel_navigation(robot)
|
||||||
|
with self.lock:
|
||||||
|
self.simple_goals[robot] = None
|
||||||
|
self.state["robots"][robot]["goal"] = None
|
||||||
|
self.state["robots"][robot]["plan"] = []
|
||||||
|
self.publish_twist(robot, command.get("x", 0), command.get("y", 0), command.get("yaw", 0))
|
||||||
|
self.last_manual[robot] = time.monotonic()
|
||||||
|
self.last_sent_zero[robot] = False
|
||||||
|
elif kind == "stop":
|
||||||
|
self.cancel_navigation(robot)
|
||||||
|
with self.lock:
|
||||||
|
self.simple_goals[robot] = None
|
||||||
|
self.publish_twist(robot)
|
||||||
|
self.last_sent_zero[robot] = True
|
||||||
|
elif kind == "emergency":
|
||||||
|
active = bool(command.get("active"))
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"][robot]["emergency"] = active
|
||||||
|
self.publish_twist(robot)
|
||||||
|
self.last_sent_zero[robot] = True
|
||||||
|
if active:
|
||||||
|
self.cancel_navigation(robot)
|
||||||
|
with self.lock:
|
||||||
|
self.simple_goals[robot] = None
|
||||||
|
elif kind == "set_pose":
|
||||||
|
self.set_pose(robot, command.get("pose", {}))
|
||||||
|
elif kind == "set_goal" and not emergency:
|
||||||
|
self.set_goal(robot, command.get("pose", {}))
|
||||||
|
elif kind == "cancel_goal":
|
||||||
|
self.cancel_navigation(robot)
|
||||||
|
self.publish_twist(robot)
|
||||||
|
with self.lock:
|
||||||
|
self.simple_goals[robot] = None
|
||||||
|
self.state["robots"][robot]["goal"] = None
|
||||||
|
self.state["robots"][robot]["plan"] = []
|
||||||
|
elif kind == "lift" and robot == "RMC2" and not emergency:
|
||||||
|
height = 0.1 if float(command.get("height", 0)) >= 0.05 else 0.0
|
||||||
|
self.lift_publisher.publish(Float64(data=height))
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"][robot]["liftStatus"] = "command: up" if height else "command: down"
|
||||||
|
elif kind == "gripper" and robot == "RMC1" and not emergency:
|
||||||
|
self.set_gripper(command.get("state") == "open")
|
||||||
|
|
||||||
|
def set_pose(self, robot, pose):
|
||||||
|
x, y, yaw = float(pose.get("x", 0)), float(pose.get("y", 0)), float(pose.get("yaw", 0))
|
||||||
|
with self.lock:
|
||||||
|
raw = self.raw_pose[robot]
|
||||||
|
self.pose_offsets[robot] = {"x": x - raw["x"], "y": y - raw["y"], "yaw": yaw - raw["yaw"]}
|
||||||
|
self.state["robots"][robot]["pose"] = {"x": x, "y": y, "yaw": yaw}
|
||||||
|
self.state["robots"][robot]["trail"] = [{"x": x, "y": y}]
|
||||||
|
msg = PoseWithCovarianceStamped()
|
||||||
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
msg.header.frame_id = "map"
|
||||||
|
msg.pose.pose.position.x = x
|
||||||
|
msg.pose.pose.position.y = y
|
||||||
|
qx, qy, qz, qw = quaternion_from_yaw(yaw)
|
||||||
|
msg.pose.pose.orientation.x, msg.pose.pose.orientation.y = qx, qy
|
||||||
|
msg.pose.pose.orientation.z, msg.pose.pose.orientation.w = qz, qw
|
||||||
|
msg.pose.covariance[0] = msg.pose.covariance[7] = 0.25
|
||||||
|
msg.pose.covariance[35] = 0.07
|
||||||
|
self.pose_publishers[robot].publish(msg)
|
||||||
|
|
||||||
|
def make_goal(self, pose):
|
||||||
|
goal = PoseStamped()
|
||||||
|
goal.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
goal.header.frame_id = "map"
|
||||||
|
goal.pose.position.x = float(pose.get("x", 0))
|
||||||
|
goal.pose.position.y = float(pose.get("y", 0))
|
||||||
|
qx, qy, qz, qw = quaternion_from_yaw(float(pose.get("yaw", 0)))
|
||||||
|
goal.pose.orientation.x, goal.pose.orientation.y = qx, qy
|
||||||
|
goal.pose.orientation.z, goal.pose.orientation.w = qz, qw
|
||||||
|
return goal
|
||||||
|
|
||||||
|
def set_goal(self, robot, pose):
|
||||||
|
goal = self.make_goal(pose)
|
||||||
|
self.goal_publishers[robot].publish(goal)
|
||||||
|
target = {"x": goal.pose.position.x, "y": goal.pose.position.y, "yaw": float(pose.get("yaw", 0))}
|
||||||
|
with self.lock:
|
||||||
|
rover = self.state["robots"][robot]
|
||||||
|
rover["goal"] = target
|
||||||
|
if not rover["plan"]:
|
||||||
|
rover["plan"] = [
|
||||||
|
{"x": rover["pose"]["x"], "y": rover["pose"]["y"]},
|
||||||
|
{"x": target["x"], "y": target["y"]},
|
||||||
|
]
|
||||||
|
client = self.nav_clients.get(robot)
|
||||||
|
if client is not None and client.server_is_ready():
|
||||||
|
request = NavigateToPose.Goal()
|
||||||
|
request.pose = goal
|
||||||
|
future = client.send_goal_async(request)
|
||||||
|
future.add_done_callback(lambda f, r=robot: self.on_goal_response(r, f))
|
||||||
|
else:
|
||||||
|
with self.lock:
|
||||||
|
self.simple_goals[robot] = target
|
||||||
|
print(f"{robot}: Nav2 is unavailable; using odometry goal controller", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
def drive_simple_goal(self, robot, target):
|
||||||
|
with self.lock:
|
||||||
|
pose = deepcopy(self.state["robots"][robot]["pose"])
|
||||||
|
dx = target["x"] - pose["x"]
|
||||||
|
dy = target["y"] - pose["y"]
|
||||||
|
distance = math.hypot(dx, dy)
|
||||||
|
|
||||||
|
if distance < 0.12:
|
||||||
|
yaw_error = wrap_angle(target["yaw"] - pose["yaw"])
|
||||||
|
if abs(yaw_error) < 0.12:
|
||||||
|
self.publish_twist(robot)
|
||||||
|
with self.lock:
|
||||||
|
self.simple_goals[robot] = None
|
||||||
|
self.last_sent_zero[robot] = True
|
||||||
|
else:
|
||||||
|
self.publish_twist(robot, yaw=clamp(yaw_error * 1.5, 0.65))
|
||||||
|
self.last_sent_zero[robot] = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if robot == "RMC1":
|
||||||
|
cosine, sine = math.cos(pose["yaw"]), math.sin(pose["yaw"])
|
||||||
|
body_x = cosine * dx + sine * dy
|
||||||
|
body_y = -sine * dx + cosine * dy
|
||||||
|
self.publish_twist(
|
||||||
|
robot,
|
||||||
|
x=clamp(body_x * 0.7, 0.38),
|
||||||
|
y=clamp(body_y * 0.7, 0.38),
|
||||||
|
yaw=clamp(wrap_angle(target["yaw"] - pose["yaw"]), 0.45),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
heading_error = wrap_angle(math.atan2(dy, dx) - pose["yaw"])
|
||||||
|
if abs(heading_error) > 0.28:
|
||||||
|
self.publish_twist(robot, yaw=clamp(heading_error * 1.4, 0.7))
|
||||||
|
else:
|
||||||
|
self.publish_twist(
|
||||||
|
robot,
|
||||||
|
x=min(0.32, max(0.10, distance * 0.65)),
|
||||||
|
yaw=clamp(heading_error * 1.2, 0.45),
|
||||||
|
)
|
||||||
|
self.last_sent_zero[robot] = False
|
||||||
|
|
||||||
|
def on_goal_response(self, robot, future):
|
||||||
|
handle = future.result()
|
||||||
|
if handle.accepted:
|
||||||
|
self.goal_handles[robot] = handle
|
||||||
|
else:
|
||||||
|
print(f"{robot}: Nav2 rejected goal", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
def cancel_navigation(self, robot):
|
||||||
|
handle = self.goal_handles.get(robot)
|
||||||
|
if handle is not None:
|
||||||
|
handle.cancel_goal_async()
|
||||||
|
self.goal_handles[robot] = None
|
||||||
|
|
||||||
|
def set_gripper(self, opened):
|
||||||
|
msg = JointTrajectory()
|
||||||
|
msg.joint_names = ["left_joint", "right_joint"]
|
||||||
|
point = JointTrajectoryPoint()
|
||||||
|
value = 0.04 if opened else 0.0213
|
||||||
|
point.positions = [value, value]
|
||||||
|
point.time_from_start = Duration(sec=1)
|
||||||
|
msg.points = [point]
|
||||||
|
self.gripper_publisher.publish(msg)
|
||||||
|
with self.lock:
|
||||||
|
self.state["robots"]["RMC1"]["gripper"] = "open" if opened else "closed"
|
||||||
|
|
||||||
|
def emit_state(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
with self.lock:
|
||||||
|
for robot in ROBOTS:
|
||||||
|
age = now - self.last_seen[robot]
|
||||||
|
self.state["robots"][robot]["online"] = age < 2.0
|
||||||
|
self.state["robots"][robot]["lastMessageMs"] = round(age * 1000) if self.last_seen[robot] else None
|
||||||
|
self.state["timestamp"] = int(time.time() * 1000)
|
||||||
|
payload = deepcopy(self.state)
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_commands(node):
|
||||||
|
for line in sys.stdin:
|
||||||
|
try:
|
||||||
|
command = json.loads(line)
|
||||||
|
if isinstance(command, dict):
|
||||||
|
node.commands.put(command)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
print(f"invalid command JSON: {error}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rclpy.init()
|
||||||
|
node = FmsBridge()
|
||||||
|
threading.Thread(target=read_commands, args=(node,), daemon=True).start()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except (KeyboardInterrupt, ExternalShutdownException):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if rclpy.ok():
|
||||||
|
for robot in ROBOTS:
|
||||||
|
node.publish_twist(robot)
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { isCommand } from './commands'
|
||||||
|
import { applyMockCommand, initialState, tickMock } from './state'
|
||||||
|
|
||||||
|
describe('command validation', () => {
|
||||||
|
test('accepts valid commands', () => {
|
||||||
|
expect(isCommand({ type: 'manual', robot: 'RMC1', x: 0.4, y: 0.2, yaw: 0 })).toBe(true)
|
||||||
|
expect(isCommand({ type: 'lift', robot: 'RMC2', height: 0.1 })).toBe(true)
|
||||||
|
expect(isCommand({ type: 'gripper', robot: 'RMC1', state: 'closed' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects wrong robot tools and non-finite coordinates', () => {
|
||||||
|
expect(isCommand({ type: 'lift', robot: 'RMC1', height: 0.1 })).toBe(false)
|
||||||
|
expect(isCommand({ type: 'gripper', robot: 'RMC2', state: 'open' })).toBe(false)
|
||||||
|
expect(isCommand({ type: 'set_goal', robot: 'RMC1', pose: { x: Number.NaN, y: 0, yaw: 0 } })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mock state', () => {
|
||||||
|
test('emergency stop blocks motion and tool state is retained', () => {
|
||||||
|
const state = initialState('mock')
|
||||||
|
applyMockCommand(state, { type: 'manual', robot: 'RMC2', x: 0.5, y: 0, yaw: 0 })
|
||||||
|
tickMock(state, 1)
|
||||||
|
expect(state.robots.RMC2.pose.x).toBeCloseTo(0.5)
|
||||||
|
|
||||||
|
applyMockCommand(state, { type: 'lift', robot: 'RMC2', height: 0.1 })
|
||||||
|
applyMockCommand(state, { type: 'emergency', robot: 'RMC2', active: true })
|
||||||
|
expect(state.robots.RMC2.velocity.x).toBe(0)
|
||||||
|
expect(state.robots.RMC2.liftStatus).toBe('up')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { Command, FmsState, Point, RobotId, RobotState } from '../src/types'
|
||||||
|
|
||||||
|
const robot = (x: number, y: number): RobotState => ({
|
||||||
|
online: false,
|
||||||
|
lastMessageMs: null,
|
||||||
|
pose: { x, y, yaw: 0 },
|
||||||
|
velocity: { x: 0, y: 0, yaw: 0 },
|
||||||
|
battery: null,
|
||||||
|
lidar: [],
|
||||||
|
trail: [{ x, y }],
|
||||||
|
plan: [],
|
||||||
|
goal: null,
|
||||||
|
emergency: false,
|
||||||
|
arucoId: null,
|
||||||
|
liftStatus: null,
|
||||||
|
gripper: 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function initialState(mode: 'ros' | 'mock'): FmsState {
|
||||||
|
const mockMap = mode === 'mock' ? makeMockMap() : null
|
||||||
|
return {
|
||||||
|
type: 'state',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
mode,
|
||||||
|
bridgeOnline: mode === 'mock',
|
||||||
|
bridgeError: null,
|
||||||
|
map: mockMap,
|
||||||
|
robots: { RMC1: robot(-2, 3), RMC2: robot(0, 0) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockMap() {
|
||||||
|
const width = 60
|
||||||
|
const height = 60
|
||||||
|
const data = Array<number>(width * height).fill(0)
|
||||||
|
for (let row = 0; row < height; row += 1) {
|
||||||
|
for (let col = 0; col < width; col += 1) {
|
||||||
|
const border = row < 2 || col < 2 || row > height - 3 || col > width - 3
|
||||||
|
const shelf = col >= 7 && col <= 10 && row >= 8 && row <= 46
|
||||||
|
if (border || shelf) data[row * width + col] = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width, height, resolution: 0.1, origin: { x: -5.5, y: -0.5 }, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
||||||
|
|
||||||
|
export function applyMockCommand(state: FmsState, command: Command): void {
|
||||||
|
const rover = state.robots[command.robot]
|
||||||
|
if (!rover) return
|
||||||
|
|
||||||
|
if (command.type === 'emergency') {
|
||||||
|
rover.emergency = command.active
|
||||||
|
rover.velocity = { x: 0, y: 0, yaw: 0 }
|
||||||
|
} else if (command.type === 'stop' || command.type === 'cancel_goal') {
|
||||||
|
rover.velocity = { x: 0, y: 0, yaw: 0 }
|
||||||
|
if (command.type === 'cancel_goal') {
|
||||||
|
rover.goal = null
|
||||||
|
rover.plan = []
|
||||||
|
}
|
||||||
|
} else if (command.type === 'manual' && !rover.emergency) {
|
||||||
|
rover.velocity = {
|
||||||
|
x: clamp(command.x, -0.7, 0.7),
|
||||||
|
y: command.robot === 'RMC1' ? clamp(command.y, -0.7, 0.7) : 0,
|
||||||
|
yaw: clamp(command.yaw, -1.2, 1.2),
|
||||||
|
}
|
||||||
|
} else if (command.type === 'set_pose') {
|
||||||
|
rover.pose = command.pose
|
||||||
|
rover.trail = [{ x: command.pose.x, y: command.pose.y }]
|
||||||
|
} else if (command.type === 'set_goal') {
|
||||||
|
rover.goal = command.pose
|
||||||
|
rover.plan = [
|
||||||
|
{ x: rover.pose.x, y: rover.pose.y },
|
||||||
|
{ x: command.pose.x, y: command.pose.y },
|
||||||
|
]
|
||||||
|
} else if (command.type === 'lift') {
|
||||||
|
rover.liftStatus = command.height > 0 ? 'up' : 'down'
|
||||||
|
} else if (command.type === 'gripper') {
|
||||||
|
rover.gripper = command.state
|
||||||
|
}
|
||||||
|
state.timestamp = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tickMock(state: FmsState, dt = 0.1): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const id of ['RMC1', 'RMC2'] as RobotId[]) {
|
||||||
|
const rover = state.robots[id]
|
||||||
|
rover.online = true
|
||||||
|
rover.lastMessageMs = 0
|
||||||
|
rover.battery = id === 'RMC1' ? 24.4 : 23.9
|
||||||
|
rover.pose.yaw += rover.velocity.yaw * dt
|
||||||
|
const c = Math.cos(rover.pose.yaw)
|
||||||
|
const s = Math.sin(rover.pose.yaw)
|
||||||
|
rover.pose.x += (rover.velocity.x * c - rover.velocity.y * s) * dt
|
||||||
|
rover.pose.y += (rover.velocity.x * s + rover.velocity.y * c) * dt
|
||||||
|
if (rover.trail.length === 0 || Math.hypot(rover.pose.x - rover.trail.at(-1)!.x, rover.pose.y - rover.trail.at(-1)!.y) > 0.03) {
|
||||||
|
rover.trail.push({ x: rover.pose.x, y: rover.pose.y })
|
||||||
|
if (rover.trail.length > 180) rover.trail.shift()
|
||||||
|
}
|
||||||
|
const points: Point[] = []
|
||||||
|
for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / 28) {
|
||||||
|
const distance = 1.0 + 0.35 * Math.sin(angle * 3 + now / 1200)
|
||||||
|
points.push({
|
||||||
|
x: rover.pose.x + Math.cos(angle + rover.pose.yaw) * distance,
|
||||||
|
y: rover.pose.y + Math.sin(angle + rover.pose.yaw) * distance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rover.lidar = points
|
||||||
|
if (id === 'RMC2') {
|
||||||
|
const row = Math.max(0, Math.min(5, Math.round(-rover.pose.x)))
|
||||||
|
const col = Math.max(0, Math.min(5, Math.round(rover.pose.y)))
|
||||||
|
rover.arucoId = String(row * 6 + col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.timestamp = now
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { Command, FmsState, Point, Pose, RobotId, RobotState } from './types'
|
||||||
|
|
||||||
|
const WORLD = { minX: -5.5, maxX: 0.5, minY: -0.5, maxY: 5.5 }
|
||||||
|
const SVG_SIZE = 600
|
||||||
|
|
||||||
|
const emptyRobot: RobotState = {
|
||||||
|
online: false,
|
||||||
|
lastMessageMs: null,
|
||||||
|
pose: { x: 0, y: 0, yaw: 0 },
|
||||||
|
velocity: { x: 0, y: 0, yaw: 0 },
|
||||||
|
battery: null,
|
||||||
|
lidar: [],
|
||||||
|
trail: [],
|
||||||
|
plan: [],
|
||||||
|
goal: null,
|
||||||
|
emergency: false,
|
||||||
|
arucoId: null,
|
||||||
|
liftStatus: null,
|
||||||
|
gripper: 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: FmsState = {
|
||||||
|
type: 'state',
|
||||||
|
timestamp: 0,
|
||||||
|
mode: 'ros',
|
||||||
|
bridgeOnline: false,
|
||||||
|
bridgeError: null,
|
||||||
|
map: null,
|
||||||
|
robots: { RMC1: emptyRobot, RMC2: emptyRobot },
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToSvg(point: Point): Point {
|
||||||
|
return {
|
||||||
|
x: ((point.y - WORLD.minY) / (WORLD.maxY - WORLD.minY)) * SVG_SIZE,
|
||||||
|
y: ((point.x - WORLD.minX) / (WORLD.maxX - WORLD.minX)) * SVG_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function points(pointsValue: Point[]): string {
|
||||||
|
return pointsValue.map(worldToSvg).map((point) => `${point.x},${point.y}`).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(value: number | null, digits = 2): string {
|
||||||
|
return value == null || !Number.isFinite(value) ? '—' : value.toFixed(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, children, className = '' }: { title: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={`rounded-xl border border-slate-700 bg-slate-900/80 p-4 ${className}`}>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-400">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-1 text-xs text-slate-400">
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-2 py-2 text-sm text-slate-100 outline-none focus:border-cyan-500"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldMap({ state, selected }: { state: FmsState; selected: RobotId }) {
|
||||||
|
const occupied = useMemo(() => {
|
||||||
|
if (!state.map) return []
|
||||||
|
const result: { x: number; y: number; size: number; value: number }[] = []
|
||||||
|
for (let row = 0; row < state.map.height; row += 1) {
|
||||||
|
for (let col = 0; col < state.map.width; col += 1) {
|
||||||
|
const value = state.map.data[row * state.map.width + col]
|
||||||
|
if (value > 25) {
|
||||||
|
result.push({
|
||||||
|
x: state.map.origin.x + col * state.map.resolution,
|
||||||
|
y: state.map.origin.y + row * state.map.resolution,
|
||||||
|
size: state.map.resolution,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.slice(0, 5000)
|
||||||
|
}, [state.map])
|
||||||
|
|
||||||
|
const markers = Array.from({ length: 36 }, (_, id) => ({
|
||||||
|
id,
|
||||||
|
x: -Math.floor(id / 6),
|
||||||
|
y: id % 6,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-square min-h-[420px] overflow-hidden rounded-xl border border-slate-700 bg-slate-950">
|
||||||
|
<svg viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`} className="h-full w-full" aria-label="Карта поля и телеметрия роверов">
|
||||||
|
<rect width={SVG_SIZE} height={SVG_SIZE} fill="#020617" />
|
||||||
|
{occupied.map((cell, index) => {
|
||||||
|
const topLeft = worldToSvg({ x: cell.x, y: cell.y })
|
||||||
|
const bottomRight = worldToSvg({ x: cell.x + cell.size, y: cell.y + cell.size })
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`cell-${index}`}
|
||||||
|
x={Math.min(topLeft.x, bottomRight.x)}
|
||||||
|
y={Math.min(topLeft.y, bottomRight.y)}
|
||||||
|
width={Math.max(1, Math.abs(bottomRight.x - topLeft.x))}
|
||||||
|
height={Math.max(1, Math.abs(bottomRight.y - topLeft.y))}
|
||||||
|
fill={cell.value > 65 ? '#334155' : '#1e293b'}
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Array.from({ length: 6 }, (_, row) => Array.from({ length: 6 }, (_, col) => {
|
||||||
|
const here = worldToSvg({ x: -row, y: col })
|
||||||
|
const right = col < 5 ? worldToSvg({ x: -row, y: col + 1 }) : null
|
||||||
|
const down = row < 5 ? worldToSvg({ x: -(row + 1), y: col }) : null
|
||||||
|
return (
|
||||||
|
<g key={`edges-${row}-${col}`} stroke="#1e3a5f" strokeWidth="2">
|
||||||
|
{right && <line x1={here.x} y1={here.y} x2={right.x} y2={right.y} />}
|
||||||
|
{down && <line x1={here.x} y1={here.y} x2={down.x} y2={down.y} />}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => {
|
||||||
|
const rover = state.robots[id]
|
||||||
|
const color = id === 'RMC1' ? '#22d3ee' : '#f59e0b'
|
||||||
|
const center = worldToSvg(rover.pose)
|
||||||
|
const direction = rover.pose.yaw - Math.PI / 2
|
||||||
|
return (
|
||||||
|
<g key={id} opacity={id === selected ? 1 : 0.55}>
|
||||||
|
{rover.trail.length > 1 && <polyline points={points(rover.trail)} fill="none" stroke={color} strokeWidth="3" opacity="0.45" />}
|
||||||
|
{rover.plan.length > 1 && <polyline points={points(rover.plan)} fill="none" stroke="#a78bfa" strokeWidth="4" strokeDasharray="10 7" />}
|
||||||
|
{rover.lidar.map((point, index) => {
|
||||||
|
const p = worldToSvg(point)
|
||||||
|
return <circle key={`${id}-lidar-${index}`} cx={p.x} cy={p.y} r="2.2" fill={color} opacity="0.55" />
|
||||||
|
})}
|
||||||
|
{rover.goal && (() => {
|
||||||
|
const goal = worldToSvg(rover.goal)
|
||||||
|
return <g><circle cx={goal.x} cy={goal.y} r="12" fill="none" stroke="#a78bfa" strokeWidth="3" /><line x1={goal.x - 16} y1={goal.y} x2={goal.x + 16} y2={goal.y} stroke="#a78bfa" /><line x1={goal.x} y1={goal.y - 16} x2={goal.x} y2={goal.y + 16} stroke="#a78bfa" /></g>
|
||||||
|
})()}
|
||||||
|
<g transform={`translate(${center.x} ${center.y}) rotate(${direction * 180 / Math.PI})`}>
|
||||||
|
<circle r={id === selected ? 18 : 14} fill={color} stroke="#f8fafc" strokeWidth={id === selected ? 3 : 1} />
|
||||||
|
<path d="M 0 -25 L -7 -10 L 7 -10 Z" fill="#f8fafc" />
|
||||||
|
</g>
|
||||||
|
<text x={center.x + 21} y={center.y - 18} fill={color} fontSize="14" fontWeight="700">{id}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{markers.map((marker) => {
|
||||||
|
const point = worldToSvg(marker)
|
||||||
|
const current = state.robots.RMC2.arucoId === String(marker.id)
|
||||||
|
return (
|
||||||
|
<g key={marker.id}>
|
||||||
|
<rect x={point.x - 11} y={point.y - 11} width="22" height="22" rx="4" fill={current ? '#f59e0b' : '#0f172a'} stroke={current ? '#fbbf24' : '#475569'} strokeWidth="2" />
|
||||||
|
<text x={point.x} y={point.y + 4} textAnchor="middle" fill="#f8fafc" fontSize="10">{marker.id}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div className="absolute left-3 top-3 rounded-lg bg-slate-950/80 px-3 py-2 text-xs text-slate-300 backdrop-blur">
|
||||||
|
ArUco: шаг 1 м · X вниз · Y вправо
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-3 right-3 flex gap-3 rounded-lg bg-slate-950/80 px-3 py-2 text-xs backdrop-blur">
|
||||||
|
<span className="text-cyan-300">● RMC1</span><span className="text-amber-300">● RMC2</span><span className="text-violet-300">-- маршрут</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualControls({ robot, emergency, send }: { robot: RobotId; emergency: boolean; send: (command: Command) => void }) {
|
||||||
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (timer.current) clearInterval(timer.current)
|
||||||
|
timer.current = null
|
||||||
|
send({ type: 'stop', robot })
|
||||||
|
}, [robot, send])
|
||||||
|
|
||||||
|
const start = (x: number, y: number, yaw: number) => {
|
||||||
|
if (emergency) return
|
||||||
|
if (timer.current) clearInterval(timer.current)
|
||||||
|
const command: Command = { type: 'manual', robot, x, y, yaw }
|
||||||
|
send(command)
|
||||||
|
timer.current = setInterval(() => send(command), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => stop, [stop])
|
||||||
|
|
||||||
|
const button = 'select-none rounded-lg border border-slate-600 bg-slate-800 px-3 py-3 font-semibold text-slate-100 hover:bg-slate-700 active:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-40'
|
||||||
|
const handlers = (x: number, y: number, yaw: number) => ({
|
||||||
|
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
start(x, y, yaw)
|
||||||
|
},
|
||||||
|
onPointerUp: stop,
|
||||||
|
onPointerCancel: stop,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, 0.35, 0)}>↖ бок</button>
|
||||||
|
<button className={button} disabled={emergency} {...handlers(0.45, 0, 0)}>↑ W</button>
|
||||||
|
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, -0.35, 0)}>бок ↗</button>
|
||||||
|
<button className={button} disabled={emergency} {...handlers(0, 0, 0.75)}>↶ A</button>
|
||||||
|
<button className={`${button} border-red-700 bg-red-950 text-red-200`} onClick={stop}>STOP</button>
|
||||||
|
<button className={button} disabled={emergency} {...handlers(0, 0, -0.75)}>D ↷</button>
|
||||||
|
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, -0.35, 0)}>↙ бок</button>
|
||||||
|
<button className={button} disabled={emergency} {...handlers(-0.45, 0, 0)}>↓ S</button>
|
||||||
|
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, 0.35, 0)}>бок ↘</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [state, setState] = useState<FmsState>(initialState)
|
||||||
|
const [selected, setSelected] = useState<RobotId>('RMC1')
|
||||||
|
const [socketOnline, setSocketOnline] = useState(false)
|
||||||
|
const [message, setMessage] = useState('Подключение…')
|
||||||
|
const [poseForm, setPoseForm] = useState<Pose>({ x: 0, y: 0, yaw: 0 })
|
||||||
|
const [goalForm, setGoalForm] = useState<Pose>({ x: -2, y: 3, yaw: 0 })
|
||||||
|
|
||||||
|
const send = useCallback(async (command: Command) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/command', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
})
|
||||||
|
const result = await response.json() as { ok: boolean; error?: string }
|
||||||
|
setMessage(result.ok ? `Команда: ${command.type}` : `Ошибка: ${result.error}`)
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`API недоступен: ${String(error)}`)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let retry: ReturnType<typeof setTimeout>
|
||||||
|
let disposed = false
|
||||||
|
const connect = () => {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
socket = new WebSocket(`${protocol}//${location.host}/ws`)
|
||||||
|
socket.onopen = () => { setSocketOnline(true); setMessage('Телеметрия подключена') }
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as FmsState
|
||||||
|
if (data.type === 'state') setState(data)
|
||||||
|
} catch { /* ignore malformed packet */ }
|
||||||
|
}
|
||||||
|
socket.onclose = () => {
|
||||||
|
setSocketOnline(false)
|
||||||
|
if (!disposed) retry = setTimeout(connect, 1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
return () => { disposed = true; clearTimeout(retry); socket?.close() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pressed = new Set<string>()
|
||||||
|
const movement = () => {
|
||||||
|
let x = 0, y = 0, yaw = 0
|
||||||
|
if (pressed.has('w')) x += 0.45
|
||||||
|
if (pressed.has('s')) x -= 0.45
|
||||||
|
if (pressed.has('a')) yaw += 0.75
|
||||||
|
if (pressed.has('d')) yaw -= 0.75
|
||||||
|
if (selected === 'RMC1' && pressed.has('q')) y += 0.35
|
||||||
|
if (selected === 'RMC1' && pressed.has('e')) y -= 0.35
|
||||||
|
if (x || y || yaw) send({ type: 'manual', robot: selected, x, y, yaw })
|
||||||
|
else send({ type: 'stop', robot: selected })
|
||||||
|
}
|
||||||
|
const down = (event: KeyboardEvent) => {
|
||||||
|
if ((event.target as HTMLElement).tagName === 'INPUT') return
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
if (!['w', 'a', 's', 'd', 'q', 'e'].includes(key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
pressed.add(key)
|
||||||
|
movement()
|
||||||
|
}
|
||||||
|
const up = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
if (!pressed.delete(key)) return
|
||||||
|
movement()
|
||||||
|
}
|
||||||
|
const blur = () => { pressed.clear(); movement() }
|
||||||
|
const repeat = setInterval(() => { if (pressed.size) movement() }, 120)
|
||||||
|
window.addEventListener('keydown', down)
|
||||||
|
window.addEventListener('keyup', up)
|
||||||
|
window.addEventListener('blur', blur)
|
||||||
|
return () => {
|
||||||
|
clearInterval(repeat)
|
||||||
|
window.removeEventListener('keydown', down)
|
||||||
|
window.removeEventListener('keyup', up)
|
||||||
|
window.removeEventListener('blur', blur)
|
||||||
|
}
|
||||||
|
}, [selected, send])
|
||||||
|
|
||||||
|
const rover = state.robots[selected]
|
||||||
|
const fresh = Date.now() - state.timestamp < 1500
|
||||||
|
const setForm = (setter: React.Dispatch<React.SetStateAction<Pose>>, key: keyof Pose, value: number) => setter((current) => ({ ...current, [key]: value }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-slate-950 p-3 text-slate-100 lg:p-5">
|
||||||
|
<header className="mx-auto mb-4 flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
|
||||||
|
<div><h1 className="text-xl font-bold">MVCH FMS · модуль Г</h1><p className="text-sm text-slate-400">Управление, навигация и телеметрия RMC1 / RMC2</p></div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className={`rounded-full px-3 py-1.5 ${socketOnline && fresh ? 'bg-emerald-950 text-emerald-300' : 'bg-red-950 text-red-300'}`}>WebSocket: {socketOnline && fresh ? 'online' : 'offline'}</span>
|
||||||
|
<span className={`rounded-full px-3 py-1.5 ${state.bridgeOnline ? 'bg-emerald-950 text-emerald-300' : 'bg-amber-950 text-amber-300'}`}>Bridge: {state.bridgeOnline ? state.mode : 'offline'}</span>
|
||||||
|
<span className="max-w-sm truncate rounded-full bg-slate-800 px-3 py-1.5 text-slate-300">{state.bridgeError ?? message}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-auto grid max-w-[1600px] gap-4 xl:grid-cols-[minmax(600px,1fr)_420px]">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => (
|
||||||
|
<button key={id} onClick={() => { setSelected(id); setPoseForm(state.robots[id].pose) }} className={`flex-1 rounded-xl border px-4 py-3 text-left ${selected === id ? 'border-cyan-500 bg-cyan-950/60' : 'border-slate-700 bg-slate-900'}`}>
|
||||||
|
<span className="font-bold">{id}</span>
|
||||||
|
<span className={`ml-3 text-xs ${state.robots[id].online ? 'text-emerald-400' : 'text-red-400'}`}>{state.robots[id].online ? 'ROS online' : 'нет данных'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FieldMap state={state} selected={selected} />
|
||||||
|
<Card title="Состояние двух роверов">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm lg:grid-cols-4">
|
||||||
|
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => <div key={id} className="rounded-lg bg-slate-950 p-3"><strong>{id}</strong><div className="mt-1 text-slate-400">x {format(state.robots[id].pose.x)} · y {format(state.robots[id].pose.y)}</div><div className="text-slate-400">yaw {format(state.robots[id].pose.yaw)} rad</div></div>)}
|
||||||
|
<div className="rounded-lg bg-slate-950 p-3"><strong>Карта</strong><div className="mt-1 text-slate-400">{state.map ? `${state.map.width} × ${state.map.height}` : 'нет /map'}</div></div>
|
||||||
|
<div className="rounded-lg bg-slate-950 p-3"><strong>Обновление</strong><div className="mt-1 text-slate-400">{state.timestamp ? new Date(state.timestamp).toLocaleTimeString() : '—'}</div></div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="grid content-start gap-4">
|
||||||
|
<Card title={`Ручное управление ${selected}`}>
|
||||||
|
<ManualControls robot={selected} emergency={rover.emergency} send={send} />
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Клавиши: W/S — ход, A/D — поворот, Q/E — боковой ход RMC1. При отпускании отправляется STOP.</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<button onClick={() => send({ type: 'emergency', robot: selected, active: !rover.emergency })} className={`rounded-xl border-2 px-5 py-5 text-lg font-black tracking-wide ${rover.emergency ? 'border-emerald-500 bg-emerald-950 text-emerald-300' : 'border-red-500 bg-red-950 text-red-200'}`}>
|
||||||
|
{rover.emergency ? 'СНЯТЬ АВАРИЙНЫЙ СТОП' : 'АВАРИЙНЫЙ СТОП'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Card title="Телеметрия">
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<dt className="text-slate-400">Позиция</dt><dd>x {format(rover.pose.x)} · y {format(rover.pose.y)}</dd>
|
||||||
|
<dt className="text-slate-400">Ориентация</dt><dd>{format(rover.pose.yaw)} rad</dd>
|
||||||
|
<dt className="text-slate-400">Скорость</dt><dd>{format(rover.velocity.x)} м/с</dd>
|
||||||
|
<dt className="text-slate-400">Лидар</dt><dd>{rover.lidar.length} точек</dd>
|
||||||
|
<dt className="text-slate-400">Аккумулятор</dt><dd>{rover.battery == null ? 'нет топика' : `${format(rover.battery)} В`}</dd>
|
||||||
|
<dt className="text-slate-400">ArUco</dt><dd>{rover.arucoId ?? '—'}</dd>
|
||||||
|
<dt className="text-slate-400">Инструмент</dt><dd>{selected === 'RMC1' ? rover.gripper : (rover.liftStatus ?? '—')}</dd>
|
||||||
|
<dt className="text-slate-400">Возраст данных</dt><dd>{rover.lastMessageMs == null ? '—' : `${rover.lastMessageMs} мс`}</dd>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Установить текущее положение">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NumberField label="X, м" value={poseForm.x} onChange={(value) => setForm(setPoseForm, 'x', value)} />
|
||||||
|
<NumberField label="Y, м" value={poseForm.y} onChange={(value) => setForm(setPoseForm, 'y', value)} />
|
||||||
|
<NumberField label="Yaw, рад" value={poseForm.yaw} onChange={(value) => setForm(setPoseForm, 'yaw', value)} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => send({ type: 'set_pose', robot: selected, pose: poseForm })} className="mt-3 w-full rounded-lg bg-slate-700 px-4 py-2 font-semibold hover:bg-slate-600">Применить без движения</button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Автономная цель Nav2">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NumberField label="X, м" value={goalForm.x} onChange={(value) => setForm(setGoalForm, 'x', value)} />
|
||||||
|
<NumberField label="Y, м" value={goalForm.y} onChange={(value) => setForm(setGoalForm, 'y', value)} />
|
||||||
|
<NumberField label="Yaw, рад" value={goalForm.yaw} onChange={(value) => setForm(setGoalForm, 'yaw', value)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<button disabled={rover.emergency} onClick={() => send({ type: 'set_goal', robot: selected, pose: goalForm })} className="rounded-lg bg-violet-700 px-3 py-2 font-semibold hover:bg-violet-600 disabled:opacity-40">Ехать к точке</button>
|
||||||
|
<button onClick={() => send({ type: 'cancel_goal', robot: selected })} className="rounded-lg bg-slate-700 px-3 py-2 font-semibold hover:bg-slate-600">Отменить</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Рабочий инструмент">
|
||||||
|
{selected === 'RMC1' ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2"><button disabled={rover.emergency} onClick={() => send({ type: 'gripper', robot: 'RMC1', state: 'open' })} className="rounded-lg bg-cyan-800 px-3 py-2 disabled:opacity-40">Открыть схват</button><button disabled={rover.emergency} onClick={() => send({ type: 'gripper', robot: 'RMC1', state: 'closed' })} className="rounded-lg bg-cyan-800 px-3 py-2 disabled:opacity-40">Закрыть схват</button></div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2"><button disabled={rover.emergency} onClick={() => send({ type: 'lift', robot: 'RMC2', height: 0.1 })} className="rounded-lg bg-amber-800 px-3 py-2 disabled:opacity-40">Поднять лифт</button><button disabled={rover.emergency} onClick={() => send({ type: 'lift', robot: 'RMC2', height: 0 })} className="rounded-lg bg-amber-800 px-3 py-2 disabled:opacity-40">Опустить лифт</button></div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: #020617;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { touch-action: manipulation; }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export type RobotId = 'RMC1' | 'RMC2'
|
||||||
|
|
||||||
|
export type Point = { x: number; y: number }
|
||||||
|
export type Pose = Point & { yaw: number }
|
||||||
|
|
||||||
|
export type RobotState = {
|
||||||
|
online: boolean
|
||||||
|
lastMessageMs: number | null
|
||||||
|
pose: Pose
|
||||||
|
velocity: { x: number; y: number; yaw: number }
|
||||||
|
battery: number | null
|
||||||
|
lidar: Point[]
|
||||||
|
trail: Point[]
|
||||||
|
plan: Point[]
|
||||||
|
goal: Pose | null
|
||||||
|
emergency: boolean
|
||||||
|
arucoId: string | null
|
||||||
|
liftStatus: string | null
|
||||||
|
gripper: 'open' | 'closed' | 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapState = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
resolution: number
|
||||||
|
origin: Point
|
||||||
|
data: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FmsState = {
|
||||||
|
type: 'state'
|
||||||
|
timestamp: number
|
||||||
|
mode: 'ros' | 'mock'
|
||||||
|
bridgeOnline: boolean
|
||||||
|
bridgeError: string | null
|
||||||
|
map: MapState | null
|
||||||
|
robots: Record<RobotId, RobotState>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Command =
|
||||||
|
| { type: 'manual'; robot: RobotId; x: number; y: number; yaw: number }
|
||||||
|
| { type: 'stop'; robot: RobotId }
|
||||||
|
| { type: 'emergency'; robot: RobotId; active: boolean }
|
||||||
|
| { type: 'set_pose'; robot: RobotId; pose: Pose }
|
||||||
|
| { type: 'set_goal'; robot: RobotId; pose: Pose }
|
||||||
|
| { type: 'cancel_goal'; robot: RobotId }
|
||||||
|
| { type: 'lift'; robot: 'RMC2'; height: 0 | 0.1 }
|
||||||
|
| { type: 'gripper'; robot: 'RMC1'; state: 'open' | 'closed' }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "server", "scripts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:3001',
|
||||||
|
'/ws': { target: 'ws://127.0.0.1:3001', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Модуль Д — складской кейс RMC1 + RMC2
|
||||||
|
|
||||||
|
Один запускаемый скрипт координирует два ровера в сценарии из конкурсного
|
||||||
|
задания:
|
||||||
|
|
||||||
|
1. RMC2 едет к стеллажу, поднимает его и отвозит в точку комплектации.
|
||||||
|
2. RMC2 опускает стеллаж и отъезжает на соседнюю свободную точку.
|
||||||
|
3. RMC1 подъезжает к стеллажу, YOLO находит заданную деталь, ARM95 берёт её.
|
||||||
|
4. RMC1 отвозит деталь к полке сдачи и кладёт её.
|
||||||
|
5. RMC2 возвращается под стеллаж и отвозит его на хранение.
|
||||||
|
6. Оба ровера последовательно возвращаются на свои стартовые точки.
|
||||||
|
7. В терминал и JSONL-лог выводится `MISSION_FINISHED`.
|
||||||
|
|
||||||
|
Решение специально последовательное: это медленнее параллельного управления,
|
||||||
|
зато уменьшает риск столкновения RMC1 и RMC2. Маршруты строятся BFS по графу
|
||||||
|
ArUco 6 × 6, занятые клетки исключаются. Движение замыкается по odometry,
|
||||||
|
`scan_front` вызывает остановку перед препятствием, команды скорости сглаживаются.
|
||||||
|
Соседняя парковочная точка RMC2 автоматически берётся из построенного пути к
|
||||||
|
комплектации, отдельный параметр для неё не нужен.
|
||||||
|
|
||||||
|
## Учебный сценарий симулятора
|
||||||
|
|
||||||
|
Дефолты образуют тренировочный сценарий на штатной геометрии `module5.launch.py`.
|
||||||
|
Стартовые позиции, стеллаж и стол взяты из сцены; свободная точка комплектации
|
||||||
|
выбрана нами для теста:
|
||||||
|
|
||||||
|
| Объект | ArUco ID | Координата поля |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| старт RMC2 | 0 | `(0, 0)` |
|
||||||
|
| старт RMC1 | 15 | `(-2, 3)` |
|
||||||
|
| выбранный стеллаж | 34 | `(-5, 4)` |
|
||||||
|
| комплектация | 16 | `(-2, 4)` |
|
||||||
|
| подход RMC1 к стеллажу | 10 | `(-1, 4)` |
|
||||||
|
| полка сдачи | 5 | `(0, 5)` |
|
||||||
|
| подход RMC1 к полке | 4 | `(0, 4)` |
|
||||||
|
| другие стеллажи | 31, 35 | занятые клетки |
|
||||||
|
|
||||||
|
Это только удобный тренировочный пресет. На зачётной попытке ID точек сообщает
|
||||||
|
эксперт — передайте их аргументами, не редактируя код. Подход RMC1 должен быть
|
||||||
|
соседней свободной клеткой с рабочей стороны стеллажа/полки.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Терминал 1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
ros2 launch ar_webots_fms_ros2 module5.launch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Терминал 2. Используется уже настроенная venv модуля В с YOLO и `numpy<2`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/scripts/mvch
|
||||||
|
git pull --ff-only
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source ~/ros2_ws/install/setup.bash
|
||||||
|
source module3/.venv/bin/activate
|
||||||
|
python3 module5/module5.py --target 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед движением программа показывает все точки и маршруты. После проверки
|
||||||
|
нажмите Enter. Классы цели: `1=hammer`, `2=wrench`, `3=pliers`.
|
||||||
|
|
||||||
|
Пример с точками эксперта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 module5/module5.py --target wrench \
|
||||||
|
--rmc1-start 15 --rmc2-start 0 \
|
||||||
|
--rack 34 --assembly 16 --assembly-approach 10 \
|
||||||
|
--delivery 5 --delivery-approach 4 --blocked 31,35
|
||||||
|
```
|
||||||
|
|
||||||
|
Только маршруты, без ROS и движения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 module5/module5.py --dry-run --target pliers
|
||||||
|
```
|
||||||
|
|
||||||
|
Тест платформ и лифта без YOLO/MoveIt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 module5/module5.py --target hammer --skip-arm
|
||||||
|
```
|
||||||
|
|
||||||
|
`--auto` убирает только начальное подтверждение. На первой тренировке его не
|
||||||
|
использовать.
|
||||||
|
|
||||||
|
## Аварийная остановка
|
||||||
|
|
||||||
|
В терминале скрипта доступны `stop`, `resume`, `abort`. Внешнее управление:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic pub /mvch/module5/emergency_stop std_msgs/msg/Bool '{data: true}' --once
|
||||||
|
ros2 topic pub /mvch/module5/emergency_stop std_msgs/msg/Bool '{data: false}' --once
|
||||||
|
ros2 topic pub /mvch/module5/command std_msgs/msg/String '{data: abort}' --once
|
||||||
|
```
|
||||||
|
|
||||||
|
Ctrl+C и `abort` публикуют нулевую скорость обоим роверам. Штатный драйвер
|
||||||
|
Webots не имеет аппаратного watchdog: после SIGKILL дополнительно остановите
|
||||||
|
симуляцию вручную.
|
||||||
|
|
||||||
|
## Калибровка ARM95
|
||||||
|
|
||||||
|
Захват переиспользует проверенный код `module3/module3.py` и веса
|
||||||
|
`module3/models/latest.pt`. При подходе к полке RMC1 разворачивается к её центру,
|
||||||
|
а точка выкладки задаётся относительно `Base_link`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--drop-arm-x 0.55
|
||||||
|
--drop-arm-y 0.0
|
||||||
|
--drop-arm-z 0.220
|
||||||
|
--drop-arm-yaw 1.5708
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти четыре значения обязательно проверить в симуляторе и затем на реальном
|
||||||
|
поле: геометрия полки может отличаться. Если ARM95 пока не откалиброван,
|
||||||
|
`--skip-arm` позволяет отдельно проверить всю логистику RMC1/RMC2.
|
||||||
|
|
||||||
|
## Логи и критерии
|
||||||
|
|
||||||
|
Логи сохраняются в `module5/logs/module5_*.jsonl` и не попадают в Git. В них
|
||||||
|
есть старт/конец, маршруты, достигнутые маркеры, остановки по лидару, команды
|
||||||
|
лифта, класс/confidence YOLO и этапы манипулятора.
|
||||||
|
|
||||||
|
Практически закрываются: все перемещения двух РМК, подъём/возврат стеллажа,
|
||||||
|
захват/сдача детали, один запускаемый скрипт, безопасность, логирование, Git и
|
||||||
|
README. Результат физического захвата, отсутствие падения детали и время до
|
||||||
|
трёх минут подтверждаются только контрольным заездом.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,921 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Модуль Д: один скрипт для складского кейса RMC1 + RMC2."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parent
|
||||||
|
ROWS = 6
|
||||||
|
COLS = 6
|
||||||
|
MARKER_SPACING = 1.0
|
||||||
|
ROBOT_NAMES = ("RMC1", "RMC2")
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, limit):
|
||||||
|
return max(-limit, min(limit, value))
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_angle(value):
|
||||||
|
return math.atan2(math.sin(value), math.cos(value))
|
||||||
|
|
||||||
|
|
||||||
|
def marker_xy(marker_id):
|
||||||
|
"""Координаты поля: 0=(0,0), 6=(-1,0), 1=(0,1)."""
|
||||||
|
row, column = divmod(marker_id, COLS)
|
||||||
|
return -row * MARKER_SPACING, column * MARKER_SPACING
|
||||||
|
|
||||||
|
|
||||||
|
def neighbours(marker_id):
|
||||||
|
row, column = divmod(marker_id, COLS)
|
||||||
|
result = []
|
||||||
|
for dr, dc in ((-1, 0), (0, -1), (0, 1), (1, 0)):
|
||||||
|
nr, nc = row + dr, column + dc
|
||||||
|
if 0 <= nr < ROWS and 0 <= nc < COLS:
|
||||||
|
result.append(nr * COLS + nc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def shortest_path(start, goal, blocked=()):
|
||||||
|
"""BFS по регулярному графу ArUco."""
|
||||||
|
forbidden = set(blocked) - {start, goal}
|
||||||
|
queue = deque([start])
|
||||||
|
previous = {start: None}
|
||||||
|
while queue:
|
||||||
|
current = queue.popleft()
|
||||||
|
if current == goal:
|
||||||
|
break
|
||||||
|
for candidate in neighbours(current):
|
||||||
|
if candidate not in forbidden and candidate not in previous:
|
||||||
|
previous[candidate] = current
|
||||||
|
queue.append(candidate)
|
||||||
|
if goal not in previous:
|
||||||
|
raise ValueError(f"Нет маршрута {start} -> {goal}; занято: {sorted(forbidden)}")
|
||||||
|
route = []
|
||||||
|
current = goal
|
||||||
|
while current is not None:
|
||||||
|
route.append(current)
|
||||||
|
current = previous[current]
|
||||||
|
return list(reversed(route))
|
||||||
|
|
||||||
|
|
||||||
|
def face_marker(position, target):
|
||||||
|
x1, y1 = marker_xy(position)
|
||||||
|
x2, y2 = marker_xy(target)
|
||||||
|
return math.atan2(y2 - y1, x2 - x1)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markers(value):
|
||||||
|
if not value.strip():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
return {int(item.strip()) for item in value.split(",")}
|
||||||
|
except ValueError as error:
|
||||||
|
raise argparse.ArgumentTypeError("Нужны ID через запятую, например 31,35") from error
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog:
|
||||||
|
def __init__(self):
|
||||||
|
directory = HERE / "logs"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = directory / f"module5_{datetime.now():%Y%m%d_%H%M%S}.jsonl"
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def write(self, event, **fields):
|
||||||
|
record = {"time": datetime.now().isoformat(timespec="milliseconds"), "event": event}
|
||||||
|
record.update(fields)
|
||||||
|
line = json.dumps(record, ensure_ascii=False)
|
||||||
|
with self.lock:
|
||||||
|
print(line, flush=True)
|
||||||
|
with self.path.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write(line + "\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scenario:
|
||||||
|
rmc1_start: int
|
||||||
|
rmc2_start: int
|
||||||
|
rack: int
|
||||||
|
assembly: int
|
||||||
|
assembly_approach: int
|
||||||
|
delivery: int
|
||||||
|
delivery_approach: int
|
||||||
|
blocked: set
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
points = {
|
||||||
|
"rmc1-start": self.rmc1_start,
|
||||||
|
"rmc2-start": self.rmc2_start,
|
||||||
|
"rack": self.rack,
|
||||||
|
"assembly": self.assembly,
|
||||||
|
"assembly-approach": self.assembly_approach,
|
||||||
|
"delivery": self.delivery,
|
||||||
|
"delivery-approach": self.delivery_approach,
|
||||||
|
}
|
||||||
|
for name, marker in points.items():
|
||||||
|
if marker not in range(ROWS * COLS):
|
||||||
|
raise ValueError(f"{name}: ID {marker} вне диапазона 0..35")
|
||||||
|
if self.assembly_approach not in neighbours(self.assembly):
|
||||||
|
raise ValueError("assembly-approach должен быть соседней ячейкой")
|
||||||
|
if self.delivery_approach not in neighbours(self.delivery):
|
||||||
|
raise ValueError("delivery-approach должен быть соседней ячейкой")
|
||||||
|
if self.assembly in self.blocked or self.rack in self.blocked:
|
||||||
|
raise ValueError("Активный стеллаж и точка комплектации не должны быть в --blocked")
|
||||||
|
if self.rack == self.assembly:
|
||||||
|
raise ValueError("Стеллаж и точка комплектации должны отличаться")
|
||||||
|
|
||||||
|
def routes(self):
|
||||||
|
# Полка сдачи и чужие стеллажи считаются препятствиями для RMC2.
|
||||||
|
rmc2_blocked = self.blocked | {self.delivery}
|
||||||
|
# Для RMC1 стеллаж уже стоит в комплектации, а полка занята физически.
|
||||||
|
rmc1_blocked = self.blocked | {self.assembly, self.delivery}
|
||||||
|
to_assembly = shortest_path(self.rack, self.assembly, rmc2_blocked)
|
||||||
|
parking = to_assembly[-2]
|
||||||
|
return {
|
||||||
|
"rmc2_to_rack": shortest_path(self.rmc2_start, self.rack, rmc2_blocked),
|
||||||
|
"rmc2_to_assembly": to_assembly,
|
||||||
|
"rmc2_clear_assembly": [self.assembly, parking],
|
||||||
|
"rmc2_back_to_assembly": [parking, self.assembly],
|
||||||
|
"rmc1_to_assembly": shortest_path(
|
||||||
|
self.rmc1_start, self.assembly_approach, rmc1_blocked
|
||||||
|
),
|
||||||
|
"rmc1_to_delivery": shortest_path(
|
||||||
|
self.assembly_approach, self.delivery_approach, rmc1_blocked
|
||||||
|
),
|
||||||
|
"rmc2_return_rack": shortest_path(self.assembly, self.rack, rmc2_blocked),
|
||||||
|
"rmc1_return": shortest_path(
|
||||||
|
self.delivery_approach, self.rmc1_start, rmc1_blocked
|
||||||
|
),
|
||||||
|
"rmc2_return": shortest_path(self.rack, self.rmc2_start, rmc2_blocked),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Anchor:
|
||||||
|
world_x: float
|
||||||
|
world_y: float
|
||||||
|
world_yaw: float
|
||||||
|
odom_x: float
|
||||||
|
odom_y: float
|
||||||
|
odom_yaw: float
|
||||||
|
|
||||||
|
def to_odom(self, world_x, world_y, world_yaw):
|
||||||
|
dx, dy = world_x - self.world_x, world_y - self.world_y
|
||||||
|
rotation = self.odom_yaw - self.world_yaw
|
||||||
|
cosine, sine = math.cos(rotation), math.sin(rotation)
|
||||||
|
return (
|
||||||
|
self.odom_x + cosine * dx - sine * dy,
|
||||||
|
self.odom_y + sine * dx + cosine * dy,
|
||||||
|
wrap_angle(self.odom_yaw + world_yaw - self.world_yaw),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--target", help="1/hammer, 2/wrench, 3/pliers")
|
||||||
|
parser.add_argument("--rmc1-start", type=int, default=15)
|
||||||
|
parser.add_argument("--rmc2-start", type=int, default=0)
|
||||||
|
parser.add_argument("--rack", type=int, default=34)
|
||||||
|
parser.add_argument("--assembly", type=int, default=16)
|
||||||
|
parser.add_argument("--assembly-approach", type=int, default=10)
|
||||||
|
parser.add_argument("--delivery", type=int, default=5)
|
||||||
|
parser.add_argument("--delivery-approach", type=int, default=4)
|
||||||
|
parser.add_argument("--blocked", type=parse_markers, default={31, 35})
|
||||||
|
parser.add_argument("--rmc1-start-yaw", type=float, default=math.pi)
|
||||||
|
parser.add_argument("--rmc2-start-yaw", type=float, default=math.pi)
|
||||||
|
parser.add_argument("--rack-yaw", type=float, default=math.pi)
|
||||||
|
parser.add_argument("--auto", action="store_true", help="Не ждать Enter перед стартом")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Показать сценарий без ROS")
|
||||||
|
parser.add_argument("--skip-arm", action="store_true", help="Только роверы и лифт")
|
||||||
|
parser.add_argument("--weights", default="module3/models/latest.pt")
|
||||||
|
parser.add_argument(
|
||||||
|
"--camera-topic", default="/RMC1/arm95/camera_gripper/image_color"
|
||||||
|
)
|
||||||
|
parser.add_argument("--conf", type=float, default=0.20)
|
||||||
|
parser.add_argument("--vision-timeout", type=float, default=60.0)
|
||||||
|
parser.add_argument("--plane-z", type=float, default=0.128)
|
||||||
|
parser.add_argument("--pick-z", type=float, default=0.210)
|
||||||
|
parser.add_argument("--approach-z", type=float, default=0.38)
|
||||||
|
parser.add_argument("--drop-arm-x", type=float, default=0.55)
|
||||||
|
parser.add_argument("--drop-arm-y", type=float, default=0.0)
|
||||||
|
parser.add_argument("--drop-arm-z", type=float, default=0.220)
|
||||||
|
parser.add_argument("--drop-arm-yaw", type=float, default=math.pi / 2)
|
||||||
|
parser.add_argument("--sensor-timeout", type=float, default=3.0)
|
||||||
|
parser.add_argument("--waypoint-timeout", type=float, default=35.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not 0.0 <= args.conf <= 1.0:
|
||||||
|
parser.error("--conf должен быть от 0 до 1")
|
||||||
|
if args.approach_z <= max(args.pick_z, args.drop_arm_z):
|
||||||
|
parser.error("--approach-z должен быть выше pick/drop")
|
||||||
|
if min(args.sensor_timeout, args.waypoint_timeout, args.vision_timeout) <= 0:
|
||||||
|
parser.error("таймауты должны быть положительными")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def make_scenario(args):
|
||||||
|
scenario = Scenario(
|
||||||
|
args.rmc1_start,
|
||||||
|
args.rmc2_start,
|
||||||
|
args.rack,
|
||||||
|
args.assembly,
|
||||||
|
args.assembly_approach,
|
||||||
|
args.delivery,
|
||||||
|
args.delivery_approach,
|
||||||
|
set(args.blocked),
|
||||||
|
)
|
||||||
|
scenario.validate()
|
||||||
|
return scenario
|
||||||
|
|
||||||
|
|
||||||
|
def print_plan(scenario, routes, target):
|
||||||
|
print("\n=== МОДУЛЬ Д: ПЛАН ДО ЗАПУСКА ===")
|
||||||
|
print(f"Целевая деталь: {target}")
|
||||||
|
print(
|
||||||
|
f"RMC2: {scenario.rmc2_start} -> стеллаж {scenario.rack} -> "
|
||||||
|
f"комплектация {scenario.assembly} -> {scenario.rack} -> {scenario.rmc2_start}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"RMC1: {scenario.rmc1_start} -> подход {scenario.assembly_approach} -> "
|
||||||
|
f"сдача через {scenario.delivery_approach} -> {scenario.rmc1_start}"
|
||||||
|
)
|
||||||
|
for name, route in routes.items():
|
||||||
|
print(f"{name}: {route}")
|
||||||
|
print("Приоритет: STOP -> безопасность -> точность -> скорость")
|
||||||
|
print("Команды: stop, resume, abort. Внешний стоп: /mvch/module5/emergency_stop")
|
||||||
|
print("====================================\n")
|
||||||
|
|
||||||
|
|
||||||
|
def build_ros_node(args, event_log):
|
||||||
|
import rclpy
|
||||||
|
from geometry_msgs.msg import Twist
|
||||||
|
from nav_msgs.msg import Odometry
|
||||||
|
from rclpy.executors import MultiThreadedExecutor
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from sensor_msgs.msg import LaserScan
|
||||||
|
from std_msgs.msg import Bool, Float64, String
|
||||||
|
|
||||||
|
if not rclpy.ok():
|
||||||
|
rclpy.init()
|
||||||
|
|
||||||
|
class WarehouseNode(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("mvch_module5")
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.poses = {name: None for name in ROBOT_NAMES}
|
||||||
|
self.odom_seen = {name: 0.0 for name in ROBOT_NAMES}
|
||||||
|
self.front_distance = {name: math.inf for name in ROBOT_NAMES}
|
||||||
|
self.scan_seen = {name: 0.0 for name in ROBOT_NAMES}
|
||||||
|
self.last_command = {name: (0.0, 0.0, 0.0, time.monotonic()) for name in ROBOT_NAMES}
|
||||||
|
self.emergency = threading.Event()
|
||||||
|
self.abort = threading.Event()
|
||||||
|
self.lift_status = "unknown"
|
||||||
|
self.lift_updates = 0
|
||||||
|
self.cmd_publishers = {
|
||||||
|
name: self.create_publisher(Twist, f"/{name}/cmd_vel", 10)
|
||||||
|
for name in ROBOT_NAMES
|
||||||
|
}
|
||||||
|
self.lift_publisher = self.create_publisher(Float64, "/RMC2/lift", 10)
|
||||||
|
for name in ROBOT_NAMES:
|
||||||
|
self.create_subscription(
|
||||||
|
Odometry,
|
||||||
|
f"/{name}/odometry",
|
||||||
|
lambda message, robot=name: self.on_odom(robot, message),
|
||||||
|
qos_profile_sensor_data,
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
LaserScan,
|
||||||
|
f"/{name}/scan_front",
|
||||||
|
lambda message, robot=name: self.on_scan(robot, message),
|
||||||
|
qos_profile_sensor_data,
|
||||||
|
)
|
||||||
|
self.create_subscription(String, "/RMC2/lift_status", self.on_lift, 10)
|
||||||
|
self.create_subscription(Bool, "/mvch/module5/emergency_stop", self.on_stop, 10)
|
||||||
|
self.create_subscription(String, "/mvch/module5/command", self.on_command, 10)
|
||||||
|
|
||||||
|
def on_odom(self, robot, message):
|
||||||
|
orientation = message.pose.pose.orientation
|
||||||
|
siny = 2.0 * (orientation.w * orientation.z + orientation.x * orientation.y)
|
||||||
|
cosy = 1.0 - 2.0 * (orientation.y**2 + orientation.z**2)
|
||||||
|
pose = (
|
||||||
|
float(message.pose.pose.position.x),
|
||||||
|
float(message.pose.pose.position.y),
|
||||||
|
math.atan2(siny, cosy),
|
||||||
|
)
|
||||||
|
with self.lock:
|
||||||
|
self.poses[robot] = pose
|
||||||
|
self.odom_seen[robot] = time.monotonic()
|
||||||
|
|
||||||
|
def on_scan(self, robot, message):
|
||||||
|
distances = []
|
||||||
|
for index, distance in enumerate(message.ranges):
|
||||||
|
angle = message.angle_min + index * message.angle_increment
|
||||||
|
if abs(angle) <= math.radians(24) and math.isfinite(distance):
|
||||||
|
if message.range_min <= distance <= message.range_max:
|
||||||
|
distances.append(float(distance))
|
||||||
|
with self.lock:
|
||||||
|
self.front_distance[robot] = min(distances, default=math.inf)
|
||||||
|
self.scan_seen[robot] = time.monotonic()
|
||||||
|
|
||||||
|
def on_lift(self, message):
|
||||||
|
with self.lock:
|
||||||
|
self.lift_status = str(message.data)
|
||||||
|
self.lift_updates += 1
|
||||||
|
|
||||||
|
def on_stop(self, message):
|
||||||
|
if message.data:
|
||||||
|
self.emergency.set()
|
||||||
|
event_log.write("EMERGENCY_STOP", source="topic")
|
||||||
|
else:
|
||||||
|
self.emergency.clear()
|
||||||
|
event_log.write("RESUME", source="topic")
|
||||||
|
|
||||||
|
def on_command(self, message):
|
||||||
|
command = message.data.strip().lower()
|
||||||
|
if command == "stop":
|
||||||
|
self.emergency.set()
|
||||||
|
event_log.write("EMERGENCY_STOP", source="command")
|
||||||
|
elif command == "resume":
|
||||||
|
self.emergency.clear()
|
||||||
|
event_log.write("RESUME", source="command")
|
||||||
|
elif command in {"abort", "quit"}:
|
||||||
|
self.abort.set()
|
||||||
|
|
||||||
|
def pose(self, robot):
|
||||||
|
with self.lock:
|
||||||
|
return self.poses[robot], self.odom_seen[robot]
|
||||||
|
|
||||||
|
def scan(self, robot):
|
||||||
|
with self.lock:
|
||||||
|
return self.front_distance[robot], self.scan_seen[robot]
|
||||||
|
|
||||||
|
def command(self, robot, x=0.0, y=0.0, yaw=0.0, immediate=False):
|
||||||
|
now = time.monotonic()
|
||||||
|
old_x, old_y, old_yaw, old_time = self.last_command[robot]
|
||||||
|
if immediate:
|
||||||
|
next_x, next_y, next_yaw = x, y, yaw
|
||||||
|
else:
|
||||||
|
dt = min(0.15, max(0.02, now - old_time))
|
||||||
|
linear_step, angular_step = 0.65 * dt, 1.2 * dt
|
||||||
|
next_x = old_x + clamp(x - old_x, linear_step)
|
||||||
|
next_y = old_y + clamp(y - old_y, linear_step)
|
||||||
|
next_yaw = old_yaw + clamp(yaw - old_yaw, angular_step)
|
||||||
|
message = Twist()
|
||||||
|
message.linear.x = next_x
|
||||||
|
message.linear.y = next_y if robot == "RMC1" else 0.0
|
||||||
|
message.angular.z = next_yaw
|
||||||
|
self.cmd_publishers[robot].publish(message)
|
||||||
|
self.last_command[robot] = (next_x, next_y, next_yaw, now)
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
for _ in range(3):
|
||||||
|
for robot in ROBOT_NAMES:
|
||||||
|
self.command(robot, immediate=True)
|
||||||
|
time.sleep(0.04)
|
||||||
|
|
||||||
|
def set_lift(self, raised, allow_already=False):
|
||||||
|
height = 0.1 if raised else 0.0
|
||||||
|
event_log.write("LIFT_COMMAND", state="up" if raised else "down", height=height)
|
||||||
|
with self.lock:
|
||||||
|
updates_before = self.lift_updates
|
||||||
|
for _ in range(5):
|
||||||
|
self.lift_publisher.publish(Float64(data=height))
|
||||||
|
time.sleep(0.1)
|
||||||
|
started = time.monotonic()
|
||||||
|
success = False
|
||||||
|
while time.monotonic() - started < 6.0:
|
||||||
|
with self.lock:
|
||||||
|
status = self.lift_status
|
||||||
|
received_update = self.lift_updates > updates_before
|
||||||
|
# Драйвер публикует status только при его изменении. Если лифт уже
|
||||||
|
# был внизу до нашей подписки, команда down не создаст сообщения.
|
||||||
|
if time.monotonic() - started >= 1.1 and received_update and "success" in status.lower():
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
if time.monotonic() - started >= 1.3 and allow_already and not received_update:
|
||||||
|
event_log.write(
|
||||||
|
"LIFT_ALREADY_AT_TARGET",
|
||||||
|
state="up" if raised else "down",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
if not success:
|
||||||
|
raise RuntimeError(f"RMC2: лифт не завершил движение, status={status}")
|
||||||
|
event_log.write("LIFT_DONE", state="up" if raised else "down", status=status)
|
||||||
|
|
||||||
|
return WarehouseNode(), rclpy, MultiThreadedExecutor
|
||||||
|
|
||||||
|
|
||||||
|
class Motion:
|
||||||
|
def __init__(self, node, args, event_log, anchors):
|
||||||
|
self.node = node
|
||||||
|
self.args = args
|
||||||
|
self.log = event_log
|
||||||
|
self.anchors = anchors
|
||||||
|
|
||||||
|
def wait_ready(self):
|
||||||
|
deadline = time.monotonic() + 20.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
odometry_ready = all(
|
||||||
|
self.node.pose(robot)[0] is not None for robot in ROBOT_NAMES
|
||||||
|
)
|
||||||
|
lidar_ready = all(self.node.scan(robot)[1] > 0.0 for robot in ROBOT_NAMES)
|
||||||
|
if odometry_ready and lidar_ready:
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
missing_odom = [robot for robot in ROBOT_NAMES if self.node.pose(robot)[0] is None]
|
||||||
|
missing_lidar = [robot for robot in ROBOT_NAMES if self.node.scan(robot)[1] == 0.0]
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Датчики не готовы; odometry={missing_odom or 'ok'}, "
|
||||||
|
f"scan_front={missing_lidar or 'ok'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def wait_if_stopped(self):
|
||||||
|
announced = False
|
||||||
|
while self.node.emergency.is_set():
|
||||||
|
self.node.stop_all()
|
||||||
|
if not announced:
|
||||||
|
self.log.write("PAUSED")
|
||||||
|
announced = True
|
||||||
|
if self.node.abort.is_set():
|
||||||
|
raise RuntimeError("Остановлено оператором")
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def drive_route(
|
||||||
|
self, robot, name, route, final_world_yaw, carrying=False,
|
||||||
|
rack_entry=False, rack_exit=False,
|
||||||
|
):
|
||||||
|
self.log.write("ROUTE", robot=robot, stage=name, markers=route, carrying=carrying)
|
||||||
|
for index, marker in enumerate(route[1:], start=1):
|
||||||
|
x, y = marker_xy(marker)
|
||||||
|
if index == len(route) - 1:
|
||||||
|
world_yaw = final_world_yaw
|
||||||
|
else:
|
||||||
|
nx, ny = marker_xy(route[index + 1])
|
||||||
|
world_yaw = math.atan2(ny - y, nx - x)
|
||||||
|
target = self.anchors[robot].to_odom(x, y, world_yaw)
|
||||||
|
self.drive_to(
|
||||||
|
robot,
|
||||||
|
marker,
|
||||||
|
target,
|
||||||
|
carrying=carrying,
|
||||||
|
allow_rack_entry=(rack_entry and index == len(route) - 1)
|
||||||
|
or (rack_exit and index == 1),
|
||||||
|
)
|
||||||
|
self.log.write("ROUTE_DONE", robot=robot, stage=name, marker=route[-1])
|
||||||
|
|
||||||
|
def drive_to(self, robot, marker, target, carrying=False, allow_rack_entry=False):
|
||||||
|
started = time.monotonic()
|
||||||
|
obstacle_since = None
|
||||||
|
while time.monotonic() - started < self.args.waypoint_timeout:
|
||||||
|
if self.node.abort.is_set():
|
||||||
|
raise RuntimeError("Остановлено оператором")
|
||||||
|
self.wait_if_stopped()
|
||||||
|
pose, seen = self.node.pose(robot)
|
||||||
|
if pose is None or time.monotonic() - seen > self.args.sensor_timeout:
|
||||||
|
self.node.command(robot, immediate=True)
|
||||||
|
raise RuntimeError(f"{robot}: потеря odometry")
|
||||||
|
x, y, yaw = pose
|
||||||
|
tx, ty, target_yaw = target
|
||||||
|
dx, dy = tx - x, ty - y
|
||||||
|
distance = math.hypot(dx, dy)
|
||||||
|
front, scan_seen = self.node.scan(robot)
|
||||||
|
fresh_scan = time.monotonic() - scan_seen <= self.args.sensor_timeout
|
||||||
|
stop_distance = 0.34 if robot == "RMC2" else 0.30
|
||||||
|
# Стеллаж имеет размер около 0.95 м и стоит центром на ArUco.
|
||||||
|
# На финальном въезде RMC2 штатно едет под его платформу.
|
||||||
|
rack_zone = allow_rack_entry and distance < 1.10
|
||||||
|
blocked = fresh_scan and front < stop_distance and not rack_zone
|
||||||
|
if blocked:
|
||||||
|
self.node.command(robot, immediate=True)
|
||||||
|
if obstacle_since is None:
|
||||||
|
obstacle_since = time.monotonic()
|
||||||
|
self.log.write(
|
||||||
|
"OBSTACLE_STOP", robot=robot, marker=marker, distance=round(front, 3)
|
||||||
|
)
|
||||||
|
if time.monotonic() - obstacle_since > 12.0:
|
||||||
|
raise RuntimeError(f"{robot}: препятствие не освободило путь")
|
||||||
|
time.sleep(0.08)
|
||||||
|
continue
|
||||||
|
obstacle_since = None
|
||||||
|
|
||||||
|
yaw_error = wrap_angle(target_yaw - yaw)
|
||||||
|
if distance < 0.09:
|
||||||
|
if abs(yaw_error) < 0.10:
|
||||||
|
self.node.command(robot, immediate=True)
|
||||||
|
self.log.write(
|
||||||
|
"WAYPOINT", robot=robot, marker=marker, error=round(distance, 3)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.node.command(robot, yaw=clamp(yaw_error * 1.5, 0.42 if carrying else 0.55))
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if robot == "RMC1":
|
||||||
|
cosine, sine = math.cos(yaw), math.sin(yaw)
|
||||||
|
body_x = cosine * dx + sine * dy
|
||||||
|
body_y = -sine * dx + cosine * dy
|
||||||
|
speed = 0.23 if carrying else 0.32
|
||||||
|
self.node.command(
|
||||||
|
robot,
|
||||||
|
x=clamp(body_x * 0.75, speed),
|
||||||
|
y=clamp(body_y * 0.75, speed),
|
||||||
|
yaw=clamp(yaw_error * 1.2, 0.40),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
heading_error = wrap_angle(math.atan2(dy, dx) - yaw)
|
||||||
|
if abs(heading_error) > 0.18:
|
||||||
|
self.node.command(
|
||||||
|
robot, yaw=clamp(heading_error * 1.45, 0.42 if carrying else 0.58)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
maximum = 0.18 if carrying else 0.27
|
||||||
|
self.node.command(
|
||||||
|
robot,
|
||||||
|
x=min(maximum, max(0.07, distance * 0.55)),
|
||||||
|
yaw=clamp(heading_error * 1.2, 0.30),
|
||||||
|
)
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.node.command(robot, immediate=True)
|
||||||
|
raise RuntimeError(f"{robot}: таймаут движения к marker {marker}")
|
||||||
|
|
||||||
|
|
||||||
|
class ArmWorkflow:
|
||||||
|
"""Тонкая обвязка проверенного зрения и MoveIt из модуля В."""
|
||||||
|
|
||||||
|
def __init__(self, args, event_log, executor, target):
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import yaml
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from cv_bridge import CvBridge
|
||||||
|
from geometry_msgs.msg import PoseStamped
|
||||||
|
from moveit.planning import MoveItPy
|
||||||
|
from moveit_configs_utils import MoveItConfigsBuilder
|
||||||
|
from rclpy.duration import Duration
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from rclpy.time import Time
|
||||||
|
from sensor_msgs.msg import CameraInfo, Image
|
||||||
|
from tf2_ros import Buffer, TransformListener
|
||||||
|
from tf_transformations import quaternion_from_euler
|
||||||
|
from ultralytics import YOLO
|
||||||
|
from module3.module3 import (
|
||||||
|
Arm,
|
||||||
|
build_vision_node,
|
||||||
|
card_near_center,
|
||||||
|
choose_target,
|
||||||
|
estimate_pose,
|
||||||
|
find_targets,
|
||||||
|
)
|
||||||
|
except ImportError as error:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Нет зависимости {error.name}: запустите из module3/.venv "
|
||||||
|
"после source ROS или используйте --skip-arm"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if int(np.__version__.split(".", 1)[0]) >= 2:
|
||||||
|
raise RuntimeError("Для cv_bridge нужен numpy<2 в module3/.venv")
|
||||||
|
weights = Path(args.weights).expanduser()
|
||||||
|
if not weights.is_absolute():
|
||||||
|
weights = ROOT / weights
|
||||||
|
if not weights.is_file():
|
||||||
|
raise RuntimeError(f"Не найдены веса YOLO: {weights}")
|
||||||
|
|
||||||
|
self.args = args
|
||||||
|
self.log = event_log
|
||||||
|
self.executor = executor
|
||||||
|
self.target = target
|
||||||
|
self.cv2 = cv2
|
||||||
|
self.np = np
|
||||||
|
self.find_targets = find_targets
|
||||||
|
self.choose_target = choose_target
|
||||||
|
self.estimate_pose = estimate_pose
|
||||||
|
self.card_near_center = card_near_center
|
||||||
|
imports = (
|
||||||
|
Node,
|
||||||
|
CvBridge,
|
||||||
|
Image,
|
||||||
|
CameraInfo,
|
||||||
|
qos_profile_sensor_data,
|
||||||
|
Buffer,
|
||||||
|
TransformListener,
|
||||||
|
Duration,
|
||||||
|
Time,
|
||||||
|
)
|
||||||
|
self.vision = build_vision_node(SimpleNamespace(topic=args.camera_topic), imports)
|
||||||
|
executor.add_node(self.vision)
|
||||||
|
self.model = YOLO(str(weights))
|
||||||
|
self.log.write("VISION_READY", target=target, weights=str(weights))
|
||||||
|
|
||||||
|
package_dir = Path(get_package_share_directory("ar_webots_fms_ros2"))
|
||||||
|
moveit_config = (
|
||||||
|
MoveItConfigsBuilder("arm95")
|
||||||
|
.robot_description(file_path=str(package_dir / "resource/urdf/arm95_webots.urdf"))
|
||||||
|
.trajectory_execution(
|
||||||
|
file_path=str(package_dir / "resource/config/moveit_controllers.yaml")
|
||||||
|
)
|
||||||
|
.moveit_cpp(file_path=str(package_dir / "resource/config/moveit_cpp.yaml"))
|
||||||
|
.to_moveit_configs()
|
||||||
|
.to_dict()
|
||||||
|
)
|
||||||
|
moveit_config["use_sim_time"] = True
|
||||||
|
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as stream:
|
||||||
|
yaml.safe_dump({"/**": {"ros__parameters": moveit_config}}, stream)
|
||||||
|
params_file = stream.name
|
||||||
|
try:
|
||||||
|
self.arm = Arm(
|
||||||
|
MoveItPy,
|
||||||
|
params_file,
|
||||||
|
PoseStamped,
|
||||||
|
quaternion_from_euler,
|
||||||
|
event_log,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
Path(params_file).unlink(missing_ok=True)
|
||||||
|
self.log.write("MOVEIT_READY")
|
||||||
|
|
||||||
|
def detect(self):
|
||||||
|
started = time.monotonic()
|
||||||
|
last_report = 0.0
|
||||||
|
seen = {}
|
||||||
|
while time.monotonic() - started < self.args.vision_timeout:
|
||||||
|
frame = self.vision.latest_frame()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
result = self.model.predict(
|
||||||
|
source=frame,
|
||||||
|
conf=self.args.conf,
|
||||||
|
imgsz=512,
|
||||||
|
device="cpu",
|
||||||
|
verbose=False,
|
||||||
|
)[0]
|
||||||
|
if result.boxes is not None:
|
||||||
|
for box in result.boxes:
|
||||||
|
name = str(result.names[int(box.cls.item())]).lower()
|
||||||
|
seen[name] = max(seen.get(name, 0.0), float(box.conf.item()))
|
||||||
|
detections = self.find_targets(
|
||||||
|
result, frame, self.target, self.cv2, self.np
|
||||||
|
)
|
||||||
|
detection = self.choose_target(self.vision, detections, self.args.plane_z)
|
||||||
|
if detection is not None:
|
||||||
|
self.log.write(
|
||||||
|
"DETECTION",
|
||||||
|
target=self.target,
|
||||||
|
confidence=round(detection.confidence, 4),
|
||||||
|
)
|
||||||
|
return detection
|
||||||
|
if time.monotonic() - last_report > 5.0:
|
||||||
|
self.log.write(
|
||||||
|
"VISION_WAIT",
|
||||||
|
target=self.target,
|
||||||
|
seen={key: round(value, 3) for key, value in seen.items()},
|
||||||
|
)
|
||||||
|
last_report = time.monotonic()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Класс {self.target} не найден за {self.args.vision_timeout:.0f} с; "
|
||||||
|
f"видел: {seen or 'ничего'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def pick(self):
|
||||||
|
self.log.write("ARM_STAGE", stage="pick_start")
|
||||||
|
self.arm.open()
|
||||||
|
self.arm.initial("транспортировочное положение перед захватом")
|
||||||
|
detection = self.detect()
|
||||||
|
x, y, yaw = self.estimate_pose(self.vision, detection, self.args.plane_z)
|
||||||
|
self.log.write("PICK_TARGET", x=round(x, 4), y=round(y, 4), yaw=round(yaw, 4))
|
||||||
|
self.arm.pose(x, y, self.args.approach_z, yaw, "подход над деталью")
|
||||||
|
|
||||||
|
# Камера приблизилась: уточняем положение по контуру карточки.
|
||||||
|
time.sleep(0.6)
|
||||||
|
frame = self.vision.latest_frame()
|
||||||
|
if frame is not None:
|
||||||
|
try:
|
||||||
|
close = self.card_near_center(frame, self.target, self.cv2, self.np)
|
||||||
|
refined_x, refined_y, refined_yaw = self.estimate_pose(
|
||||||
|
self.vision, close, self.args.plane_z
|
||||||
|
)
|
||||||
|
correction = math.hypot(refined_x - x, refined_y - y)
|
||||||
|
if correction <= 0.12:
|
||||||
|
x, y, yaw = refined_x, refined_y, refined_yaw
|
||||||
|
self.log.write("PICK_REFINED", correction=round(correction, 4))
|
||||||
|
except Exception as error:
|
||||||
|
self.log.write("PICK_REFINE_SKIPPED", reason=str(error))
|
||||||
|
|
||||||
|
self.arm.pose(x, y, self.args.approach_z, yaw, "точный подход")
|
||||||
|
self.arm.pose(x, y, self.args.pick_z, yaw, "опустить схват")
|
||||||
|
self.arm.close()
|
||||||
|
self.arm.pose(x, y, self.args.approach_z, yaw, "поднять деталь")
|
||||||
|
self.arm.initial("транспортировочное положение с деталью")
|
||||||
|
self.log.write("DETAIL_PICKED", target=self.target)
|
||||||
|
|
||||||
|
def place(self):
|
||||||
|
self.log.write("ARM_STAGE", stage="place_start")
|
||||||
|
x, y = self.args.drop_arm_x, self.args.drop_arm_y
|
||||||
|
yaw = self.args.drop_arm_yaw
|
||||||
|
self.arm.pose(x, y, self.args.approach_z, yaw, "подход к полке сдачи")
|
||||||
|
self.arm.pose(x, y, self.args.drop_arm_z, yaw, "опустить деталь на полку")
|
||||||
|
self.arm.open()
|
||||||
|
self.arm.pose(x, y, self.args.approach_z, yaw, "отойти от детали")
|
||||||
|
self.arm.initial("транспортировочное положение без детали")
|
||||||
|
self.log.write("DETAIL_PLACED", target=self.target)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.executor.remove_node(self.vision)
|
||||||
|
self.vision.destroy_node()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_commands(node):
|
||||||
|
while not node.abort.is_set():
|
||||||
|
try:
|
||||||
|
command = input().strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
return
|
||||||
|
if command == "stop":
|
||||||
|
node.emergency.set()
|
||||||
|
elif command == "resume":
|
||||||
|
node.emergency.clear()
|
||||||
|
elif command in {"abort", "quit"}:
|
||||||
|
node.abort.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target(value):
|
||||||
|
names = {"1": "hammer", "2": "wrench", "3": "pliers"}
|
||||||
|
value = (value or "").strip().lower()
|
||||||
|
return names.get(value, value)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
target = resolve_target(args.target)
|
||||||
|
if not target and not args.dry_run:
|
||||||
|
target = resolve_target(input("Цель (1 hammer, 2 wrench, 3 pliers): "))
|
||||||
|
if not target:
|
||||||
|
target = "hammer"
|
||||||
|
if target not in {"hammer", "wrench", "pliers"}:
|
||||||
|
raise ValueError(f"Неизвестная цель: {target}")
|
||||||
|
|
||||||
|
scenario = make_scenario(args)
|
||||||
|
routes = scenario.routes()
|
||||||
|
print_plan(scenario, routes, target)
|
||||||
|
if args.dry_run:
|
||||||
|
return 0
|
||||||
|
if not args.auto:
|
||||||
|
input("Проверьте точки и маршруты. Enter — начать: ")
|
||||||
|
|
||||||
|
event_log = EventLog()
|
||||||
|
event_log.write("MISSION_START", target=target, log=str(event_log.path))
|
||||||
|
mission_started = time.monotonic()
|
||||||
|
node, rclpy, MultiThreadedExecutor = build_ros_node(args, event_log)
|
||||||
|
executor = MultiThreadedExecutor(num_threads=3)
|
||||||
|
executor.add_node(node)
|
||||||
|
spin_thread = threading.Thread(target=executor.spin, daemon=True)
|
||||||
|
spin_thread.start()
|
||||||
|
exit_code = 0
|
||||||
|
arm_workflow = None
|
||||||
|
try:
|
||||||
|
initial_motion = Motion(node, args, event_log, {})
|
||||||
|
initial_motion.wait_ready()
|
||||||
|
anchors = {}
|
||||||
|
for robot, start_marker, start_yaw in (
|
||||||
|
("RMC1", scenario.rmc1_start, args.rmc1_start_yaw),
|
||||||
|
("RMC2", scenario.rmc2_start, args.rmc2_start_yaw),
|
||||||
|
):
|
||||||
|
pose, _ = node.pose(robot)
|
||||||
|
world_x, world_y = marker_xy(start_marker)
|
||||||
|
anchors[robot] = Anchor(world_x, world_y, start_yaw, *pose)
|
||||||
|
event_log.write("LOCALIZED", robot=robot, marker=start_marker, odom=pose)
|
||||||
|
motion = Motion(node, args, event_log, anchors)
|
||||||
|
threading.Thread(target=terminal_commands, args=(node,), daemon=True).start()
|
||||||
|
parking = routes["rmc2_clear_assembly"][-1]
|
||||||
|
assembly_entry_yaw = face_marker(parking, scenario.assembly)
|
||||||
|
|
||||||
|
node.set_lift(False, allow_already=True)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC2", "to_rack", routes["rmc2_to_rack"], args.rack_yaw, rack_entry=True
|
||||||
|
)
|
||||||
|
node.set_lift(True)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC2",
|
||||||
|
"rack_to_assembly",
|
||||||
|
routes["rmc2_to_assembly"],
|
||||||
|
assembly_entry_yaw,
|
||||||
|
carrying=True,
|
||||||
|
rack_exit=True,
|
||||||
|
)
|
||||||
|
node.set_lift(False)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC2",
|
||||||
|
"clear_assembly",
|
||||||
|
routes["rmc2_clear_assembly"],
|
||||||
|
face_marker(parking, scenario.assembly),
|
||||||
|
rack_exit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assembly_yaw = face_marker(scenario.assembly_approach, scenario.assembly)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC1", "to_assembly", routes["rmc1_to_assembly"], assembly_yaw
|
||||||
|
)
|
||||||
|
if args.skip_arm:
|
||||||
|
event_log.write("ARM_SKIPPED", stage="pick", reason="--skip-arm")
|
||||||
|
else:
|
||||||
|
arm_workflow = ArmWorkflow(args, event_log, executor, target)
|
||||||
|
arm_workflow.pick()
|
||||||
|
|
||||||
|
delivery_yaw = face_marker(scenario.delivery_approach, scenario.delivery)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC1",
|
||||||
|
"to_delivery",
|
||||||
|
routes["rmc1_to_delivery"],
|
||||||
|
delivery_yaw,
|
||||||
|
carrying=not args.skip_arm,
|
||||||
|
)
|
||||||
|
if args.skip_arm:
|
||||||
|
event_log.write("ARM_SKIPPED", stage="place", reason="--skip-arm")
|
||||||
|
else:
|
||||||
|
arm_workflow.place()
|
||||||
|
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC2",
|
||||||
|
"back_to_assembly",
|
||||||
|
routes["rmc2_back_to_assembly"],
|
||||||
|
assembly_entry_yaw,
|
||||||
|
rack_entry=True,
|
||||||
|
)
|
||||||
|
node.set_lift(True)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC2",
|
||||||
|
"assembly_to_rack",
|
||||||
|
routes["rmc2_return_rack"],
|
||||||
|
args.rack_yaw,
|
||||||
|
carrying=True,
|
||||||
|
rack_entry=True,
|
||||||
|
rack_exit=True,
|
||||||
|
)
|
||||||
|
node.set_lift(False)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC1", "return_start", routes["rmc1_return"], args.rmc1_start_yaw
|
||||||
|
)
|
||||||
|
motion.drive_route(
|
||||||
|
"RMC2", "return_start", routes["rmc2_return"], args.rmc2_start_yaw,
|
||||||
|
rack_exit=True,
|
||||||
|
)
|
||||||
|
event_log.write(
|
||||||
|
"MISSION_FINISHED",
|
||||||
|
target=target,
|
||||||
|
elapsed_seconds=round(time.monotonic() - mission_started, 2),
|
||||||
|
)
|
||||||
|
print("\nMISSION FINISHED: оба РМК вернулись на старт")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
event_log.write("MISSION_ABORTED", reason="Ctrl+C")
|
||||||
|
exit_code = 130
|
||||||
|
except Exception as error:
|
||||||
|
event_log.write("MISSION_FAILED", reason=str(error))
|
||||||
|
exit_code = 1
|
||||||
|
finally:
|
||||||
|
node.abort.set()
|
||||||
|
node.stop_all()
|
||||||
|
if arm_workflow is not None:
|
||||||
|
arm_workflow.close()
|
||||||
|
if not executor.shutdown(timeout_sec=2.0):
|
||||||
|
event_log.write("SHUTDOWN_TIMEOUT", component="ROS executor")
|
||||||
|
# rclpy/MoveItPy Jazzy иногда зависают или падают в C++-деструкторах.
|
||||||
|
# Нулевые скорости и логи уже отправлены; ОС безопасно освободит ресурсы.
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
os._exit(exit_code)
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
raise SystemExit(run(arguments()))
|
||||||
|
except (ValueError, ImportError) as error:
|
||||||
|
raise SystemExit(str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||