Lidar_Muxa/README.md

68 KiB
Raw Blame History

FlyGuard — обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара

Решение кейса «Система обнаружения посторонних объектов для беспилотных поездов в тоннеле метро» (хакатон «Лидеры цифровой трансформации 2026», направление «Город»; заказчик — Департамент транспорта Москвы / ГУП «Московский метрополитен»).

На вход — поток облаков точек Hesai Pandar128. На выход — ответ на единственный важный вопрос: «путь свободен» или «впереди препятствие на N метров».

Быстрый старт для жюри

Образ — файлом, если на стенде нет сети (архив — в разделе «Стенд без интернета»), или сборкой из репозитория там, где сеть есть:

docker load -i flyguard_image.tar
docker build -t flyguard .

Узел — в одном терминале, запись — в другом:

docker run --rm -it --gpus all --network host --ipc host flyguard
ros2 bag play --read-ahead-queue-size 10 /путь/к/записи

В консоли узла (пример — запись doubleT_obstacle, сокращено):

вычисления: видеокарта NVIDIA GeForce RTX 4070 Ti SUPER (16 ГБ, CUDA 12.8) — …
калибровка решётки лучей: 1/12
ПРЕПЯТСТВИЕ: 55.7 м, уверенность 0.36, объектов 1
путь свободен
итог: принято кадров 201, обработано 190, на калибровку 11, пропущено 0; кадров с тревогой 189
  • Без видеокарты — та же команда без --gpus all, результат тот же.
  • Топик с облаком узел находит сам, какое бы имя у него ни было.
  • --ipc host обязателен: без него кадры с хоста до контейнера не доходят вовсе.
  • Результат для программ — топики /flyguard/obstacle, /flyguard/detected, /flyguard/distance (раздел «Выходные данные»); RViz и схема мозга мухи — раздел «Демонстрация со схемой мозга мухи».

В двух словах

ТЗ формулирует главный вызов так:

«Возможно, нужно научиться хорошо описывать нормальный тоннель, а затем искать всё, что в него не вписывается.»

Это дословное описание того, чем занимается мозг Drosophila melanogaster. Муха решает ровно нашу задачу — на лету, без разметки, без одометрии, с мизерным бюджетом нейронов и в жёстком реальном времени. Поэтому архитектура FlyGuard собрана из её вычислительных схем, взятых из коннектома (FlyWire / hemibrain):

Проблема кейса Схема мухи Модуль
Разметки нет, почти всё — пустой тоннель грибовидное тело: KC + APL + MBON, подавление знакомого mushroom_body.py
Нельзя путать своё движение с чужим объектом T4/T5 → LPTC, широкопольный оптический поток medulla.py
Увидеть заранее, независимо от размера LPLC2 — детектор надвигания medulla.py
Мелкий объект = 5 лучей на кадр кольцевой аттрактор эллипсоидного тела: накопление улик central_complex.py
На 170 м контраст к фону равен нулю веерное тело: улики копятся в координатах мира, а не кадра fan_body.py
Не реагировать на штатные конструкции депрессия синапсов KC→MBON mushroom_body.py
Сенсор закреплён нежёстко жужжальца и оцеллии: стабилизация «взгляда» geometry.py
100 мс на кадр, слабый CPU разрежённый бинарный код, 0.1 % активных клеток mushroom_body.py

Подробный разбор с привязкой к типам нейронов — в docs/ALGORITHM.md и docs/CONNECTOME.md.


Архитектура

ROS 2 bag  →  /lidar_points (PointCloud2, 0.3–0.9 млн точек, 10 Гц)
     │
     ├─ RETINA          омматидиальная решётка → дальностный образ 128 × N
     ├─ HALTERES        плоскость пути: крен, тангаж, высота сенсора
     ├─ LAMINA          диспаритет 1/R, ON/OFF, центр-окружение на 3 масштабах
     ├─ MEDULLA/LP      T4/T5 → LPTC: скорость поезда без одометрии; LPLC2: надвигание
     ├─ LOBULA          LC11: кандидаты; разрез по контрасту отделяет предмет от стены
     ├─ MUSHROOM BODY   новизна: подавление знакомой обстановки тоннеля
     ├─ FAN-SHAPED BODY накопление лучей в координатах пути: улика там, где нет контраста
     ├─ CENTRAL COMPLEX накопление улик в координатах пути, треки
     └─ DESCENDING      два порога: предупреждение и экстренное торможение
     │
     ▼
/flyguard/obstacle  ·  /flyguard/markers  ·  /flyguard/brain  ·  /flyguard/diagnostics

Детально — docs/ARCHITECTURE.md.


Сборка

docker build -t flyguard .

Образ основан на ros:humble-ros-base-jammy (Ubuntu 22.04 + ROS 2 Humble). Все зависимости ставятся на этапе сборки; во время работы сеть не нужна. Обе обученные части лежат в образе и подключаются сами: память тоннеля (artifacts/mushroom_body.npz) и считывание MBON (artifacts/mbon_readout.npz) — ровно та конфигурация, что замерена в разделе «Результаты».

Собранный образ проверяется одной командой:

docker run --rm -v "$PWD/docker:/smoke:ro" flyguard bash /smoke/smoke_test.sh

Стенд без интернета

Организаторы подтвердили: на тестовом сервере сети нет. Узлу она и не нужна — всё ставится при сборке. Но сама docker build без сети не пройдёт: ей нужны базовый образ ros:humble-ros-base-jammy и пакеты apt. Поэтому образ собирается там, где сеть есть, и переносится файлом:

docker save flyguard | gzip > flyguard_image.tar.gz      # на машине с сетью
docker load -i flyguard_image.tar.gz                      # на стенде

После docker load все команды ниже работают как есть. Образ — 9.3 ГБ, из них 6.6 ГБ — PyTorch с библиотеками CUDA (раздел «Видеокарта»); архив — около 5 ГБ. Наш готовый архив — flyguard_image.tar, 4.7 ГБ: слои в нём уже сжаты, поэтому gzip поверх не нужен, а docker load -i принимает и .tar, и .tar.gz. Проверено на чистом движке: архив загружен в Docker Desktop 29.8 (Windows 11, WSL 2) и работает с --gpus all и без него.

