commit 0a8894f3b22ead84826408fdc3d3fb7209679599 Author: mavxa Date: Wed Sep 9 16:31:27 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02a73b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +reports/ +.pytest_cache/ +*.pt +!module3/models/latest.pt +module3/runs/ +module3/predictions/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..34f4a99 --- /dev/null +++ b/README.md @@ -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' +``` + +Пароли и ключи в репозитории не хранятся. diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/module2/module_b.py b/module2/module_b.py new file mode 100644 index 0000000..0001f4f --- /dev/null +++ b/module2/module_b.py @@ -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() diff --git a/module3/README.md b/module3/README.md new file mode 100644 index 0000000..ba152b7 --- /dev/null +++ b/module3/README.md @@ -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. diff --git a/module3/capture_dataset.py b/module3/capture_dataset.py new file mode 100755 index 0000000..96a6490 --- /dev/null +++ b/module3/capture_dataset.py @@ -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() diff --git a/module3/dataset/.gitignore b/module3/dataset/.gitignore new file mode 100644 index 0000000..16b9d46 --- /dev/null +++ b/module3/dataset/.gitignore @@ -0,0 +1 @@ +raw diff --git a/module3/dataset/classes.txt b/module3/dataset/classes.txt new file mode 100644 index 0000000..3dee804 --- /dev/null +++ b/module3/dataset/classes.txt @@ -0,0 +1,3 @@ +hammer +pliers +wrench diff --git a/module3/dataset/data.example.yaml b/module3/dataset/data.example.yaml new file mode 100644 index 0000000..dab1809 --- /dev/null +++ b/module3/dataset/data.example.yaml @@ -0,0 +1,9 @@ +path: . +train: train/images +val: valid/images +test: test/images + +names: + 0: hammer + 1: pliers + 2: wrench diff --git a/module3/logs/.gitignore b/module3/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/module3/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/module3/models/latest.pt b/module3/models/latest.pt new file mode 100644 index 0000000..61af0ca Binary files /dev/null and b/module3/models/latest.pt differ diff --git a/module3/module3.py b/module3/module3.py new file mode 100644 index 0000000..1559ebd --- /dev/null +++ b/module3/module3.py @@ -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() diff --git a/module3/prepare_dataset.py b/module3/prepare_dataset.py new file mode 100644 index 0000000..18a43c0 --- /dev/null +++ b/module3/prepare_dataset.py @@ -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() diff --git a/module3/requirements.txt b/module3/requirements.txt new file mode 100644 index 0000000..d2880f0 --- /dev/null +++ b/module3/requirements.txt @@ -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 diff --git a/module3/test_model.py b/module3/test_model.py new file mode 100755 index 0000000..5c1d922 --- /dev/null +++ b/module3/test_model.py @@ -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() diff --git a/module3/train.py b/module3/train.py new file mode 100755 index 0000000..ee992c5 --- /dev/null +++ b/module3/train.py @@ -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() diff --git a/module3/training/.gitignore b/module3/training/.gitignore new file mode 100644 index 0000000..9912a1f --- /dev/null +++ b/module3/training/.gitignore @@ -0,0 +1,6 @@ +*.zip +.venv +runs +*.cache +test +valid diff --git a/module3/training/README.dataset.txt b/module3/training/README.dataset.txt new file mode 100644 index 0000000..5810d8a --- /dev/null +++ b/module3/training/README.dataset.txt @@ -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 diff --git a/module3/training/README.roboflow.txt b/module3/training/README.roboflow.txt new file mode 100644 index 0000000..49f0aca --- /dev/null +++ b/module3/training/README.roboflow.txt @@ -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. diff --git a/module3/training/data.yaml b/module3/training/data.yaml new file mode 100644 index 0000000..cce78de --- /dev/null +++ b/module3/training/data.yaml @@ -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 diff --git a/module3/training/requirements.md b/module3/training/requirements.md new file mode 100644 index 0000000..6b13215 --- /dev/null +++ b/module3/training/requirements.md @@ -0,0 +1,9 @@ +# пайторч без куда для амд +``` + pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +``` + +# база +``` + pip install -r ../requirements.txt +``` diff --git a/module3/training/train/images/frame_20260905_210619_103130_000_jpg.rf.0ea921c2f72f4430c08915949013412a.jpg b/module3/training/train/images/frame_20260905_210619_103130_000_jpg.rf.0ea921c2f72f4430c08915949013412a.jpg new file mode 100644 index 0000000..a457cec Binary files /dev/null and b/module3/training/train/images/frame_20260905_210619_103130_000_jpg.rf.0ea921c2f72f4430c08915949013412a.jpg differ diff --git a/module3/training/train/images/frame_20260905_210623_307410_002_jpg.rf.90cbb36eb7b46b55571c327a6ee0a12e.jpg b/module3/training/train/images/frame_20260905_210623_307410_002_jpg.rf.90cbb36eb7b46b55571c327a6ee0a12e.jpg new file mode 100644 index 0000000..f624d89 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210623_307410_002_jpg.rf.90cbb36eb7b46b55571c327a6ee0a12e.jpg differ diff --git a/module3/training/train/images/frame_20260905_210624_827788_004_jpg.rf.d2e1663590c3054fc19d3b78b168cbc1.jpg b/module3/training/train/images/frame_20260905_210624_827788_004_jpg.rf.d2e1663590c3054fc19d3b78b168cbc1.jpg new file mode 100644 index 0000000..008e355 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210624_827788_004_jpg.rf.d2e1663590c3054fc19d3b78b168cbc1.jpg differ diff --git a/module3/training/train/images/frame_20260905_210625_525594_006_jpg.rf.f0124197fb00c3a8fe70e216107b77be.jpg b/module3/training/train/images/frame_20260905_210625_525594_006_jpg.rf.f0124197fb00c3a8fe70e216107b77be.jpg new file mode 100644 index 0000000..10dd561 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210625_525594_006_jpg.rf.f0124197fb00c3a8fe70e216107b77be.jpg differ diff --git a/module3/training/train/images/frame_20260905_210625_934956_007_jpg.rf.bda3050e4ead7be8008107504bc99cce.jpg b/module3/training/train/images/frame_20260905_210625_934956_007_jpg.rf.bda3050e4ead7be8008107504bc99cce.jpg new file mode 100644 index 0000000..c4b7e70 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210625_934956_007_jpg.rf.bda3050e4ead7be8008107504bc99cce.jpg differ diff --git a/module3/training/train/images/frame_20260905_210626_343395_008_jpg.rf.8f4b9ef70d74df8efc3920bfcc4adee7.jpg b/module3/training/train/images/frame_20260905_210626_343395_008_jpg.rf.8f4b9ef70d74df8efc3920bfcc4adee7.jpg new file mode 100644 index 0000000..5db80c0 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210626_343395_008_jpg.rf.8f4b9ef70d74df8efc3920bfcc4adee7.jpg differ diff --git a/module3/training/train/images/frame_20260905_210626_742950_009_jpg.rf.44523e8c78465f32c64e88b525232d09.jpg b/module3/training/train/images/frame_20260905_210626_742950_009_jpg.rf.44523e8c78465f32c64e88b525232d09.jpg new file mode 100644 index 0000000..da6a3e6 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210626_742950_009_jpg.rf.44523e8c78465f32c64e88b525232d09.jpg differ diff --git a/module3/training/train/images/frame_20260905_210627_665064_011_jpg.rf.edc8654949e35df18d1f41445b9ea277.jpg b/module3/training/train/images/frame_20260905_210627_665064_011_jpg.rf.edc8654949e35df18d1f41445b9ea277.jpg new file mode 100644 index 0000000..425ca81 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210627_665064_011_jpg.rf.edc8654949e35df18d1f41445b9ea277.jpg differ diff --git a/module3/training/train/images/frame_20260905_210642_026169_012_jpg.rf.a0ec2107161b3189f84a0d6ec23fec67.jpg b/module3/training/train/images/frame_20260905_210642_026169_012_jpg.rf.a0ec2107161b3189f84a0d6ec23fec67.jpg new file mode 100644 index 0000000..1675193 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210642_026169_012_jpg.rf.a0ec2107161b3189f84a0d6ec23fec67.jpg differ diff --git a/module3/training/train/images/frame_20260905_210642_299877_013_jpg.rf.39af656456901e88279ef8728acc1c00.jpg b/module3/training/train/images/frame_20260905_210642_299877_013_jpg.rf.39af656456901e88279ef8728acc1c00.jpg new file mode 100644 index 0000000..9e8dde9 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210642_299877_013_jpg.rf.39af656456901e88279ef8728acc1c00.jpg differ diff --git a/module3/training/train/images/frame_20260905_210657_079252_014_jpg.rf.cbaba7c819d2c2d5f49f637665b233e1.jpg b/module3/training/train/images/frame_20260905_210657_079252_014_jpg.rf.cbaba7c819d2c2d5f49f637665b233e1.jpg new file mode 100644 index 0000000..f2eef05 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210657_079252_014_jpg.rf.cbaba7c819d2c2d5f49f637665b233e1.jpg differ diff --git a/module3/training/train/images/frame_20260905_210657_351675_015_jpg.rf.fb80e06a010b189aae95a2169b0de0c4.jpg b/module3/training/train/images/frame_20260905_210657_351675_015_jpg.rf.fb80e06a010b189aae95a2169b0de0c4.jpg new file mode 100644 index 0000000..6741d5a Binary files /dev/null and b/module3/training/train/images/frame_20260905_210657_351675_015_jpg.rf.fb80e06a010b189aae95a2169b0de0c4.jpg differ diff --git a/module3/training/train/images/frame_20260905_210657_760995_016_jpg.rf.1d5f9df0ba08f5172aa010aeb0ebd430.jpg b/module3/training/train/images/frame_20260905_210657_760995_016_jpg.rf.1d5f9df0ba08f5172aa010aeb0ebd430.jpg new file mode 100644 index 0000000..bd31cb9 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210657_760995_016_jpg.rf.1d5f9df0ba08f5172aa010aeb0ebd430.jpg differ diff --git a/module3/training/train/images/frame_20260905_210659_127615_019_jpg.rf.aa73d6ac84260ee43a093553db35ff25.jpg b/module3/training/train/images/frame_20260905_210659_127615_019_jpg.rf.aa73d6ac84260ee43a093553db35ff25.jpg new file mode 100644 index 0000000..73c6687 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210659_127615_019_jpg.rf.aa73d6ac84260ee43a093553db35ff25.jpg differ diff --git a/module3/training/train/images/frame_20260905_210659_402853_020_jpg.rf.54756d3386e74e9b204f948771e6920b.jpg b/module3/training/train/images/frame_20260905_210659_402853_020_jpg.rf.54756d3386e74e9b204f948771e6920b.jpg new file mode 100644 index 0000000..19cd5a5 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210659_402853_020_jpg.rf.54756d3386e74e9b204f948771e6920b.jpg differ diff --git a/module3/training/train/images/frame_20260905_210659_816477_021_jpg.rf.d7bb5f5b0ecc711c848d9bc3bc721f5f.jpg b/module3/training/train/images/frame_20260905_210659_816477_021_jpg.rf.d7bb5f5b0ecc711c848d9bc3bc721f5f.jpg new file mode 100644 index 0000000..126d1c5 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210659_816477_021_jpg.rf.d7bb5f5b0ecc711c848d9bc3bc721f5f.jpg differ diff --git a/module3/training/train/images/frame_20260905_210700_110395_022_jpg.rf.44eb906b432b749c01fcd4d74bd8a1fb.jpg b/module3/training/train/images/frame_20260905_210700_110395_022_jpg.rf.44eb906b432b749c01fcd4d74bd8a1fb.jpg new file mode 100644 index 0000000..bc6c628 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210700_110395_022_jpg.rf.44eb906b432b749c01fcd4d74bd8a1fb.jpg differ diff --git a/module3/training/train/images/frame_20260905_210700_506694_023_jpg.rf.f33dd25d7bd5ef339163e12c8dd9bd87.jpg b/module3/training/train/images/frame_20260905_210700_506694_023_jpg.rf.f33dd25d7bd5ef339163e12c8dd9bd87.jpg new file mode 100644 index 0000000..9de9372 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210700_506694_023_jpg.rf.f33dd25d7bd5ef339163e12c8dd9bd87.jpg differ diff --git a/module3/training/train/images/frame_20260905_210702_141692_026_jpg.rf.9e912fb265aefa2b5ee735f8fa310b0c.jpg b/module3/training/train/images/frame_20260905_210702_141692_026_jpg.rf.9e912fb265aefa2b5ee735f8fa310b0c.jpg new file mode 100644 index 0000000..a0dffae Binary files /dev/null and b/module3/training/train/images/frame_20260905_210702_141692_026_jpg.rf.9e912fb265aefa2b5ee735f8fa310b0c.jpg differ diff --git a/module3/training/train/images/frame_20260905_210702_399319_027_jpg.rf.6c78c1d131026eeb52d1e8afcb6e9c13.jpg b/module3/training/train/images/frame_20260905_210702_399319_027_jpg.rf.6c78c1d131026eeb52d1e8afcb6e9c13.jpg new file mode 100644 index 0000000..d812c8e Binary files /dev/null and b/module3/training/train/images/frame_20260905_210702_399319_027_jpg.rf.6c78c1d131026eeb52d1e8afcb6e9c13.jpg differ diff --git a/module3/training/train/images/frame_20260905_210702_965293_028_jpg.rf.40d0ed62c9310dfcdc4c177df32f86f5.jpg b/module3/training/train/images/frame_20260905_210702_965293_028_jpg.rf.40d0ed62c9310dfcdc4c177df32f86f5.jpg new file mode 100644 index 0000000..9c7cdff Binary files /dev/null and b/module3/training/train/images/frame_20260905_210702_965293_028_jpg.rf.40d0ed62c9310dfcdc4c177df32f86f5.jpg differ diff --git a/module3/training/train/images/frame_20260905_210705_817035_030_jpg.rf.dc252bacfbb9deadcadc0f9800c9740a.jpg b/module3/training/train/images/frame_20260905_210705_817035_030_jpg.rf.dc252bacfbb9deadcadc0f9800c9740a.jpg new file mode 100644 index 0000000..5abfcab Binary files /dev/null and b/module3/training/train/images/frame_20260905_210705_817035_030_jpg.rf.dc252bacfbb9deadcadc0f9800c9740a.jpg differ diff --git a/module3/training/train/images/frame_20260905_210706_206209_031_jpg.rf.f241fbe17947c9cd9b2f9ed337af58b4.jpg b/module3/training/train/images/frame_20260905_210706_206209_031_jpg.rf.f241fbe17947c9cd9b2f9ed337af58b4.jpg new file mode 100644 index 0000000..ca8e673 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210706_206209_031_jpg.rf.f241fbe17947c9cd9b2f9ed337af58b4.jpg differ diff --git a/module3/training/train/images/frame_20260905_210707_032854_032_jpg.rf.98cc67af2131e8bd5f43f38c4c882c23.jpg b/module3/training/train/images/frame_20260905_210707_032854_032_jpg.rf.98cc67af2131e8bd5f43f38c4c882c23.jpg new file mode 100644 index 0000000..9990fab Binary files /dev/null and b/module3/training/train/images/frame_20260905_210707_032854_032_jpg.rf.98cc67af2131e8bd5f43f38c4c882c23.jpg differ diff --git a/module3/training/train/images/frame_20260905_210707_562683_033_jpg.rf.25d15962fca66176b4853c8df3c21484.jpg b/module3/training/train/images/frame_20260905_210707_562683_033_jpg.rf.25d15962fca66176b4853c8df3c21484.jpg new file mode 100644 index 0000000..ec89a6b Binary files /dev/null and b/module3/training/train/images/frame_20260905_210707_562683_033_jpg.rf.25d15962fca66176b4853c8df3c21484.jpg differ diff --git a/module3/training/train/images/frame_20260905_210709_483238_037_jpg.rf.1a1b9fa20f9975c6759896ca85b1800f.jpg b/module3/training/train/images/frame_20260905_210709_483238_037_jpg.rf.1a1b9fa20f9975c6759896ca85b1800f.jpg new file mode 100644 index 0000000..824b492 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210709_483238_037_jpg.rf.1a1b9fa20f9975c6759896ca85b1800f.jpg differ diff --git a/module3/training/train/images/frame_20260905_210709_889817_038_jpg.rf.594519111286b30c71761a839ca281b3.jpg b/module3/training/train/images/frame_20260905_210709_889817_038_jpg.rf.594519111286b30c71761a839ca281b3.jpg new file mode 100644 index 0000000..0aab6bc Binary files /dev/null and b/module3/training/train/images/frame_20260905_210709_889817_038_jpg.rf.594519111286b30c71761a839ca281b3.jpg differ diff --git a/module3/training/train/images/frame_20260905_210710_303577_039_jpg.rf.11cea1c469c45220aa8eae5ae7c2d5a3.jpg b/module3/training/train/images/frame_20260905_210710_303577_039_jpg.rf.11cea1c469c45220aa8eae5ae7c2d5a3.jpg new file mode 100644 index 0000000..da941f4 Binary files /dev/null and b/module3/training/train/images/frame_20260905_210710_303577_039_jpg.rf.11cea1c469c45220aa8eae5ae7c2d5a3.jpg differ diff --git a/module3/training/train/labels/frame_20260905_210619_103130_000_jpg.rf.0ea921c2f72f4430c08915949013412a.txt b/module3/training/train/labels/frame_20260905_210619_103130_000_jpg.rf.0ea921c2f72f4430c08915949013412a.txt new file mode 100644 index 0000000..4772375 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210619_103130_000_jpg.rf.0ea921c2f72f4430c08915949013412a.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210623_307410_002_jpg.rf.90cbb36eb7b46b55571c327a6ee0a12e.txt b/module3/training/train/labels/frame_20260905_210623_307410_002_jpg.rf.90cbb36eb7b46b55571c327a6ee0a12e.txt new file mode 100644 index 0000000..6b129ae --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210623_307410_002_jpg.rf.90cbb36eb7b46b55571c327a6ee0a12e.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210624_827788_004_jpg.rf.d2e1663590c3054fc19d3b78b168cbc1.txt b/module3/training/train/labels/frame_20260905_210624_827788_004_jpg.rf.d2e1663590c3054fc19d3b78b168cbc1.txt new file mode 100644 index 0000000..ec93c79 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210624_827788_004_jpg.rf.d2e1663590c3054fc19d3b78b168cbc1.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210625_525594_006_jpg.rf.f0124197fb00c3a8fe70e216107b77be.txt b/module3/training/train/labels/frame_20260905_210625_525594_006_jpg.rf.f0124197fb00c3a8fe70e216107b77be.txt new file mode 100644 index 0000000..da120af --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210625_525594_006_jpg.rf.f0124197fb00c3a8fe70e216107b77be.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210625_934956_007_jpg.rf.bda3050e4ead7be8008107504bc99cce.txt b/module3/training/train/labels/frame_20260905_210625_934956_007_jpg.rf.bda3050e4ead7be8008107504bc99cce.txt new file mode 100644 index 0000000..03463a8 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210625_934956_007_jpg.rf.bda3050e4ead7be8008107504bc99cce.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210626_343395_008_jpg.rf.8f4b9ef70d74df8efc3920bfcc4adee7.txt b/module3/training/train/labels/frame_20260905_210626_343395_008_jpg.rf.8f4b9ef70d74df8efc3920bfcc4adee7.txt new file mode 100644 index 0000000..09427e1 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210626_343395_008_jpg.rf.8f4b9ef70d74df8efc3920bfcc4adee7.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210626_742950_009_jpg.rf.44523e8c78465f32c64e88b525232d09.txt b/module3/training/train/labels/frame_20260905_210626_742950_009_jpg.rf.44523e8c78465f32c64e88b525232d09.txt new file mode 100644 index 0000000..16af030 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210626_742950_009_jpg.rf.44523e8c78465f32c64e88b525232d09.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210627_665064_011_jpg.rf.edc8654949e35df18d1f41445b9ea277.txt b/module3/training/train/labels/frame_20260905_210627_665064_011_jpg.rf.edc8654949e35df18d1f41445b9ea277.txt new file mode 100644 index 0000000..22b3516 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210627_665064_011_jpg.rf.edc8654949e35df18d1f41445b9ea277.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210642_026169_012_jpg.rf.a0ec2107161b3189f84a0d6ec23fec67.txt b/module3/training/train/labels/frame_20260905_210642_026169_012_jpg.rf.a0ec2107161b3189f84a0d6ec23fec67.txt new file mode 100644 index 0000000..cddd61f --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210642_026169_012_jpg.rf.a0ec2107161b3189f84a0d6ec23fec67.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210642_299877_013_jpg.rf.39af656456901e88279ef8728acc1c00.txt b/module3/training/train/labels/frame_20260905_210642_299877_013_jpg.rf.39af656456901e88279ef8728acc1c00.txt new file mode 100644 index 0000000..bfbe932 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210642_299877_013_jpg.rf.39af656456901e88279ef8728acc1c00.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210657_079252_014_jpg.rf.cbaba7c819d2c2d5f49f637665b233e1.txt b/module3/training/train/labels/frame_20260905_210657_079252_014_jpg.rf.cbaba7c819d2c2d5f49f637665b233e1.txt new file mode 100644 index 0000000..a9a3179 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210657_079252_014_jpg.rf.cbaba7c819d2c2d5f49f637665b233e1.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210657_351675_015_jpg.rf.fb80e06a010b189aae95a2169b0de0c4.txt b/module3/training/train/labels/frame_20260905_210657_351675_015_jpg.rf.fb80e06a010b189aae95a2169b0de0c4.txt new file mode 100644 index 0000000..20b101c --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210657_351675_015_jpg.rf.fb80e06a010b189aae95a2169b0de0c4.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210657_760995_016_jpg.rf.1d5f9df0ba08f5172aa010aeb0ebd430.txt b/module3/training/train/labels/frame_20260905_210657_760995_016_jpg.rf.1d5f9df0ba08f5172aa010aeb0ebd430.txt new file mode 100644 index 0000000..8551a42 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210657_760995_016_jpg.rf.1d5f9df0ba08f5172aa010aeb0ebd430.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210659_127615_019_jpg.rf.aa73d6ac84260ee43a093553db35ff25.txt b/module3/training/train/labels/frame_20260905_210659_127615_019_jpg.rf.aa73d6ac84260ee43a093553db35ff25.txt new file mode 100644 index 0000000..61cde29 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210659_127615_019_jpg.rf.aa73d6ac84260ee43a093553db35ff25.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210659_402853_020_jpg.rf.54756d3386e74e9b204f948771e6920b.txt b/module3/training/train/labels/frame_20260905_210659_402853_020_jpg.rf.54756d3386e74e9b204f948771e6920b.txt new file mode 100644 index 0000000..086e3db --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210659_402853_020_jpg.rf.54756d3386e74e9b204f948771e6920b.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210659_816477_021_jpg.rf.d7bb5f5b0ecc711c848d9bc3bc721f5f.txt b/module3/training/train/labels/frame_20260905_210659_816477_021_jpg.rf.d7bb5f5b0ecc711c848d9bc3bc721f5f.txt new file mode 100644 index 0000000..aac305b --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210659_816477_021_jpg.rf.d7bb5f5b0ecc711c848d9bc3bc721f5f.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210700_110395_022_jpg.rf.44eb906b432b749c01fcd4d74bd8a1fb.txt b/module3/training/train/labels/frame_20260905_210700_110395_022_jpg.rf.44eb906b432b749c01fcd4d74bd8a1fb.txt new file mode 100644 index 0000000..726e416 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210700_110395_022_jpg.rf.44eb906b432b749c01fcd4d74bd8a1fb.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210700_506694_023_jpg.rf.f33dd25d7bd5ef339163e12c8dd9bd87.txt b/module3/training/train/labels/frame_20260905_210700_506694_023_jpg.rf.f33dd25d7bd5ef339163e12c8dd9bd87.txt new file mode 100644 index 0000000..9618f4a --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210700_506694_023_jpg.rf.f33dd25d7bd5ef339163e12c8dd9bd87.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210702_141692_026_jpg.rf.9e912fb265aefa2b5ee735f8fa310b0c.txt b/module3/training/train/labels/frame_20260905_210702_141692_026_jpg.rf.9e912fb265aefa2b5ee735f8fa310b0c.txt new file mode 100644 index 0000000..f4df874 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210702_141692_026_jpg.rf.9e912fb265aefa2b5ee735f8fa310b0c.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210702_399319_027_jpg.rf.6c78c1d131026eeb52d1e8afcb6e9c13.txt b/module3/training/train/labels/frame_20260905_210702_399319_027_jpg.rf.6c78c1d131026eeb52d1e8afcb6e9c13.txt new file mode 100644 index 0000000..272d683 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210702_399319_027_jpg.rf.6c78c1d131026eeb52d1e8afcb6e9c13.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210702_965293_028_jpg.rf.40d0ed62c9310dfcdc4c177df32f86f5.txt b/module3/training/train/labels/frame_20260905_210702_965293_028_jpg.rf.40d0ed62c9310dfcdc4c177df32f86f5.txt new file mode 100644 index 0000000..57c16e3 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210702_965293_028_jpg.rf.40d0ed62c9310dfcdc4c177df32f86f5.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210705_817035_030_jpg.rf.dc252bacfbb9deadcadc0f9800c9740a.txt b/module3/training/train/labels/frame_20260905_210705_817035_030_jpg.rf.dc252bacfbb9deadcadc0f9800c9740a.txt new file mode 100644 index 0000000..ee9f473 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210705_817035_030_jpg.rf.dc252bacfbb9deadcadc0f9800c9740a.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210706_206209_031_jpg.rf.f241fbe17947c9cd9b2f9ed337af58b4.txt b/module3/training/train/labels/frame_20260905_210706_206209_031_jpg.rf.f241fbe17947c9cd9b2f9ed337af58b4.txt new file mode 100644 index 0000000..b957034 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210706_206209_031_jpg.rf.f241fbe17947c9cd9b2f9ed337af58b4.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210707_032854_032_jpg.rf.98cc67af2131e8bd5f43f38c4c882c23.txt b/module3/training/train/labels/frame_20260905_210707_032854_032_jpg.rf.98cc67af2131e8bd5f43f38c4c882c23.txt new file mode 100644 index 0000000..4914b53 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210707_032854_032_jpg.rf.98cc67af2131e8bd5f43f38c4c882c23.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210707_562683_033_jpg.rf.25d15962fca66176b4853c8df3c21484.txt b/module3/training/train/labels/frame_20260905_210707_562683_033_jpg.rf.25d15962fca66176b4853c8df3c21484.txt new file mode 100644 index 0000000..f8e8292 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210707_562683_033_jpg.rf.25d15962fca66176b4853c8df3c21484.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210709_483238_037_jpg.rf.1a1b9fa20f9975c6759896ca85b1800f.txt b/module3/training/train/labels/frame_20260905_210709_483238_037_jpg.rf.1a1b9fa20f9975c6759896ca85b1800f.txt new file mode 100644 index 0000000..5911c8d --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210709_483238_037_jpg.rf.1a1b9fa20f9975c6759896ca85b1800f.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210709_889817_038_jpg.rf.594519111286b30c71761a839ca281b3.txt b/module3/training/train/labels/frame_20260905_210709_889817_038_jpg.rf.594519111286b30c71761a839ca281b3.txt new file mode 100644 index 0000000..5e1d7e9 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210709_889817_038_jpg.rf.594519111286b30c71761a839ca281b3.txt @@ -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 diff --git a/module3/training/train/labels/frame_20260905_210710_303577_039_jpg.rf.11cea1c469c45220aa8eae5ae7c2d5a3.txt b/module3/training/train/labels/frame_20260905_210710_303577_039_jpg.rf.11cea1c469c45220aa8eae5ae7c2d5a3.txt new file mode 100644 index 0000000..a9b0b56 --- /dev/null +++ b/module3/training/train/labels/frame_20260905_210710_303577_039_jpg.rf.11cea1c469c45220aa8eae5ae7c2d5a3.txt @@ -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 diff --git a/module4/.gitignore b/module4/.gitignore new file mode 100644 index 0000000..147e659 --- /dev/null +++ b/module4/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +*.tsbuildinfo +.env diff --git a/module4/README.md b/module4/README.md new file mode 100644 index 0000000..6fe1030 --- /dev/null +++ b/module4/README.md @@ -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 +``` + +Открыть . Сервер Bun сам запускает +`server/ros_bridge.py`; отдельно стартовать Python-файл не нужно. + +Для разработки с Vite HMR: + +```bash +bun run dev +``` + +Страница будет на , 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 +``` diff --git a/module4/bun.lock b/module4/bun.lock new file mode 100644 index 0000000..0865e8f --- /dev/null +++ b/module4/bun.lock @@ -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=="], + } +} diff --git a/module4/index.html b/module4/index.html new file mode 100644 index 0000000..93f4ba4 --- /dev/null +++ b/module4/index.html @@ -0,0 +1,13 @@ + + + + + + + MVCH FMS - модуль Г + + +
+ + + diff --git a/module4/package.json b/module4/package.json new file mode 100644 index 0000000..afdc9ca --- /dev/null +++ b/module4/package.json @@ -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" + } +} diff --git a/module4/scripts/dev.ts b/module4/scripts/dev.ts new file mode 100644 index 0000000..3bd8cae --- /dev/null +++ b/module4/scripts/dev.ts @@ -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 { + 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 {} diff --git a/module4/server/commands.ts b/module4/server/commands.ts new file mode 100644 index 0000000..1b395f3 --- /dev/null +++ b/module4/server/commands.ts @@ -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 + 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 + 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 + } +} diff --git a/module4/server/index.ts b/module4/server/index.ts new file mode 100644 index 0000000..11b7162 --- /dev/null +++ b/module4/server/index.ts @@ -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): void { + server.publish('telemetry', JSON.stringify(state)) +} + +async function readBridge(server: Server): Promise { + 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): 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({ + 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, 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'})`) diff --git a/module4/server/ros_bridge.py b/module4/server/ros_bridge.py new file mode 100644 index 0000000..4aa797e --- /dev/null +++ b/module4/server/ros_bridge.py @@ -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() diff --git a/module4/server/state.test.ts b/module4/server/state.test.ts new file mode 100644 index 0000000..5038a4e --- /dev/null +++ b/module4/server/state.test.ts @@ -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') + }) +}) diff --git a/module4/server/state.ts b/module4/server/state.ts new file mode 100644 index 0000000..8eff042 --- /dev/null +++ b/module4/server/state.ts @@ -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(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 +} diff --git a/module4/src/App.tsx b/module4/src/App.tsx new file mode 100644 index 0000000..949980e --- /dev/null +++ b/module4/src/App.tsx @@ -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 ( +
+

