Отборочный этап «TrueTechChamp» завершился и можно поговорить о подходах к задачам. Здесь будут описаны наивные решения программиста, почти незнакомого с робототехникой, впрочем, зато по всем трем задачам — из чего автор делает вывод что узкоспециальные познания тут не требуются — это развлечение доступное, в общем‑то, всем:‑)

Задачи были такие: 1) проехать по известному «лабиринту» из двух комнат с фиксированными препятствиями, то есть запрограммировать фиксированный маршрут — кое‑кто бился над этим неделю и больше — но всё же решений около сотни; 2) проехать неизвестный лабиринт из стенок под прямыми углами — с этим справились вчетверо меньше команд; 3) проехать по змеевидной платформе, используя камеру глубины, и не упасть за край — мне известно примерно о двух с половиной решениях её.

Сейчас подробно рассмотрим какие были сложности и как с ними можно справиться. И да, организационные проблемы преследовали мероприятие до последнего дня, но об этом уже немало сказано, в том числе в сильных выражениях:) В любом это вне «фокуса» данной статьи. Сосредоточимся на задачах!

Общие замечания

Задания выполнялись с помощью симулятора Webots — участники получали проект, описывающий «мир» и самого робота а также некий промежуточный код который позволял управлять роботом по сети, так что решение можно было писать практически на любом языке — нужно было предоставить докер‑файл который подготавливает все нужные зависимости и запускает код — дальше мы просто отправляем команды для движения по UDP и принимаем данные с датчиков робота по TCP.

Примечание: данный симулятор по отзывам работает не слишком стабильно в разных системах — особенно много замечаний было от пользователей разных версий Windows. Мне видимо повезло — на моем ноутбуке Ubuntu 24.04 и с проблемами я не сталкивался.

Материалы задач можно взять в репозитории https://github.com/RodionGork/mts‑challenge-2025 и «поиграться» самостоятельно — в том числе и применить к ним описанные ниже подходы и решения.

Робот на старте 2й задачи, линии показывают угол который "видит" лидар
Робот на старте 2й задачи, линии показывают угол который "видит" лидар

Робот — четырёхколёсная тележка с типичным «дифференциальным» приводом — то есть внутри кода контроллера управляются скорости всех четырёх колёс — но для участника это объединено в возможность задать требуемую скорость движения вперед и скорость поворота (vLinear, wAngular) — отправляемые команды так и состоят из двух чисел float32 — всего 8 байт.

Робот ведёт себя «физично» — заданные скорости достигаются не мгновенно. Актуальны следующие характеристики (их можно найти в файле «контроллера»):

Механика робота:

  • максимальная линейная скорость 1 м/сек (в коде был немного сбивающий с толку коэффициент SPEEDUP - здесь и далее учтено его дефолтное значение 2)

  • максимальная скорость поворота 2 рад/сек

  • максимальное линейное ускорение 0.1 м/сек^2

  • максимальное угловое ускорение 0.4 рад/сек^2

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

Датчики робота:

  • одометр, определяющий пройденное расстояние или угол на который повернулся робот — по вращению колёс; эти данные накапливают ошибку из‑за того что колёса проскальзывают — в общем, как и с реальным роботом

  • лидар — лазерный инструмент определяющий расстояние до препятствий — он выпускает 360 лучей в сектор 90 градусов перед роботом (то есть с шагом 0.25 градуса) — он также не идеально точен (или стены слишком шероховаты), но на больших расстояниях это не критично

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

  • камера глубины — в третьей задаче — даёт «картинку» в виде прямоугольного массива чисел, указывающих расстояния до препятствия (этакая эволюция лидара в дополнительно измерение)

Посмотрим, как мы этим можем воспользоваться для решения задач.

Задача 1 - комната с Уточками

Итак, в первой задаче "лабиринт" задан, известен заранее и не меняется. Препятствия как и во всех прочих задачах неподвижны. Здесь они представлены гигантскими утками, деревянными паллетами, стенками, "мостиком" между комнатами и пр.

Общий вид - проехать нужно в белый прямоугольник во второй комнате
Общий вид - проехать нужно в белый прямоугольник во второй комнате
Начало пути - в "гараже" из гигантских уток
Начало пути - в "гараже" из гигантских уток

С первого взгляда на задачу у многих возникает вопрос "неужели дали такую простую задачу - ведь маршрут можно захардкодить".