Если сети нет и там, где собирается образ, колёса PyTorch скачиваются на соседней машине (python docker/fetch_wheels.py) и отдаются сборке любым HTTP-сервером — аргумент TORCH_WHEELS, подробности в docker/wheels/README.md.

Запуск

Одной командой — детектор и проигрывание бэга вместе:

docker run --rm -it --gpus all --network host --ipc host -v /path/to/bags:/data flyguard \
    ros2 launch flyguard detect.launch.py bag:=/data/doubleT_obstacle

Или раздельно — узел в контейнере, а ros2 bag play на хосте, как на поезде:

docker run --rm -it --gpus all --network host --ipc host flyguard
ros2 bag play --read-ahead-queue-size 10 /path/to/bags/doubleT_obstacle

--ipc host обязателен, если bag проигрывается на хосте. С --network host Fast DDS считает контейнер и хост одной машиной и передаёт кадры через разделяемую память /dev/shm, а без --ipc host у контейнера она своя. Кадры тогда теряются молча: замерено, узел не получает ни одного кадра, а с флагом — 252 из 252. Если флаг всё же забыт, узел через пять секунд после появления издателя пишет в журнал, в чём дело. Проигрывание внутри контейнера (первая команда или второй контейнер из этого же образа) работает и без флага: точка входа видит собственную /dev/shm и переводит транспорт на UDP.

Профиль транспорта для кадров в 24 МБ лежит в образе и подключается сам (docker/fastdds_large.xml); снять — FLYGUARD_DDS_PROFILE=0.

--read-ahead-queue-size 10 тоже не косметика: по умолчанию проигрыватель читает вперёд 1000 сообщений, при кадре в 24 МБ это 24 ГБ, и пока он их читает, первые секунды записи успевают «просрочиться» и не публикуются вовсе (без флага доходит 119 кадров из 201). В первой команде launch ставит его сам.

С RViz окну нужен доступ к экрану хоста:

xhost +local:
docker run --rm -it --network host --ipc host -e DISPLAY=$DISPLAY \
    -v /tmp/.X11-unix:/tmp/.X11-unix -v /path/to/bags:/data flyguard \
    ros2 launch flyguard detect.launch.py bag:=/data/doubleT_obstacle rviz:=true

RViz в контейнере без доступа к видеокарте рисует программно (Mesa llvmpipe на процессоре): вращение мышью идёт рывками по 2–5 кадров в секунду, хотя в настройках стоит Frame Rate: 30 — это потолок, а не факт. Замер на doubleT_obstacle: программный RViz занимает 1.9 ядра, с видеокартой — 0.2 ядра, в 9 раз меньше. Видеокарта пробрасывается так:

Где Добавить к docker run
Linux, NVIDIA (нужен nvidia-container-toolkit) --gpus all (графика драйвера включена в образе: NVIDIA_DRIVER_CAPABILITIES=all)
Linux, встроенная Intel/AMD --device /dev/dri
Windows: Docker Desktop или Docker в WSL 2 --device /dev/dxg -v /usr/lib/wsl:/usr/lib/wsl:ro -e LD_LIBRARY_PATH=/usr/lib/wsl/lib (этих флагов хватает и для CUDA; одного --gpus all в Docker Desktop мало — CUDA будет, а RViz рисует процессором)

Проверено на Windows 11 + WSL 2 (RTX 5070 Ti, D3D12) — в Docker внутри WSL и в Docker Desktop 29.8; строки для Linux — стандартные флаги, на стенде не проверялись. Экран на Windows даёт WSLg: из терминала Ubuntu — -e DISPLAY=:0 -v /tmp/.X11-unix:/tmp/.X11-unix, из PowerShell с Docker Desktop — -e DISPLAY=:0 -v /run/desktop/mnt/host/wslg/.X11-unix:/tmp/.X11-unix. Записи на Windows держите в файловой системе WSL (/root/bags и т. п.): с диска C: через общую папку Docker Desktop кадры по 24 МБ идут около одного в секунду — замерено, за минуту дошло 62 кадра из 201. Чем рисует OpenGL, показывает проверка без окна:

docker run --rm -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix <флаги видеокарты> \
    -v "$PWD/docker:/smoke:ro" flyguard python3 /smoke/gl_probe.py

Можно и вовсе запустить RViz на хосте с нашим конфигом, если там стоит ROS 2: rviz2 -d ros2_ws/src/flyguard/config/flyguard.rviz. На обнаружение всё это не влияет: детектор с программным RViz и с аппаратным обработал одни и те же 190 кадров из 190.

RViz показывает не сырое облако, а облако обзора, которое публикует сам узел (/flyguard/view_cloud): лучи сектора обработки, выровненные по плоскости рельсов, — до 77 тысяч точек вместо 900 тысяч. Рамки препятствий стоят в тех же координатах, пол пути лежит на сетке. Сырое облако RViz не тянет (24 МБ на кадр), и топик у записей разный — /lidar_points в пяти записях и /sensing/lidar/hesai128/pointcloud в doubleT_obstacle, — а облако обзора есть всегда, из какого бы топика узел ни читал. Публикуется, только когда на него кто-то подписан, и в замеры задержки не входит.

Оранжевый контур — габарит, который узел проверяет. Ось пути впереди узел оценивает в каждом кадре по сечению тоннеля, и в кривой габарит изгибается вместе с ней. Проверяется объединение изогнутого габарита и прямого, вдоль оси лидара: неточная оценка оси не должна сужать зону поиска. На прямом пути контуры совпадают, в кривой рисуются оба — изогнутый ярко, прямой бледно (рисунок). Рамки препятствий стоят у своих точек. На кривой синтетики организаторов (радиус около 760 м) ось сходится с положением предмета до 0.04 м на 22 м, до 0.15 м на 39 м и до 0.27 м на 64 м (EXPERIMENTS п. 20).

Чтобы не ждать до нужного места, проигрывание начинается с любой секунды: start:=18.

Видеокарта

