Отборочный этап «TrueTechChamp» завершился и можно поговорить о подходах к задачам. Здесь будут описаны наивные решения программиста, почти незнакомого с робототехникой, впрочем, зато по всем трем задачам — из чего автор делает вывод что узкоспециальные познания тут не требуются — это развлечение доступное, в общем‑то, всем:‑)
Задачи были такие: 1) проехать по известному «лабиринту» из двух комнат с фиксированными препятствиями, то есть запрограммировать фиксированный маршрут — кое‑кто бился над этим неделю и больше — но всё же решений около сотни; 2) проехать неизвестный лабиринт из стенок под прямыми углами — с этим справились вчетверо меньше команд; 3) проехать по змеевидной платформе, используя камеру глубины, и не упасть за край — мне известно примерно о двух с половиной решениях её.
Сейчас подробно рассмотрим какие были сложности и как с ними можно справиться. И да, организационные проблемы преследовали мероприятие до последнего дня, но об этом уже немало сказано, в том числе в сильных выражениях:) В любом это вне «фокуса» данной статьи. Сосредоточимся на задачах!
Общие замечания
Задания выполнялись с помощью симулятора Webots — участники получали проект, описывающий «мир» и самого робота а также некий промежуточный код который позволял управлять роботом по сети, так что решение можно было писать практически на любом языке — нужно было предоставить докер‑файл который подготавливает все нужные зависимости и запускает код — дальше мы просто отправляем команды для движения по UDP и принимаем данные с датчиков робота по TCP.
Примечание: данный симулятор по отзывам работает не слишком стабильно в разных системах — особенно много замечаний было от пользователей разных версий Windows. Мне видимо повезло — на моем ноутбуке Ubuntu 24.04 и с проблемами я не сталкивался.
Материалы задач можно взять в репозитории https://github.com/RodionGork/mts‑challenge-2025 и «поиграться» самостоятельно — в том числе и применить к ним описанные ниже подходы и решения.

Робот — четырёхколёсная тележка с типичным «дифференциальным» приводом — то есть внутри кода контроллера управляются скорости всех четырёх колёс — но для участника это объединено в возможность задать требуемую скорость движения вперед и скорость поворота (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 подключила подобный модуль и "вращая" робота вокруг стартовой позиции получила такую "карту" построенную им:

Здесь наглядно проявляется упомянутое выше замечание о том что лидар не очень точный - поэтому стенки выглядят как жирные множества точек. Кроме того заметно что как минимум в одном месте картопостроитель уже дал странный эффект.
Однако для данной задачи такой инструмент - немного оверкилл. Я (и как выяснилось - многие) пошли другим путём, используя лишь несколько "лучей" лидара - например крайние и центральный. Сейчас мы подробнее посмотрим "приёмы" работы в таком режиме.
Для примера, попробуем поправить наш код выше, чтобы он начал "заворачивать" обходя уточек сверху - скажем, в качестве условия поставим приближение к верхней стене, регистрируемое по правому лучу лидара.
Заведём вспомогательные переменные 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 - тайный лабиринт
Во второй задаче лабиринт проще в смысле геометрии - но зато его топология неизвестна. В задании включен пример лабиринта, но проверка производится на мире другой конфигурации (хотя размер тот же). Цель - попросту попасть в центр лабиринта.

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

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

ksotar
21.10.2025 06:51Спасибо, это очень хорошая статья!
Решение третьей и правда, лаконичное и надёжное. Первая в этом смысле выглядит даже сложнее :)
Пришлось нажать кнопку "Я не робот" чтобы зайти на Хабр :)
RodionGork Автор
21.10.2025 06:51Мне всегда приходится жать :) м.б. потому что в incognito режиме браузер обычно, просто чтобы реклама не накапливалась
Спасибо за отзыв!
NutsUnderline
Содержательно и без воды, хотя бы я не сказал что это "туториал".
единственное что я вынес из ТАУ - все модели не идеальны, реальный мир потребует поправок :)
ну а то что сложные системы иногда глючат, и все надо прогонять неоднократно (а времени на это нет) жизнь учит наверное всех.
в команду для таких задач возможно стоит взять реального гонщика умеющего "в дрифт". у них как раз задача - оптимизировать траекторию движения с учетом физики, причем это работает "аппаратно" - на уровне подсознательно и рефлексов. опять же выясняться недостатки виртуальной модели, но проще будет в реал все вынести.
RodionGork Автор
попробуем убрать этот флажок :) честно сказать - не понял до сих пор их предназначения
хорошая мысль! да и в целом стало понятно (не только на нашем опыте) что к составу команды в идеале нужно отнестись ответственно и подобрать людей кому это интересно и кто реально готов что-то делать :)
NutsUnderline
как видим, полезные советы тоже можно давать :) :)