С этим "захардкодить" и возникает проблема - для демонстрации напишем первую простую программу. Как упомянуто, решать можно на разных языках, в основном примеры в статье будут на Python т.к. он довольно популярен (однако 2 и 3 задачу я решал на Perl - по не связанным с мероприятием причинам - надеюсь тут не будет проблемы т.к. вы легко можете сконвертировать эти решения в Python используя ChatGPT/DeepSeek и т.п.)

Сперва нам нужно сделать функции для отправки команд (send_cmd) и для получения телеметрии (read_tele). В упрощённом виде для этого достаточно следующих строк:

import socket, struct, time, math, os

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

tele = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tele.bind(('0.0.0.0', 5600))
tele.listen(1)
print("waiting to establish telemetry channel")
teleConn, _ = tele.accept()
print("established telemetry channel")

def send_cmd(forwardSpeed, angularSpeed):
    packet = struct.pack("<2f", forwardSpeed, angularSpeed)
    sock.sendto(packet, ('127.0.0.1', 5555))

def read_tele():
    global x, y, th, lidar
    bytes = teleConn.recv(48)
    hdr, x, y, th, vx, vy, vth, wx, wy, wz, n = struct.unpack('<xxxx4s9fI', bytes)
    bytes = teleConn.recv(n * 4)
    lidar = list(struct.unpack('<%sf' % n, bytes))

Как можно видеть, из датчиков мы в глобальные переменные выбираем показания одометра (x, y, th) и массив расстояний с лидара - его значения идут "справа-налево", т.е. lidar[0] это расстояние до препятствия под 45 градусов справа, а lidar[-1] - слева.

Добавим вспомогательную функцию drive_until, которая задаёт команду движения и ждёт пока не выполнится заданное (третьим параметром) условие. После чего запишем несколько "кусков траектории" вызовами этой функции:

def drive_until(v, da, cond):
    t0 = time.time()
    send_cmd(v, da)
    while True:
        read_tele()
        dt = time.time() - t0
        if eval(cond):
            break

drive_until(-1, 0, 'x < -2')  # пятимся назад из "гаража"
drive_until(0, 0, 'dt > 4')   # сбрасываем набранный "задний ход"
drive_until(1, 0.1, 'th > 0.8') # вперед с лёгким заворотом влево до 45 град
drive_until(1, 0, 'y > 6') # прямо пока Y по одометру не достигнет 6
print("x=%s, y=%s, th=%s, left=%s, right=%s" % (x, y, th, lidar[-1], lidar[0]))

Как видим, функция попросту проверяет заданное ей "условие останова" с помощью eval(...) - для тех кого eval пугает можно заменить его на лямбду.

Итак код должен сделать следующее:

  • выехать "задним ходом" из гаража пока показание X по одометру не станет меньше -2 (на данной карте X оказался направлен снизу-вверх)

  • погасить набранную скорость в течение 4 секунд

  • двигаться вперед и немного влево с угловой скоростью 0.1, пока по одометру не будет зафиксировано достижение угла около 45 градусов

  • двигаться прямо, пока по одометру значение Y не достигнет величины 6

  • в конце распечатать значения с одометра и "крайние" показания с лидара - расстояния до стенок соответствующие видимым на картинке "лучам" (границам сектора лидара)

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

явно, что уже можно было начать поворот влево, к "мосту"
явно, что уже можно было начать поворот влево, к "мосту"

Для лучшего понимания добавим и видео - можно заметить, что на втором этапе, когда робот пытается остановить сменить направление движения, оказывается что он разогнался уже достаточно сильно и 4 секунд на гашение скорости ему не хватает - он начинает поворачивать сохраняя инерцию "заднего хода". Это нам не мешает - но позволяет понять насколько "инертна" наша машинка.

Итак, в чём проблема? Посмотрим какие результаты робот распечатывает по окончании движения. Повторим "заезд" несколько раз:

Заезд #1:
x=2.711, y=6.006, th=0.807, left=2.938, right=1.017

Заезд #N:
x=2.742, y=6.006, th=0.806, left=3.066, right=0.862

Я нарочно выбрал пару заездов которые окончились с достаточно различающимися результатами - видно что хотя показания одометра по Y идентичны (с точностью до округления до тысячных долей) - но по X разница уже в 1.5% а расстояние до верхней стены (справа) вообще отличается на 15%. Очевидно чем меньшее расстояние нужно уловить - тем больше сказывается погрешность.