Раскладка точек по решётке, ламина и кластеризация — три стадии, работающие с целым образом 128 × 600 лучей, — считаются на видеокарте, если узел её видит; остальное на процессоре. Для этого контейнеру нужен --gpus all (на Linux — с nvidia-container-toolkit). Без флага, без видеокарты или при любом её сбое на ходу узел считает на процессоре — с тем же результатом: на видеокарте те же операции в тех же типах, и решения совпадают с процессорными покадрово на синтетике и шести записях (EXPERIMENTS, п. 21). Где идёт счёт, узел пишет в журнал:

вычисления: пока процессор — видеокарту проверяю и прогреваю в фоне (в Docker под WSL до 20 с), результат тот же
вычисления: видеокарта NVIDIA GeForce RTX 5070 Ti (16 ГБ, CUDA 12.8) — сетчатка, ламина, кластеризация; остальное на процессоре (готова через 16.0 с после старта)

Видеокарта поднимается в фоне, а узел подписывается на лидар сразу и первые кадры считает на процессоре; на видеокарту он переходит между кадрами. Первый запуск ядер CUDA в новом контейнере под WSL длится 15 с (на Windows без контейнера — 0.9 с), и раньше узел всё это время не был подписан: запись, пущенная сразу, теряла первые 15 с. Теперь запуск одновременно с записью даёт те же 201 кадр из 201 и 189 обнаружений из 190. Зависни драйвер совсем, узел останется на процессоре, а не встанет.

Кадр в контейнере (медиана / p95), видеокарта против процессора:

Где doubleT_obstacle синтетика организаторов
Docker Desktop 29.8 (Windows 11, WSL 2), --gpus all 23.9 / 35.2 мс против 34.8 / 39.4 26.4 / 42.6 мс против 42.7 / 48.0
Docker Engine в Ubuntu под WSL, /dev/dxg 23.4 / 30.2 мс против 31.8 / 34.9 —

Решения одинаковые: 189 обнаружений из 190 и 398 кадров с тревогой из 1499 в обоих режимах. Принудительно на процессоре — device:=cpu. В Docker Engine, поставленном прямо в Ubuntu под WSL (без Docker Desktop), вместо --gpus all: --device /dev/dxg -v /usr/lib/wsl:/usr/lib/wsl:ro -e LD_LIBRARY_PATH=/usr/lib/wsl/lib. Под WSL драйвер CUDA пишет в каждый новый контейнер свой кэш, около 330 МБ, а виртуальный диск Docker от этого растёт и сам не сжимается; если на диске тесно — -e CUDA_CACHE_DISABLE=1, на скорость это не влияет.

Пауза, перемотка, покадрово

Проигрыватель, запущенный из launch (bag:=...), клавиатуры не слышит. Для него в образе есть пульт: контейнер запускается с именем, а пульт — во втором терминале.

docker run --rm -it --name flyguard_demo --network host --ipc host -e DISPLAY=$DISPLAY \
    -v /tmp/.X11-unix:/tmp/.X11-unix -v /path/to/bags:/data flyguard \
    ros2 launch flyguard detect.launch.py bag:=/data/doubleT_obstacle rviz:=true
docker exec -it flyguard_demo flyguard-keys /data/doubleT_obstacle
Клавиша Действие
пробел пауза / продолжить
← → на 3 секунды назад / вперёд
. один кадр вперёд (на паузе)
↑ ↓ быстрее / медленнее (×0.1 … ×4)
0 в начало записи
q выйти, запись играет дальше

На паузе RViz держит последний кадр и рамки, и сцену можно крутить. После перемотки узел сам замечает скачок времени записи и начинает треки, одометрию и ось пути заново — иначе предмет с прошлого места всплыл бы там, где его нет; то же самое происходит на каждом круге ros2 bag play --loop. Если ros2 bag play запущен вручную в своём терминале, пульт не нужен: у проигрывателя свои клавиши (пробел — пауза, → — следующий кадр, ↑ ↓ — скорость).

Все записи подряд, по строке сводки на каждую (узел поднимается заново для каждой записи):

docker run --rm --network host --ipc host -v /path/to/bags:/data:ro \
    -v "$PWD/docker:/smoke:ro" flyguard bash /smoke/check_all.sh

Итог узел пишет прямо в консоль контейнера — смену состояния и, пока тревога держится, ближайшую дальность раз в секунду; когда тревога снимается, пишет путь свободен. Так выглядит запись с настоящим препятствием (doubleT_obstacle, объект на 55–57 м):

[detector-1] [WARN] [1790249076.653486850] [flyguard]: ПРЕПЯТСТВИЕ: 55.7 м, уверенность 0.36, объектов 1
[detector-1] [WARN] [1790249327.983883704] [flyguard]: препятствие: 55.9 м, уверенность 0.51, объектов 1
[detector-1] [WARN] [1790249336.988973904] [flyguard]: препятствие: 56.0 м, уверенность 0.68, объектов 2

Если поезд движется, к строке добавляется время до столкновения.

Полный программный выход без RViz:

ros2 topic echo /flyguard/obstacle

Облака без поля ring и с нарушенным порядком точек

Синтетический бэг организаторов (cloud_with_fake_obj, 24.09) устроен не так, как записи с поезда: в облаке только x, y, z, intensity, без ring и timestamp, а там, где вставлен предмет, заслонённые им точки удалены и точки предмета вписаны в середину массива. Число точек в кадре гуляет от 214 до 353 тысяч, и порядок «столбец · эхо · кольцо» нарушен даже в кадрах ровно на 307 200 точек — ни в одном из первых двенадцати он не цел. Узел это переносит сам:

  • кольца восстанавливаются по гистограмме элевации (у лазерного канала она постоянна до 0.0001°, соседние каналы Pandar128 разнесены на 0.086°);
  • каждый кадр проверяется: у целого кадра элевация каждой точки совпадает с элевацией её кольца. Такой кадр идёт быстрым путём по порядку точек, а кадр с нарушенным порядком раскладывается в ту же решётку по углам каждой точки. На целом кадре оба пути дают образ бит в бит; раскладка по углам дороже: 11 мс против 6 на синтетике (скан 120°), 22 против 6 на круговом скане. В контейнере кадр синтетики — 43.7 / 50.2 мс (медиана / p95).