{title}

+ {children} +
+ ) +} + +function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) { + return ( + + ) +} + +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 ( +
+ + + {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 ( + 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 ( + + {right && } + {down && } + + ) + }))} + + {(['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 ( + + {rover.trail.length > 1 && } + {rover.plan.length > 1 && } + {rover.lidar.map((point, index) => { + const p = worldToSvg(point) + return + })} + {rover.goal && (() => { + const goal = worldToSvg(rover.goal) + return + })()} + + + + + {id} + + ) + })} + + {markers.map((marker) => { + const point = worldToSvg(marker) + const current = state.robots.RMC2.arucoId === String(marker.id) + return ( + + + {marker.id} + + ) + })} + +
+ ArUco: шаг 1 м · X вниз · Y вправо +
+
+ ● RMC1● RMC2-- маршрут +
+
+ ) +} + +function ManualControls({ robot, emergency, send }: { robot: RobotId; emergency: boolean; send: (command: Command) => void }) { + const timer = useRef | 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) => { + event.currentTarget.setPointerCapture(event.pointerId) + start(x, y, yaw) + }, + onPointerUp: stop, + onPointerCancel: stop, + }) + + return ( +
+ + + + + + + + + +
+ ) +} + +export default function App() { + const [state, setState] = useState(initialState) + const [selected, setSelected] = useState('RMC1') + const [socketOnline, setSocketOnline] = useState(false) + const [message, setMessage] = useState('Подключение…') + const [poseForm, setPoseForm] = useState({ x: 0, y: 0, yaw: 0 }) + const [goalForm, setGoalForm] = useState({ 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 + 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() + 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>, key: keyof Pose, value: number) => setter((current) => ({ ...current, [key]: value })) + + return ( +
+
+

MVCH FMS · модуль Г

Управление, навигация и телеметрия RMC1 / RMC2

+
+ WebSocket: {socketOnline && fresh ? 'online' : 'offline'} + Bridge: {state.bridgeOnline ? state.mode : 'offline'} + {state.bridgeError ?? message} +
+
+ +
+
+
+ {(['RMC1', 'RMC2'] as RobotId[]).map((id) => ( + + ))} +
+ + +
+ {(['RMC1', 'RMC2'] as RobotId[]).map((id) =>
{id}
x {format(state.robots[id].pose.x)} · y {format(state.robots[id].pose.y)}
yaw {format(state.robots[id].pose.yaw)} rad
)} +
Карта
{state.map ? `${state.map.width} × ${state.map.height}` : 'нет /map'}
+
Обновление
{state.timestamp ? new Date(state.timestamp).toLocaleTimeString() : '—'}
+
+
+
+ + +
+
+ ) +} diff --git a/module4/src/index.css b/module4/src/index.css new file mode 100644 index 0000000..02de586 --- /dev/null +++ b/module4/src/index.css @@ -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; } diff --git a/module4/src/main.tsx b/module4/src/main.tsx new file mode 100644 index 0000000..db032b7 --- /dev/null +++ b/module4/src/main.tsx @@ -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( + + + , +) diff --git a/module4/src/types.ts b/module4/src/types.ts new file mode 100644 index 0000000..0e08e90 --- /dev/null +++ b/module4/src/types.ts @@ -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 +} + +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' } diff --git a/module4/tsconfig.app.json b/module4/tsconfig.app.json new file mode 100644 index 0000000..e935337 --- /dev/null +++ b/module4/tsconfig.app.json @@ -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"] +} diff --git a/module4/tsconfig.json b/module4/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/module4/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/module4/tsconfig.node.json b/module4/tsconfig.node.json new file mode 100644 index 0000000..1114f5c --- /dev/null +++ b/module4/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"] +} diff --git a/module4/vite.config.ts b/module4/vite.config.ts new file mode 100644 index 0000000..7a4fbe7 --- /dev/null +++ b/module4/vite.config.ts @@ -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 }, + }, + }, +}) diff --git a/module5/README.md b/module5/README.md new file mode 100644 index 0000000..aa47704 --- /dev/null +++ b/module5/README.md @@ -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. Результат физического захвата, отсутствие падения детали и время до +трёх минут подтверждаются только контрольным заездом. diff --git a/module5/logs/.gitignore b/module5/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/module5/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/module5/module5.py b/module5/module5.py new file mode 100644 index 0000000..631ee59 --- /dev/null +++ b/module5/module5.py @@ -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() diff --git a/tools/.gitkeep b/tools/.gitkeep new file mode 100644 index 0000000..e69de29