Источников ошибки здесь два:

  • во-первых, как сказано выше, колёса могут немного проскальзывать, из-за чего и угол и вычисленные координаты потихоньку сбиваются

  • во-вторых мы отправляем команды асинхронно и нет гарантии на каком из маленьких "шагов" своего внутреннего цикла их получает контроллер - так что сказываются неточности связанные с моментом приёма и исполнения команд

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

Оговоримся, что некоторые успешные решения были всё-таки сданы только с использованием одометра. Многое зависит от выбранного маршрута и как именно пользоваться одометром. В частности, не следует оперировать "абсолютными" координатами, но лучше опираться на разницу координат (или угла) в начале и конце очередного участка траектории. В общем, если любопытно - поэкспериментируйте.

Один из путей сделать решение более "робастным", устойчивым к возникающим ошибкам - использовать лидар.

Для этого существуют разные подходы. Можно взять какую-нибудь готовую жирную библиотеку из категории SLAM - например, капитан нашей команды Анна Малышева, экспериментируя с ROS2 подключила подобный модуль и "вращая" робота вокруг стартовой позиции получила такую "карту" построенную им:

Используется RViz для визуализации полученной карты
Используется RViz для визуализации полученной карты

Здесь наглядно проявляется упомянутое выше замечание о том что лидар не очень точный - поэтому стенки выглядят как жирные множества точек. Кроме того заметно что как минимум в одном месте картопостроитель уже дал странный эффект.

Однако для данной задачи такой инструмент - немного оверкилл. Я (и как выяснилось - многие) пошли другим путём, используя лишь несколько "лучей" лидара - например крайние и центральный. Сейчас мы подробнее посмотрим "приёмы" работы в таком режиме.

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

Заведём вспомогательные переменные left и right просто чтобы не запутаться в индексах лидара, будем их обновлять в read_tele:

def read_tele():
    global x, y, th, lidar, left, right
    bytes = teleConn.recv(48)
    hdr, x, y, th, vx, vy, vth, wx, wy, wz, n = struct.unpack('<xxxx4s9fI', bytes)
    bytes = teleConn.recv(n * 4)
    lidar = list(struct.unpack('<%sf' % n, bytes))
    left, right = lidar[-1], lidar[0]

Завершим последний фрагмент траектории из примера выше по условию что до стенки осталось меньше 2 метров, и добавим следующий фрагмент - движение с поворотом в "обход" уточек и в направлении "мостика":

drive_until(-1, 0, 'x < -2')
drive_until(0, 0, 'dt > 4')
drive_until(1, 0.1, 'th > 0.8')
drive_until(1, 0, 'right < 3')
drive_until(1, 0.4, 'dt > 8')