До этой правки узел на синтетике не обработал бы ни одного кадра: калибровка решётки требовала поле ring.

Контрольная запись может прийти и в другом виде, поэтому формат проверяется инструментом: настоящие кадры переписываются в 15 других обличий, проходят через тот же разбор CDR, что в узле, и через конвейер, а решения сравниваются с исходными:

python tools/check_formats.py
Обличье Итог
«нет эха» — NaN вместо нулей; шапка 128 × N; поля в другом порядке и с выравниванием; яркость uint8; координаты float64; без ring и timestamp; только точки с эхом без ring; 5 Гц; одинаковое время кадров решения те же
без поля intensity было: падение на каждом кадре. Яркость теперь не обязательна, без неё — нули
ring есть, но точки разложены по кольцам или идут только с эхом было: падение на каждом кадре. Порядок проверяется по полю ring, и при другом порядке решётка строится по углам
ring есть, порядок перемешан было: дальности врали на десятки метров. Исправлено тем же
система координат с осью X вперёд (REP-103) было: узел молча не видел ничего — рабочий сектор смотрел в стену. Теперь «вперёд» находится по дальним эхам: далеко лидар видит только вдоль тоннеля. Поворот признаётся, только если в переднем секторе дальних эх почти нет (меньше 2 %), а в другом — больше половины; на всех выданных записях в переднем секторе 100 % дальних эх. Узел пишет в журнал, что поворачивает кадры

После правок все обличья дают те же решения, что исходный кадр: на doubleT_obstacle — в каждом кадре и с той же дальностью.

Демонстрация со схемой мозга мухи

docker run --rm -it --network host --ipc host -e DISPLAY=$DISPLAY \
    -v /tmp/.X11-unix:/tmp/.X11-unix -v /path/to/bags:/data flyguard \
    ros2 launch flyguard detect.launch.py bag:=/data/doubleT_obstacle rviz:=true brain_view:=true

Топик /flyguard/brain отдаёт мозг дрозофилы, подсвеченный живой активностью: видно, как загорается ламина на контрасте, лобулярная пластинка на скорости, доли грибовидного тела на новизне и гигантское волокно в момент решения. Вид отключён по умолчанию, чтобы не попадать в замеры задержки; когда включён, рисуется пять раз в секунду по 15 мс. С brain_view:=true RViz берёт свой конфиг: схема справа во всю высоту, панель Displays свёрнута — открывается стрелкой у левого края окна.

Три варианта, brain_style:

Значение Что показывает
scheme нарисованная схема нейропилей с подписями
cloud 139 255 настоящих нейронов коннектома FlyWire на своих анатомических местах
hybrid по умолчанию: сверху панели решётки лучей (дальность, ON/OFF ламины, кандидаты), снизу облако нейронов

Облако — не симуляция. Мембранные потенциалы 139 тысяч клеток никто не интегрирует: коннектом даёт анатомию и принадлежность клеток, конвейер даёт активность по стадиям, а вид накладывает одно на другое. Привязка держится на именах типов клеток, и все они есть в выгрузке поимённо — LC11 (127 нейронов), LPLC2 (210), HS/VS (22), T4/T5 (12 245), клетки Кеньона (5177), MBON (96), APL (2), гигантское волокно DNp01 (2). Ровно те схемы, из которых собран FlyGuard.

Для экрана и видео вид рисуется крупнее: brain_scale:=2 — 2360 × 1572 (под 2K), brain_scale:=3 — 3540 × 2358 (под 4K). Сомы берутся из атласа своего размера, это настоящие координаты, а не растянутая картинка; шрифты, линии и отступы растут вместе с масштабом. Кадр вида рисуется 14 мс в 1×, 57 мс в 2× и 113 мс в 3×, пять раз в секунду; в 2× на записи с поезда и на синтетике узел принял все кадры. По умолчанию масштаб 1 — прежние 1180 пикселей по ширине.

Атласы (по 620–660 КБ, три размера) лежат в пакете и пересобираются из публичных выгрузок Codex:

python tools/build_brain_atlas.py
python tools/build_brain_atlas.py --width 2360 --height 1240 --margin 36 --out ros2_ws/src/flyguard/flyguard/data/brain_atlas_x2.npz

Данные FlyWire — CC-BY 4.0 (Dorkenwald et al., Schlegel et al., Nature 2024).


Выходные данные

Топик Тип Назначение
/flyguard/obstacle flyguard_msgs/ObstacleStatus основной программный выход
/flyguard/detected std_msgs/Bool бинарный статус для простой интеграции
/flyguard/distance std_msgs/Float32 расстояние до ближайшего объекта, м (−1 — свободно)
/flyguard/markers visualization_msgs/MarkerArray рамки объектов для RViz
/flyguard/view_cloud sensor_msgs/PointCloud2 облако обзора для RViz: сектор обработки в координатах пути, только при подписчике
/flyguard/brain sensor_msgs/Image мозг мухи с живой активностью (схема, облако нейронов или гибрид)
/flyguard/diagnostics diagnostic_msgs/DiagnosticArray задержки по стадиям, скорость, радиус кривой

ObstacleStatus содержит: detected, emergency, distance, time_to_collision, confidence, speed, stopping_distance, processing_ms и список объектов с габаритами, числом лучей, новизной и стабильным track_id.


Параметры

Все настройки — в ros2_ws/src/flyguard/config/flyguard.yaml, переопределяются при запуске:

ros2 launch flyguard detect.launch.py fov_deg:=35.0 half_width:=1.5
Параметр По умолчанию Смысл
input_topic /lidar_points топик лидара. Узел слушает и запасные имена (/sensing/lidar/hesai128/pointcloud, /points_raw), а если облако идёт в топик с другим именем, через секунду находит его сам, подписывается и пишет об этом в журнал
device auto где считать сетчатку, ламину и кластеризацию: auto — видеокарта, если есть (контейнер с --gpus all), иначе процессор; cuda; cpu. Видеокарта поднимается в фоне, первые кадры считает процессор. Результат одинаков, см. раздел «Видеокарта»
mbon_path из образа обученное считывание MBON (.npz); пусто — ручная формула веса улики
mbon_power 1.5 резкость считывания: вес наблюдения — вероятность в этой степени, см. EXPERIMENTS п. 17.4
half_width 1.2 полуширина габарита, м. Так его задают организаторы в синтетике 24.09: «у края» — до 1.13 м от оси, «вне габарита, но близко» — с 1.14 м. При прежних 1.6 второй давал ложную тревогу, см. EXPERIMENTS п. 18.4
h_lo / h_hi 0.28 / 2.3 границы основной части габарита по высоте над головкой рельса, м
h_top / half_width_top / top_d_max 3.3 / 1.0 / 90 верхняя секция: от h_hi до h_top, уже основной и не дальше top_d_max — то, что свисает со свода в путь вагона. Ищется отдельным проходом (top_detect), в котором свод не склеивается со свисающим; стоящее, что уходит вниз к полу, остаётся основному проходу. Верх 3.3, а не выше: у свода свои кабели и кронштейны, и на 60–90 м ошибка наклона опускает их в секцию. h_top: 0 — выключить
hover_floor 0.5 висящее посреди габарита: для кандидата целиком в габарите, низом выше 0.6 м, ближе 80 м, компактного вдоль пути (до 1.5 м) и не тоньше 0.2 м вероятность считывания не ниже этой. Считывание учили на стоящих предметах, и куб 0.3 м на высоте 1.2 м оно гасило: находился с 31 м, с правилом — с 63 м, ложных тревог столько же. 0 — выключить, см. EXPERIMENTS п. 24
flat_h / flat_top / flat_d / flat_w 0.06 / 0.26 / 70 / 0.15 плоское у пола: высотой меньше flat_h, целиком ниже flat_top над рельсом, не дальше flat_d — вес наблюдения умножается на flat_w. Пластины на полотне, края жёлоба, порог гермозатвора: организаторы подтвердили, что в жёлобе — не препятствие. flat_h: 0 — выключить
h_lo_core / core_from 0.16 / 30.0 пол между рельсами и дальность, с которой он опущен: иначе упавший на пути человек (0.30 м) виден верхушкой в два сантиметра. Ближе 30 м пол прежний — там в полосу попадают головки рельсов, см. EXPERIMENTS п. 16.3
d_min / d_max 4.0 / 220.0 зона поиска по дальности, м
fov_deg 30.0 полусектор обработки по азимуту, °
min_rays 4 минимум лучей на кандидата
memory_path из образа обученная память тоннеля (.npz)
brain_view false публиковать вид мозга
brain_style hybrid scheme · cloud · hybrid — что именно рисовать
brain_scale 1 масштаб вида мозга: 2 — 2360 × 1572 для 2K, 3 — для 4K (облако и гибрид)
ctx_up 4.0 насколько кластеризация смотрит выше габарита, м; меньше — и колонна, срезанная по верхней границе, выглядит предметом
split_adv 0.0 разделение фигуры и фона по скорости сближения: рабочая дальность 62 → 80 м ценой вчетверо больших ложных тревог. По умолчанию выключено, см. EXPERIMENTS п. 9.4
enable_accumulator true накопление лучей в координатах пути: удваивает рабочую дальность там, где локальный контраст равен нулю
acc_near / acc_gain 55.0 / 1.5 с какой дальности включается накопление и какая опора считается полным контрастом
split_gap 6.0 разрез компоненты, растёкшейся вдоль стены, по контрасту ламины: гладкая стена даёт ноль по построению, предмет на ней — ступеньку. 0 — выключить
split_near / split_top 55.0 / 1 ближе какой дальности не резать и сколько фигур выносить из компоненты; одна фигура вместо всех — 6.4 против 9.4 ложных трека на км при той же дальности
enable_habituation false привыкание внутри проезда. Выключено: измерено, что избирательности нет, см. EXPERIMENTS п. 10
best_effort false QoS: RELIABLE. При BEST_EFFORT потеря одного UDP-фрагмента отбрасывает весь 24-мегабайтный кадр
queue_depth 10 глубина очереди подписки
raw_subscription true брать кадр сырыми байтами CDR, минуя сборку Python-объекта sensor_msgs
async_worker false обрабатывать в отдельном потоке; по умолчанию в колбэке — поток борется за GIL с приёмом

Ничего про геометрию сенсора не захардкожено: решётка лучей, высота установки, крен и тангаж калибруются по самим данным на первых кадрах. В предоставленных записях встречаются две разные раскладки скана (3600 азимутов на 360° и 1200 на 120°) и две высоты установки сенсора (1.31 м и 1.70 м) — решение работает с обеими без единой правки.


Результаты

Полный разбор с методикой — docs/EXPERIMENTS.md.

Метрика Значение
Реальный объект (0.67 × 1.35 м на 55 м) обнаружен в 99.5 % кадров
Синтетика организаторов (cloud_with_fake_obj, 10 предметов) 9 из 10 верно: из восьми предметов в габарите найдены семь (2×2 посередине — с 95 м, 2×2 сверху — с 87 м, длинный на рельсах — с 79 м, 0.3 на рельсе — с 76 м, 0.3 у края — с 63 м, 0.3 висящий посередине — с 45 м, стержень 5 см с потолка — только с 13 м), оба предмета вне габарита — без тревоги; не найден 2×2, заходящий в габарит на 0.3 м. Ложных тревог за 151 с — три коротких (tools/eval_org_synth.py, EXPERIMENTS п. 18–19)
Ложные тревоги, leave-one-bag-out 5.7 разных ложных трека на километр (8.4 % кадров) — без обученного считывания 11.9
То же на незнакомой линии, памяти нет 12.8 на км (20.7 % кадров) — без считывания 36.1
То же на второй половине new_data — другой день, 3.41 км, ни в каком виде не видена при обучении, памяти нет 11.7 на км (17.9 % кадров)
Время обработки кадра итоговый образ в контейнере: doubleT_obstacle 33.4 / 37.9 мс (медиана / p95), синтетика 43.5 / 48.8 мс (требование 100 мс)
То же на ядре уровня стенда жюри (i7-9700E ≈ 0.61 нашего по PassMark, docker/jury_cpu_test.sh) реальная запись 55 / 65 мс (медиана / p95), 201 из 201; синтетика 68 / 80 мс, 1510 из 1510
Использование процессора и видеокарты (узел в своём контейнере, запись — в другом, docker/usage_test.sh) узел занимает 0.39–0.42 ядра в медиане с видеокартой и 0.52–0.56 без неё; память 1.3–1.4 ГБ и 0.6–0.9 ГБ; видеокарта — 0.4–0.8 ГБ памяти и +4–6 п.п. загрузки к фону (пик 13–18 %). Кадр 30–31 мс с видеокартой, 44 мс без неё, приняты все кадры: 201 из 201 и 1510 из 1510
Приём в контейнере 201 из 201 и 252 из 252, отброшено 0
Рабочая дальность, размеченный полигон 100 м для человека стоя, 122 м сидя, 100 м для чемодана, 20 м для человека лёжа
Обнаружение на 50 м (при видимости) 0.72 человек стоя; 0.50 человек лёжа — против 0.27 до пола в колее
Обнаружение на 100 м (при видимости) 0.52 стоя и 0.54 сидя — против 0.19 без обученного считывания; лёжа 0.19 против 0.00
Обнаружение на 150 м (при видимости) 0.46 стоя и 0.39 сидя — против 0.39 и 0.20 до пола в колее
Оценка скорости без одометрии согласие перепроекции 0.76–0.95
Разделение «знакомое / новое» ROC AUC 0.905
Обобщение на форму, которой не было в обучении ROC AUC 0.965–0.997, потеря не больше 0.028
Вклад накопления улик без него ложных объектов в 13.5 раза больше
Вклад памяти тоннеля вдвое меньше ложных тревог, обнаружение не страдает
Вклад обученного считывания MBON при итоговых настройках ложных вдвое меньше: 11.9 → 5.7 на км на знакомой линии, 36.1 → 14.7 на незнакомой
Вклад гашения знакомости на дальности P@100 0.31 → 0.53, P@150 0.00 → 0.33, рабочая дальность 80 → 100 м
Вклад дальнего порога тревоги P@150 0.33 → 0.37, человек сидя 0.22 → 0.24; ложных на знакомой линии 8.0 → 8.0, на незнакомой 20.3 → 21.3
Вклад резкости считывания 1.5 ложных на незнакомой линии 23.5 → 14.7 на км, на знакомой 6.5 → 5.7, на второй половине new_data 23.9 → 17.4; полигон парно +1 / −142 из 7 600 наблюдений (39 вблизи у края габарита, 82 за 90 м). Сравнение при равной строгости с моделью, обученной на new_data, — EXPERIMENTS п. 17.4
Вклад габарита 1.2 м и верхней секции (тогда до 3.7 м, теперь до 3.3 — п. 19) синтетика организаторов 6 → 8 из 10; полигон парно +43 / −33, посторонних 793 → 742; ложных на незнакомой линии 14.7 → 13.8 на км, на знакомой 5.7 → 5.7; цена — ящик на 100 м (P@100 0.50 → 0.40). EXPERIMENTS п. 18.4
Вклад пола вероятности для висящего посреди габарита куб 0.3 м у края на высоте 1.2 м: 31 → 63 м; висящий посередине: 43 → 45 м. Ложных треков столько же на синтетике (3), знакомой линии (5.7 на км), незнакомой (12.8) и new_data (11.7); полигон парно +10 / −0. Считывание учили на стоящих предметах, и предмет в воздухе оно гасило. EXPERIMENTS п. 24
Вклад сброса при скачке времени вторая половина new_data (70 с выпавших кадров): 17.4 → 13.8 ложного трека на км; на записях без разрывов не меняет ничего. EXPERIMENTS п. 18.9
Вклад штрафа за плоское у пола и прохода по верхней секции синтетика 8 → 9 из 10, фантомов на ней 5 → 3; пустые записи 3 → 2 ложных трека (73 → 45 кадров); незнакомая линия 13.8 → 12.8 на км, вторая половина new_data 14.4 → 11.7; знакомая 5.7 → 5.7; полигон парно 0 / −3 (каска), посторонних 742 → 758. EXPERIMENTS п. 19
Вклад пола в колее (с переобучением считывания) лёжа P@50 0.27 → 0.50, P@150 стоя 0.39 → 0.49, ящик 0.06 → 0.26; парно +685 наблюдений против −33; ложных на знакомой линии 8.0 → 8.0, на незнакомой 21.3 → 23.5
Вклад разреза по контрасту 9.1 → 7.5 ложных трека на км (замер до обученного считывания), и слепая полоса 40–90 м на roundT_pressureGate_roundT 0.00 → 0.45…1.00

Строки «вклад …» для отдельных механизмов измерены до появления обученного считывания, и опора у них 7.4–7.5 трека на километр, а не 3.3.

Про полигон честно. 23.09 он пересобран: у каждого сценария теперь свой генератор случайности, а в каталоге появился человек лёжа. При тех же настройках человек стоя дал 80 м рабочей дальности и P@100 = 0.48 вместо прежних 100 м и 0.53 — это шум полигона, а не поломка: две реализации случайности расходятся на P@150 до 0.07. Поэтому правки теперь меряются парно, на одних и тех же вставках (tools/compare_benchmark.py): считается, сколько наблюдений перевернулось из «не видел» в «видел» и обратно. Разбор — EXPERIMENTS, п. 16.1.

Про слепые участки честно. Размеченный полигон вскрыл то, чего не видно на одном реальном объекте: на части участков предмет сливается со стеной по дальности, попадает с ней в одну связную компоненту и отбрасывается вместе с ней. Человек на оси пути обнаруживался в 100 % кадров на трёх бэгах из пяти и в 0 % на двух — при 65 лучах на предмете, то есть не из-за видимости.