Как видите, эту последнюю "дугу" робот будет завершать по времени (мы используем переменную dt которая считает время от начала данного фрагмента траектории внутри самой функции drive_until - но это ещё более неточно чем одометр, поэтому попробуем придумать другое условие.

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

С одним лучом, конечно, угол не сосчитать, но ведь лучей много - возьмём один из соседних, не ближайший (т.к. уж слишком хромает точность), а, допустим, через градус-полтора. Можно нарисовать такую картинку которая напомнит уроки геометрии из 6 класса:

здесь угол между "соседними" лучами гипертрофирован для наглядности
здесь угол между "соседними" лучами гипертрофирован для наглядности

Итак, мы рассматриваем два луча лидара, ОА (крайний правый, lidar[0]) и OB (отстоящий от него немного, скажем, lidar[5]). Угол AOB определяется количеством "шагов лидара" между этими лучами, то есть 0.25 градусов помноженные, в нашем случае, на 5, итого 1.25 градуса - назовём это углом phi.

В точку C на луче OB мы опустили перпендикуляр, так что OC=OA*cos(phi) и AC=OA*sin(phi). При этом в маленьком треугольничке ABC нетрудно найти второй катет: BC=OB-OC. После этого угол при вершине A (назовём его alpha) определяется элементарно - это же арктангенс: alpha=atan2(BC,AC) - и наконец можно сложить его с углом OAC чтобы получить угол OAB - именно угол луча OA со стеной:

OAB = OAC + alpha = (90 - phi) + atan2(BC, AC)

Конечно, вычисления в коде легче вести в радианах. Получится вот такая функция:

def rt_wall_angle():
    oa = lidar[0]
    ob = lidar[5]
    phi = 5 * math.pi/2 / 360 # сектор 90 градусов (pi/2) содержит 360 лучей
    oc = oa * math.cos(phi)
    ac = oa * math.sin(phi)
    return math.pi/2 - phi + math.atan2(ob - oc, ac)

Это не единственный способ вычисления интересующего нас угла - можно придумать достаточно много способов, различающихся по удобству и точности. Тем не менее для наших целей он подходит - мы теперь можем в некотором смысле "выравниваться" по стенам. Добавим аналогичную функцию для левой стены (можно вынести общий код в единую функцию и только передавать индексы в массиве значений лидара). Легко придумать и иные "трюки" - например добавить переменную d_right которая хранит изменение right с прошлой телеметрии - если оно положительно, то расстояние начало увеличиваться - например если луч "провалился" за угол. Добавим все эти придумки и получим код который "заруливает" робота на мост и становится почти прямо вдоль него.

drive_until(-1, 0, 'x < -2')
drive_until(0, 0, 'dt > 4')
drive_until(1, 0.1, 'th > 0.8')
drive_until(1, 0, 'right < 3')
drive_until(1, 0.4, 'dt > 8 and rt_wall_angle() > 1.8')
drive_until(1, 0, 'd_right > 0')
drive_until(1, -1, 'lidar[len(lidar)//2] > 3 and lt_wall_angle() > math.pi * 3/4')

Видео ниже демонстриует как робот проходит эти фрагменты. Дальше предоставим попрактиковаться самостоятельно. Если хочется посмотреть готовое решение - оно присутствует в репозитории. Если же будете "практиковаться" обратите внимание на два момента:

  • при совершении поворота, после того как заканчивается фрагмент траектории, робот ещё немного будет поворачивать, т.к. угловая скорость тоже не гаснет мгновенно - но если удачно выбирать условия окончания "фрагментов", то это не сильно мешает

  • нас интересует не только доехать до финиша но и сделать это достаточно быстро; рекордные решения проезжают быстрее 70 секунд - очевидно, хорошая скорость достигается удачным выбором маршрута (и способом его прохождения)

Замечание о ТАУ

На этапе составления команд в чате мелькали заявления "ищу напарника, разбираюсь в ML" или "знаю CV" - любопытно что эти "модные" скиллы для задач оказались неактуальны - в то же время успех сопутствовал знатокам ТАУ, более ощутимо во 2й и 3й задачах.

ТАУ - теория автоматического управления - один из важных "кирпичиков" в фундаменте робототехники - да и многих других отраслей. Впрочем, в некоторых современных библиотеках и фреймворках такие штуки спрятаны "под капотом" методов навигации и подобных (непонятно, удалось ли кому-то успешно ими воспользоваться на соревновании).

Не претендуя рассказать "Всё ТАУ за 5 минут" рассмотрим её на примере 1й задачи. Как было отмечено, наша функция drive_until(...) имеет явный недостаток - когда она завершается (по достижении заданного условия) - у робота остаётся заданная в начале функции скорость (как линейная так и угловая) - т.е. он продолжает двигаться и вращаться по инерции ещё некоторое время, в начале уже следующего "фрагмента траектории".

Чтобы этого избежать, можно действовать более утончённым способом, рассмотрим, например, такую функцию для поворота, целью которого является "встать под углом a к стене":

def turn_to_angle_with_wall(a):
    while True:
        read_tele()
        if abs(rt_wall_angle() - a) < 0.01:
            break
        send_cmd(0, rt_wall_angle() - a)

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

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

Собственно ТАУ и занимается способами создания таких "регуляторов". Есть и классические методы основанные на анализе системы уравнений движения - и "модерновые" (например регулятор можно представить маленькой нейронкой и просто натренировать его, если понятна целевая функция).

Упражнение на мосту - подруливание к стене

Более сложный - и более полезный пример из 1й задачи. Выше мы разобрались как построить примерную траекторию из "фрагментов" и заехать на мост - да ещё и приблизительно выровняться паралельно направлению этого моста. Однако из-за накапливающихся ошибок эта паралельная траектория может быть ближе или дальше от стены (например верхней) - из-за этого съехав с моста мы знаем своё направление, но не точно знаем положение (мы понимаем что мы у съезда с моста - но выше или ниже - неясно).

Хорошо бы построить регулятор, который при движении по верхней части моста "подруливает" на заданное расстояние (например, 0.25) к стене - так что робот дальше движется тоже паралельно, но съехав с моста оказывается в строго предсказуемой точке.

синяя линия - траектория на которую мы хотим "попасть"
синяя линия - траектория на которую мы хотим "попасть"

Попробуйте создать функцию такого вида:

angular_velocity(distance_to_wall, angle_to_wall)

То есть она должна в цикле, после каждого получения телеметрии, получать на вход вычисленное текущее расстояние до стены, и угол с этой стеной - а на выходе выдавать какую скорость нужно сообщить в очередную команду роботу send_cmd(1, angular_v) - т.е. двигаться всё с той же максимальной линейной скоростью, но скорость поворота менять в зависимости от того что выдал "регулятор".

Очевидно, на выходе должен быть 0 когда мы движемся на требуемом расстоянии и строго паралельно стене. В остальном с характером этой функции интересно поэкспериментировать. Попробуйте "изобрести" её, даже если не знаете ничего о ТАУ. При некоторой настойчивости вы скоро придёте к более-менее рабочему варианту.

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

Задача 2 - тайный лабиринт

Во второй задаче лабиринт проще в смысле геометрии - но зато его топология неизвестна. В задании включен пример лабиринта, но проверка производится на мире другой конфигурации (хотя размер тот же). Цель - попросту попасть в центр лабиринта.

Лабиринт размера 16*16 клеток по 0.5 метра каждая
Лабиринт размера 16*16 клеток по 0.5 метра каждая

Таким образом здесь две задачи:

  • "стратегическая" - научиться "исследовать" незнакомый лабиринт, заглядывая во все закоулки (в условии оговорено что все ответвления "слепые" - иначе говоря лабиринт односвязный)

  • "тактическая" - сделать так чтобы робот уверенно проезжал по коридорам, вписывался в повороты, ориентировался и т.п.

Первая задача на поверку тривиальна. Некоторые участники не разобравшись начинают вспоминать названия алгоритмов вроде "Дейкстра" или "А-со-звёздочкой" - но все это не имеет отношения, т.к. эти продвинутые алгоритмы относятся к ситуации когда топология уже известна и кроме того необходима возможность делать следующие шаги алгоритма из не связанных между собою узлов (т.к. это разновидности поиска в ширину) - а робот телепортироваться не умеет.

Возможно такая путаница была навеяна одной из задач прошлого года - там роботу давалось 3 проезда по лабиринту - т.е. на первом он может его исследовать как "неизвестный" - а уже на следующих пробовать построить оптимальный маршрут. Но в этот раз проезд только один...

Таким образом нам годится "поиск в глубину" - или он же под видом известного из детских книжек правила "держаться правой стены" (или левой).

Вторая задача, на "макро-уровне" гораздо сложнее. Робот как и в первой задаче подвержен инерции и неточностям управления. Например, в исходной позиции исходя из алгоритма "правой стенки" нам нужно проехать три клетки вперед и развернуться, попутно "посмотрев" лидаром налево (и убедившись что там ехать некуда):

три клетки вперед, разворот на 180, две клетки вперед, поворот вправо на 90 градусов...
три клетки вперед, разворот на 180, две клетки вперед, поворот вправо на 90 градусов...

Кроме того в лабиринте присутствуют дополнительные небольшие препятствия в виде конусов - однако в итоге проверка проходила на конфигурации где конусы не встречались на пути, поэтому больше их упоминать не будем.

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

В попытках противостоять этой проблеме я пришёл к функции angles которая вычисляет под каким углом робот находится по отношению к "сетке стен". Её значение меняется в пределах 90 градусов и равно 0 когда он "выровнен". В основе этой функции лежит простейшая статистическая обработка углов, которые образуют коротенькие фрагменты стен - отрезки между близкими значениями в массиве показаний лидара. Иными словами, взяв два луча лидара (например через 5 или 10 ячеек массива) мы определяем координаты точек (относительно робота) где эти лучи "втыкаются" в стены - получается "отрезок стены", заданный этими двумя точками. Его угол с координатной сеткой находится просто функцией atan2 - после чего мы определяем среднее по всем таким углам (с учетом поворотов кратных 90 градусам), и отбрасываем слишком "отдельно-стоящие" значения (вот код - в статью копировать эти 20 строк мы не будем т.к., кажется, вербальное объяснение лучше в данном случае).

Теперь, умея "выравнивать" робота я добавил функции:

  • forth - для движения вперед на заданное число клеток

  • turnl - для поворота влево на 90 градусов

  • turnr - то же, для поворота вправо

Одним из явных недостатков этих функций в текущей реализации является то что каждая из них завершается аккуратным "остановом" (при этом мы используем базовые принципы упомянутые выше в разделе о ТАУ) - из-за инерционности робота на это тратится довольно много времени. Однако такое "тормозное" решение было засчитано раньше чем я сумел довести "усовершенствованное", поэтому оставил как есть :)

Итак, мы имеем функции движения - осталось только написать сам алгоритм обхода лабиринта. Для этого потребовалась ещё одна функция find_break, которая отыскивает (по лидару) ближайшее ответвление в правой или левой стене. С такой функцией сам алгоритм получается очень простой:

telemetry();
while (1) {
	my ($rb, $lb) = (right_break(), left_break());
	if ($rb == 0 && $lb == 0) {
		turnl();
	} elsif ($rb > 0) {
		forth(int((front_dist() - 0.15) * 2) - $rb);
		turnr();
	} else {
		forth(int((front_dist() - 0.15) * 2) - $lb);
	}
}

Поясняя "по-русски" - робот из каждой очередной достигнутой точки (клетки) смотрит перед собой и определяет где ближайшее ответвление влево и вправо. Дальше действует так:

  • если находится хоть одно ответвление вправо - едет до него и поворачивает направо

  • в противном случае если есть хоть одно ответвление влево, едет до него но не поворачивает (а вдруг дальше есть еще ответвление влево)

  • если же не было ответвлений вообще - поворачивает влево

В этих трёх правилах заключён весь алгоритм "поиска в глубину" по правилу "держаться правой стены". Видео показывает как это работает. Не быстро - но с душой :) Можно заметить что 3-е правило создаёт маленькую оптимизацию - робот не посещает прямые "слепые" коридоры.

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