Один из двух слепых бэгов прозрел. На roundT_pressureGate_roundT — той самой записи, где компонента из 42 000 лучей течёт вдоль стены от 4 до 99 м и уносит предмет с собой, — полностью слепая полоса 40–90 м стала 0.45 / 1.00 / 0.57. Помог разрез компоненты по контрасту ламины: гладкая стена даёт нулевой центр-окружение по построению, а предмет на ней — ступеньку. Из переглубокой компоненты выносится ровно одна, сильнейшая фигура; без этого ограничения разрез отрезает фон, протяжённость кандидата падает с 6.9 до 0.8 м, вместе с ней пропадает множитель компактности в весе улики, и каждое наблюдение начинает весить вдесятеро больше. Измеренная цена одной фигуры против всех — 6.4 против 9.4 ложных трека на км при одинаковой дальности. Второй слепой бэг, roundT_doubleT, за 40 м остаётся слепым, и это разобрано покадрово: за 50 м до предмета не доходит линия взгляда — 98 % лучей упираются в преграду ближе него независимо от того, куда поперёк его ставить, — а на 35–52 м мешает наш порог split_near, снижать который вышло слишком дорого (7.4 → 17.1 ложного трека на км ради девяти метров). Подробно — EXPERIMENTS, п. 9.6.

Два предыдущих подхода к тому же — разрез по допуску глубины и разрез по скорости сближения (split_adv) — тоже возвращали зрение, но стоили 25.8 и 21.5 трека на километр и остались выключенными. Разбор всех трёх — EXPERIMENTS, п. 9.3–9.5.

Про новый участок честно. Обученная память на незнакомой линии бесполезна по определению: без неё ложных треков 21.8 на километр против 9.1. Мы попробовали закрыть это привыканием внутри проезда — гасить форму, встретившуюся в нескольких разных точках пути, то есть штатную повторяющуюся обстановку. Механизм сделан, доведён до работы и отвергнут по замеру: при ёмкости, достаточной чтобы популяция не насыщалась, он не меняет ничего (7.5 против 7.5 ложных треков на км), а весь видимый эффект маленькой популяции оказался глобальным глушением, которое давит предмет сильнее обстановки (новизна вставленного предмета 0.24 при медианной новизне кандидата 0.43…0.71). Код оставлен и выключен, полный разбор с таблицами — EXPERIMENTS, п. 10.

Работает на новом участке то, что и работало: перенос долговременной памяти. В дескрипторе намеренно смешаны признаки формы и углового размера (переносятся на любой тоннель) с положением в сечении (запоминает конкретную обстановку), и первая половина снижает ложные тревоги с 21.8 до 9.1 трека на километр на бэге, которого память не видела.

Про обученное считывание честно. Метки для него сделаны вставкой предметов трассировкой лучей, и первые две модели сенсора оказались неверными: сначала яркость вставки считалась по ламбертовой ρ·cosθ/r², и за 110 м предмет выходил тусклее тоннеля, потом — постоянной, и он стал ярче тоннеля впятеро. В обоих случаях модель училась узнавать вставку по яркости, а не по форме, и полигонная дальность была завышена. Абсолютной шкалы интенсивности в этих записях нет вовсе: медиана по кандидатам обстановки 3…7 в пяти бэгах и 23.5 в шестом. Сейчас вставка берёт яркость реальных возвратов с тех же лучей, признак стал неинформативным, и все цифры выше получены уже так. Заявленные до этого разбора 100 м рабочей дальности и P@100 = 0.53 были получены на полигоне с артефактом: после починки модели сенсора те же замеры дали 80 м и 0.31. Нынешние 100 м и 0.53 — совпадение по величине, но получены они уже на честном полигоне и другими средствами (гашение знакомости и порог по лучам, зависящий от дальности). Разбор с таблицами — EXPERIMENTS, п. 11.

Про дальность и ложные честно. Улика далёкого предмета — произведение нескольких множителей, и один из них на дальности оказался перевёрнутым: у вставленного человека на 120–185 м новизна 0.150 против 0.199 у окружающей обстановки (AUC 0.293). На шести лучах дескриптор вырождается, и память тоннеля узнаёт в предмете любую далёкую конструкцию — то есть мы сами гасили свой сигнал. После гашения вклада новизны за 90 м обнаружение на 150 м выросло с нуля до 0.33, а на 100 м с 0.31 до 0.53. Вместе с порогом по числу лучей, зависящим от дальности (четыре вблизи, три за 90 м), рабочая дальность по человеку выросла с 80 до 100 м. Цена — ложные тревоги на знакомой линии 3.5 → 8.0 трека на километр; на незнакомой плата нулевая, потому что там подавлять нечем. Кому дороже тишина, тот ставит nov_fade_from: 0 и получает 3.5 на километр, теряя дальнюю зону. Лишние далёкие треки дают предупреждения, а не торможение: экстренный уровень требует близкой дистанции. Разбор — EXPERIMENTS, п. 12.

Про решение по треку честно. Замышлялось обученное считывание по истории трека — и оно не обогнало ни один из признаков, которые ему же и дали (AUC 0.880 против 0.895 у одного среднего отсчёта). Зато по дороге нашлось, что улика насыщается: на настоящем объекте она 1.000 и у предмета, и у ложных треков (AUC 0.624), а средний вес наблюдения, из которого она складывается, — 0.998 против 0.269. Смешивать его с уликой оказалось бесполезно: при равном числе ложных тревог простой порог даёт обнаружение не хуже, а на незнакомой линии смешивание вытаскивает лишние треки (20.3 → 24.0 на км). В итоге приняли не модель, а ключ, отвергнутый тремя разделами раньше, — порог тревоги, опускаемый с 0.5 до 0.3 за 90 м. Тогда он ничего не давал, потому что улика далёкого трека была нулём; после гашения знакомости она им быть перестала, и тот же ключ поднял P@150 с 0.33 до 0.37. Отрицательный результат верен только для конфигурации, в которой получен. Разбор — EXPERIMENTS, п. 15.