Дальнейшее возможное улучшение данного решения связано с тем чтобы научить робота без остановки отрабатывать движения в духе "прямо, затем поворот" - то есть запрограммировать небольшие шаблонные "тактические элементы". Впрочем, конечно, это несколько усложнит алгоритм обхода (например, если робот заехал в тупик, его нужно будет научить выезжать задним ходом).

Задача 3 - не упасть с платформы

Эта задача не была в окончательном виде сформулирована и подготовлена организаторами - у неё отсутствует подробное условие а в материалах контроллер явно не завершен - в нём отсутствует отработка команд движения - даны только примеры получения данных с камеры. Организаторы предложили всё это дописать самостоятельно, в том числе допустили что можно писать код алгоритма в самом контроллере, не ковыряясь с реализацией общения по сети. В связи с этим в репозитории присутствует контроллер udp_diff.py в том виде в каком его "дописал" я - из телеметрии он передаёт только данные с камеры глубины - этого оказалось достаточно.

В этой задаче робот оказывается на "змеевидной" платформе состоящей из прямых отрезков и этаких "кругов для разворота". Задача - доехать до последнего круга и не свалиться. За отсутствием стен лидар здесь бесполезен, поэтому робот должен ориентироваться на "камеру глубины" - она выдаёт прямоугольное "изображение" в котором "пиксели" имеют значение расстояния до препятствия (или 0 если в пределах действия камеры "луч" ни во что не упирается).

вид с исходной позиции
вид с исходной позиции
вид "трассы" целиком
вид "трассы" целиком

Понятно что в общих чертах работа с камерой глубины примерно похожа на работу с лидаром, но поскольку камера наклонена, использовать её для определения каких-либо углов затруднительно (да и края платформы ведь не везде ортогональны). Кроме того "видеть" куда нужно ехать робот может только с некоторого расстояния - если он уже заехал на "круг" или "поворот" сложной формы - здесь определиться куда именно нужно двигаться уже затруднительно.

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

вид с "экрана" камеры глубины, из начальной позиции
вид с "экрана" камеры глубины, из начальной позиции

По каждой из этих дорожек в простом цикле мы отсчитываем количество пикселей пока не будет найден "край". Найденные значения записаны в переменные $ff (прямо - зелёная линия), $fr (прямо-направо - синяя линия) и $rt (направо - красная линия).