Про упавшего на пути человека честно. Самый важный для метро случай был виден хуже всех крупных предметов: при высоте 0.30 м и поле габарита 0.28 м в габарит попадала верхушка в два сантиметра (P@50 = 0.27, на 100 м — ноль). Пол между рельсами опущен до 0.16 м (предложение Zhirik1337), но не везде: без порога по дальности обнаружение вблизи падало вдвое — в полосу 0.16…0.28 м попадают головки рельсов, рельс собирается в одну компоненту от самой кабины, и предмет выбрасывается вместе с ней. Дальше 30 м вреда нет, и после переобучения считывания на кандидатах нового пола лежачий человек P@50 = 0.50, рабочая дальность 32 м; ложных на знакомой линии столько же, на незнакомой +2.2 на км. Ещё четыре правки из того же набора замерены и отвергнуты: каждая давала меньше, чем стоила. Разбор — EXPERIMENTS, п. 16.

Про ось пути честно. Ось берётся не из рельсов, а из дрейфа центра сечения тоннеля с дальностью: на 100 м рельсы дают единицы точек, а свод — тысячи. Поэтому ось не дрожит: сдвиг между соседними кадрами в одной точке пути на 100 м — p90 не хуже 0.35 м при полуширине габарита 1.6 м. Но наблюдается сечение только до 62–107 м, дальше ось продолжается по касательной, и касательная расходится с кривой до 1.19 м на 150 м. Продолжать вместо неё измеренную кривизну пробовали — хуже: кривизна оценивается на коротком плече, гуляет между кадрами и даёт до 6 м расхождения.

Про край габарита честно. На синтетике организаторов граница между «у края, внутри» и «вне, но близко» — сантиметры: 1.13 и 1.14 м от оси. Ошибка нашей оси на 40–80 м — 0.2–0.4 м, проверено по их предметам (EXPERIMENTS п. 20.1). Поэтому ящик 2×2, заходящий в габарит на 0.06–0.28 м, мы не берём: по форме и перепаду дальности он неотличим от настоящих конструкций у края, которые из-за той же ошибки оси оказываются у нас на 1.0–1.1 м. Правило, которое его бы нашло, на записях без препятствий срабатывает в 16 местах, одно держится 451 кадр (п. 20.3).

Из этого следует оговорка к дальним цифрам полигона. Предмет вставляется на нашу же оценку оси, поэтому ошибку оси полигон не мерит в принципе. В эксплуатации предмет на 150 м может оказаться в метре от того места, где мы считаем путь, и у края габарита из него выпасть. Дальние цифры оптимистичны именно по этой причине, а не из-за обнаружения. Разбор — EXPERIMENTS, п. 13.

Про дальность честно. Паспортный максимум Pandar128 — 200 м, максимальное эхо в датасете — 208.8 м. Но реальная прямая видимость в этих тоннелях 121–167 м: тоннели кривые (радиусы 1300–8700 м), и дальше линия взгляда упирается в стену. Заявленные в ТЗ «300 м → отлично» на предоставленных участках физически недостижимы никаким алгоритмом.

Что мешает дотянуться до 200 м там, где видимость позволяет, тоже измерено и оказалось не тем, чего ожидаешь. Человек на 170 м освещён в каждом кадре (5 лучей) и за проход набирает около 84 попаданий в одну точку мира; кандидат формируется, размер определяется верно. Не растёт улика: кольцо окружения ламины на 170 м упирается в стену тоннеля, которая там же, и локальный контраст обнуляется. Лечится не порогом, а накоплением лучей в координатах пути и геометрической картой линии — разбор в docs/EXPERIMENTS.md, п. 7.3.

Про стекло честно. Лидар светит на 905 нм, и прозрачное стекло для него почти прозрачно: луч уходит насквозь и возвращается от того, что за стеклом, а гладкая поверхность отражает зеркально — в сторону, а не назад к прибору. Стеклянный предмет виден только тем, что в нём не прозрачно: этикеткой, пробкой, рамой, грязью, бликом там, где луч падает на поверхность почти по нормали. Отдельного приёма для стекла у нас нет, и честно его не сделать: из пустоты предмет не восстановить. Для масштаба — даже непрозрачная бутылка (0.03 м²) на полигоне почти не видна: на 40–55 м на ней 3 луча, а ниже примерно десяти лучей предмет не отличить от шума (EXPERIMENTS п. 9).


Разработка без ROS

Весь конвейер работает и офлайн, прямо по .db3, без установленного ROS — это удобно для экспериментов на Windows и для воспроизведения метрик:

python tools/inspect_bags.py --root data/for_hackathon      # калибровка решётки
python tools/run_pipeline.py --all --memory artifacts/mushroom_body.npz
python tools/evaluate.py --device cuda                      # leave-one-bag-out
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz
python tools/render_brain.py --bag data/for_hackathon/doubleT_obstacle --video brain.mp4

evaluate.py, make_benchmark.py и make_training_set.py раскладывают работу по бэгам на процессы (--jobs, по умолчанию — по числу записей, но не больше физических ядер). Результат совпадает с последовательным побайтово: случайность у каждой записи своя, общей изменяемой памяти между ними нет. Задержку кадра при этом мерить нельзя — нужен --jobs 1.

Обучение памяти тоннеля (без единой метки, на пустых проездах):

python tools/train_mushroom_body.py --device cuda \
    --extra-cache data/cache/new_data_candidates.npz

Состав репозитория

ros2_ws/src/flyguard/        ROS 2-пакет: конвейер, узел, launch, конфиги, RViz, вид мозга
ros2_ws/src/flyguard_msgs/   сообщения ObstacleStatus и DetectedObject
docker/                      Dockerfile образа, точка входа, проверки в контейнере
flyguard/                    то же ядро, что в ROS-пакете, — для инструментов и тестов без ROS
tools/                       офлайн-инструменты: калибровка, обучение, метрики, полигон
tests/                       тесты без ROS: pytest tests
docs/                        архитектура, алгоритм, эксперименты, коннектом
artifacts/                   обученная память тоннеля и результаты замеров

Ядро в flyguard/ и в ros2_ws/src/flyguard/flyguard/ — один и тот же код из одного источника. Корневой Dockerfile и docker/Dockerfile собирают один и тот же образ. В корне лежат ещё Dockerfile.offline, Dockerfile.gpu и docker-compose.yml для прогона ядра по записям без ROS и Dockerfile.ros2 с узлом flyguard/flyguard_ros2_node.py.

Лицензия

MIT.