Дальше оставалось только подобрать правила в духе "сворачивать левее если $rt < 12" и наоборот сворачивать правее если слишком удалились от края. Также добавлены условия чтобы увеличивать или уменьшать скорость - но все они действуют подобным "ступенчатым" образом, не имеют плавной регулировки. Как пояснили знатоки, в контексте ТАУ это можно назвать самым простым, "релейным" регулятором. Как бы оно ни называлось, получился такой код, совершающий довольно грубоватое, но приводящее к цели движение:

while (1) {
    telemetry();
    my ($rt, $fr, $ff);
    for ($ff = 0; $ff < $camh && view(0, $ff); $ff++) {};
    for ($fr = 0; $fr < $camh && view(int($fr)/3, $fr); $fr++) {};
    for ($rt = 0; $rt < $camw/2 && view($rt, 15); $rt++) {}
    if ($rt < 12) {
        command($rt < 10 ? 0 : 0.05, 0.3);
    } elsif ($fr < 22) {
        command(0.1, 0.3);
    } elsif ($fr > 25) {
        command(0.1, -0.3);
    } else {
        command($ff > 36 ? .6 : .25, 0);
    }
}

Здесь он приведён не для того чтобы вчитываться (целиком программа есть в репозитории) - а чтобы показать насколько коротка в общем-то "управляющая программа". Видео ниже демонстрирует прохождение маршрута - однако поскольку весь процесс занимает больше 6 минут, оно ускорено (4x) и содержит только первую половину (при желании запустите решение и посмотрите живьём до конца).

Один из участников (Даниил) показывал более "умное" на мой взгляд решение - использующее определение "дорожки" по камере и красиво сворачивающее под прямыми углами - но на момент демонстрации оно не доходило до конца по причине упомянутой выше - робот не может сориентироваться в выборе направления дальнейшего движения (это нужно делать заранее, когда еще видно куда ехать).

Заключение

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

Нелишне будет произнести благодарности - организаторов не упоминаю поскольку не знаю их ни лично ни по именам, в чате они под обезличенным аккаунтом присутствуют. Кроме этого стоит сказать спасибо коллегам-участникам из других команд, среди которых с большим позитивом упомянем Михаила Богомолова и Даниила Тутубалина (надеюсь я правильно переписал имена с ников в ТГ) - которые не только нарешали больше, раньше и лучше других - но и достаточно щедро делились советами и идеями с прочими участниками.

Из нашей команды (Раскольники) благодарность, конечно, выношу нашему капитану - Анне Малышевой, которая в общем-то и втянула меня в участие в этом году. Притом занятно было встретиться для обсуждения задач с нею и сокомандниками - учитывая что кажется не виделись со времён пандемии :) по этому поводу было сделано симпатишное фото которым я осмелюсь завершить своё повествование. Спасибо также и всем внимательным читателям!

Анна - в настоящее время студент магистратуры ИТМО
Анна - в настоящее время студент магистратуры ИТМО

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


  1. NutsUnderline
    21.10.2025 06:51

    Содержательно и без воды, хотя бы я не сказал что это "туториал".

    единственное что я вынес из ТАУ - все модели не идеальны, реальный мир потребует поправок :)

    ну а то что сложные системы иногда глючат, и все надо прогонять неоднократно (а времени на это нет) жизнь учит наверное всех.

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


    1. RodionGork Автор
      21.10.2025 06:51

      я не сказал что это "туториал".

      попробуем убрать этот флажок :) честно сказать - не понял до сих пор их предназначения

      в команду для таких задач возможно стоит взять 

      хорошая мысль! да и в целом стало понятно (не только на нашем опыте) что к составу команды в идеале нужно отнестись ответственно и подобрать людей кому это интересно и кто реально готов что-то делать :)


      1. NutsUnderline
        21.10.2025 06:51

        как видим, полезные советы тоже можно давать :) :)


  1. ksotar
    21.10.2025 06:51

    Спасибо, это очень хорошая статья!
    Решение третьей и правда, лаконичное и надёжное. Первая в этом смысле выглядит даже сложнее :)

    Пришлось нажать кнопку "Я не робот" чтобы зайти на Хабр :)


    1. RodionGork Автор
      21.10.2025 06:51

      Мне всегда приходится жать :) м.б. потому что в incognito режиме браузер обычно, просто чтобы реклама не накапливалась

      Спасибо за отзыв!