# FlyGuard — обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара Решение кейса «Система обнаружения посторонних объектов для беспилотных поездов в тоннеле метро» (хакатон «Лидеры цифровой трансформации 2026», направление «Город»; заказчик — Департамент транспорта Москвы / ГУП «Московский метрополитен»). На вход — поток облаков точек Hesai Pandar128. На выход — ответ на единственный важный вопрос: **«путь свободен» или «впереди препятствие на N метров»**. ## Быстрый старт для жюри Образ — файлом, если на стенде нет сети ([`flyguard_image.tar`, 4.7 ГБ, Google Drive](https://drive.google.com/file/d/1fJLz3b0-mmJL1YsTFRozKp1CIKm5f0NX/view?usp=sharing); скачать из терминала — раздел «Стенд без интернета»), или сборкой из репозитория там, где сеть есть: ```bash docker load -i flyguard_image.tar ``` ```bash docker build -t flyguard . ``` Узел — в одном терминале, запись — в другом: ```bash docker run --rm -it --gpus all --network host --ipc host flyguard ``` ```bash 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`, результат тот же. - Топик с облаком узел находит сам, какое бы имя у него ни было, и сам подстраивается под QoS записи: если она публикуется «best effort», переподписывается так же. - `--ipc host` обязателен: без него кадры с хоста до контейнера не доходят вовсе. - Результат для программ — топики `/flyguard/obstacle`, `/flyguard/detected`, `/flyguard/distance` (раздел «Выходные данные»); RViz и схема мозга мухи — раздел «Демонстрация со схемой мозга мухи». - Всё сразу — узел, проигрывание, RViz и мозг мухи — одной командой из папки репозитория: `bash docker/demo.sh /папка/с/записями имя_записи`; флаги видеокарты и доступ к экрану (`xhost`) скрипт подбирает сам. --- ## В двух словах ТЗ формулирует главный вызов так: > «Возможно, нужно научиться хорошо описывать нормальный тоннель, а затем искать всё, что в него > не вписывается.» Это дословное описание того, чем занимается мозг **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/ALGORITHM.md) и [docs/CONNECTOME.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](docs/ARCHITECTURE.md). --- ## Сборка ```bash docker build -t flyguard . ``` Образ основан на `ros:humble-ros-base-jammy` (Ubuntu 22.04 + ROS 2 Humble). Все зависимости ставятся на этапе сборки; **во время работы сеть не нужна**. Обе обученные части лежат в образе и подключаются сами: память тоннеля (`artifacts/mushroom_body.npz`) и считывание MBON (`artifacts/mbon_readout.npz`) — ровно та конфигурация, что замерена в разделе «Результаты». Собранный образ проверяется одной командой: ```bash docker run --rm -v "$PWD/docker:/smoke:ro" flyguard bash /smoke/smoke_test.sh ``` ### Стенд без интернета Организаторы подтвердили: на тестовом сервере сети нет. Узлу она и не нужна — всё ставится при сборке. Но сама `docker build` без сети не пройдёт: ей нужны базовый образ `ros:humble-ros-base-jammy` и пакеты apt. Поэтому образ собирается там, где сеть есть, и переносится файлом: ```bash docker save flyguard | gzip > flyguard_image.tar.gz # на машине с сетью ``` ```bash docker load -i flyguard_image.tar.gz # на стенде ``` После `docker load` все команды ниже работают как есть. Образ — 9.3 ГБ, из них 6.6 ГБ — PyTorch с библиотеками CUDA (раздел «Видеокарта»); архив — около 5 ГБ. Наш готовый архив — [`flyguard_image.tar` на Google Drive](https://drive.google.com/file/d/1fJLz3b0-mmJL1YsTFRozKp1CIKm5f0NX/view?usp=sharing), 4.7 ГБ (4 732 318 208 байт). Из терминала — без браузера и без предупреждения Drive о большом файле: ```bash curl -L -o flyguard_image.tar "https://drive.usercontent.google.com/download?id=1fJLz3b0-mmJL1YsTFRozKp1CIKm5f0NX&export=download&confirm=t" ``` ```bash sha256sum flyguard_image.tar # 515bdbc527ccfcaf81c22aed9bc766a7e5438d8c52d4a7501438c9dfe4a3d104 ``` Слои в нём уже сжаты (gzip), поэтому `gzip` поверх не нужен, а `docker load -i` принимает и `.tar`, и `.tar.gz`. Архив самодостаточный: внутри все 74 слоя образа, хеш каждого сходится, так что чистому движку больше ничего не нужно. Проверено: `docker load` в Docker Desktop 29.8 (Windows 11, WSL 2) — 5.5 мин, образ работает с `--gpus all` и без него. Если сети нет и там, где собирается образ, колёса PyTorch скачиваются на соседней машине (`python docker/fetch_wheels.py`) и отдаются сборке любым HTTP-сервером — аргумент `TORCH_WHEELS`, подробности в [docker/wheels/README.md](docker/wheels/README.md). ## Запуск Одной командой — детектор и проигрывание бэга вместе: ```bash 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` на хосте, как на поезде: ```bash docker run --rm -it --gpus all --network host --ipc host flyguard ``` ```bash 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 окну нужен доступ к экрану хоста: ```bash 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, показывает проверка без окна: ```bash 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`, — а облако обзора есть всегда, из какого бы топика узел ни читал. Публикуется, только когда на него кто-то подписан, и в замеры задержки не входит. **Оранжевый контур — габарит, который узел проверяет.** Ось пути впереди узел оценивает в каждом кадре по сечению тоннеля, и в кривой габарит изгибается вместе с ней. Проверяется объединение изогнутого габарита и прямого, вдоль оси лидара: неточная оценка оси не должна сужать зону поиска. На прямом пути контуры совпадают, в кривой рисуются оба — изогнутый ярко, прямой бледно ([рисунок](docs/figures/curve_gauge.png)). Рамки препятствий стоят у своих точек. На кривой синтетики организаторов (радиус около 760 м) ось сходится с положением предмета до 0.04 м на 22 м, до 0.15 м на 39 м и до 0.27 м на 64 м (EXPERIMENTS п. 20). Чтобы не ждать до нужного места, проигрывание начинается с любой секунды: `start:=18`. ### Видеокарта Раскладка точек по решётке, ламина и кластеризация — три стадии, работающие с целым образом 128 × 600 лучей, — считаются на видеокарте, если узел её видит; остальное на процессоре. Для этого контейнеру нужен `--gpus all` (на Linux — с nvidia-container-toolkit). Без флага, без видеокарты или при любом её сбое на ходу узел считает на процессоре — **с тем же результатом**: на видеокарте те же операции в тех же типах, и решения совпадают с процессорными покадрово на синтетике и шести записях ([EXPERIMENTS](docs/EXPERIMENTS.md), п. 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:=...`), клавиатуры не слышит. Для него в образе есть пульт: контейнер запускается с именем, а пульт — во втором терминале. ```bash 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 ``` ```bash docker exec -it flyguard_demo flyguard-keys /data/doubleT_obstacle ``` | Клавиша | Действие | |---|---| | пробел | пауза / продолжить | | ← → | на 3 секунды назад / вперёд | | `.` | один кадр вперёд (на паузе) | | ↑ ↓ | быстрее / медленнее (×0.1 … ×4) | | `0` | в начало записи | | `q` | выйти, запись играет дальше | На паузе RViz держит последний кадр и рамки, и сцену можно крутить. После перемотки узел сам замечает скачок времени записи и начинает треки, одометрию и ось пути заново — иначе предмет с прошлого места всплыл бы там, где его нет; то же самое происходит на каждом круге `ros2 bag play --loop`. Если `ros2 bag play` запущен вручную в своём терминале, пульт не нужен: у проигрывателя свои клавиши (пробел — пауза, → — следующий кадр, ↑ ↓ — скорость). Все записи подряд, по строке сводки на каждую (узел поднимается заново для каждой записи): ```bash 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: ```bash 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, что в узле, и через конвейер, а решения сравниваются с исходными: ```bash 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` — в каждом кадре и с той же дальностью. ### Демонстрация со схемой мозга мухи ```bash xhost +local: docker run --rm -it --gpus all --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 ``` То же одной командой, с подбором флагов видеокарты под машину: `bash docker/demo.sh /path/to/bags doubleT_obstacle`. Топик `/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: ```bash 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_ws/src/flyguard/config/flyguard.yaml), переопределяются при запуске: ```bash 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](docs/EXPERIMENTS.md) п. 17.4 | | `half_width` | `1.2` | полуширина габарита, м. Так его задают организаторы в синтетике 24.09: «у края» — до 1.13 м от оси, «вне габарита, но близко» — с 1.14 м. При прежних 1.6 второй давал ложную тревогу, см. [EXPERIMENTS](docs/EXPERIMENTS.md) п. 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](docs/EXPERIMENTS.md) п. 24 | | `small_rays` | `2` | канал малых целей (LC11): кандидат из двух-трёх лучей, если он висит в пустоте посреди габарита — вся связная компонента не больше 4 лучей (`small_ctx`), низ выше 0.5 м над рельсом (`small_h`), не дальше 0.8 м от оси (`small_u`), фон за ним дальше 5 м (`small_gap`), дальность 45–100 м (`small_d_from` / `small_d_to`). Обычный порог — четыре луча, а куб 0.3 м организаторов с 57 м ложится в две ячейки образа: находился с 45 м, с каналом — с 76 м, ложных тревог столько же. `0` — выключить, см. [EXPERIMENTS](docs/EXPERIMENTS.md) п. 26 | | `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](docs/EXPERIMENTS.md) п. 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](docs/EXPERIMENTS.md) п. 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](docs/EXPERIMENTS.md) п. 10 | | `best_effort` | `false` | QoS: RELIABLE. При BEST_EFFORT потеря одного UDP-фрагмента отбрасывает весь 24-мегабайтный кадр. Если же сама запись публикуется «best effort», надёжный подписчик с ней по правилам DDS несовместим и не получил бы ни кадра, — узел это видит и сам переподписывается «best effort» (EXPERIMENTS п. 27) | | `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](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 висящий посередине — с 76 м, 0.3 у края — с 63 м, стержень 5 см с потолка — только с 13 м), оба предмета вне габарита — без тревоги; не найден 2×2, заходящий в габарит на 0.3 м. Ложных тревог за 151 с — три коротких (`tools/eval_org_synth.py`, EXPERIMENTS п. 18–19, 26) | | Ложные тревоги, 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 | | Вклад канала малых целей (LC11) | куб 0.3 м, висящий посередине габарита: **45 → 76 м**. С 57 м его точки ложатся в две ячейки образа, а кандидату нужно четыре луча; канал берёт пятно из двух-трёх лучей, если оно висит в пустоте: вся компонента не больше четырёх лучей, низ выше 0.5 м, не дальше 0.8 м от оси, фон за ним дальше 5 м. Ложных столько же на синтетике (3), знакомой линии (5.7 на км), незнакомой (12.8) и `new_data` (11.7); полигон парно 0 / −0, посторонних 758 = 758; время кадра то же. EXPERIMENTS п. 26 | | Вклад сброса при скачке времени | вторая половина `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](docs/EXPERIMENTS.md), п. 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](docs/EXPERIMENTS.md), п. 9.6. Два предыдущих подхода к тому же — разрез по допуску глубины и разрез по скорости сближения (`split_adv`) — тоже возвращали зрение, но стоили 25.8 и 21.5 трека на километр и остались выключенными. Разбор всех трёх — [EXPERIMENTS](docs/EXPERIMENTS.md), п. 9.3–9.5. **Про новый участок честно.** Обученная память на незнакомой линии бесполезна по определению: без неё ложных треков 21.8 на километр против 9.1. Мы попробовали закрыть это привыканием внутри проезда — гасить форму, встретившуюся в нескольких разных точках пути, то есть штатную повторяющуюся обстановку. Механизм сделан, доведён до работы и **отвергнут по замеру**: при ёмкости, достаточной чтобы популяция не насыщалась, он не меняет ничего (7.5 против 7.5 ложных треков на км), а весь видимый эффект маленькой популяции оказался глобальным глушением, которое давит предмет сильнее обстановки (новизна вставленного предмета 0.24 при медианной новизне кандидата 0.43…0.71). Код оставлен и выключен, полный разбор с таблицами — [EXPERIMENTS](docs/EXPERIMENTS.md), п. 10. Работает на новом участке то, что и работало: перенос долговременной памяти. В дескрипторе намеренно смешаны признаки формы и углового размера (переносятся на любой тоннель) с положением в сечении (запоминает конкретную обстановку), и первая половина снижает ложные тревоги с 21.8 до 9.1 трека на километр на бэге, которого память не видела. **Про обученное считывание честно.** Метки для него сделаны вставкой предметов трассировкой лучей, и первые две модели сенсора оказались неверными: сначала яркость вставки считалась по ламбертовой ρ·cosθ/r², и за 110 м предмет выходил тусклее тоннеля, потом — постоянной, и он стал ярче тоннеля впятеро. В обоих случаях модель училась узнавать вставку по яркости, а не по форме, и полигонная дальность была завышена. Абсолютной шкалы интенсивности в этих записях нет вовсе: медиана по кандидатам обстановки 3…7 в пяти бэгах и 23.5 в шестом. Сейчас вставка берёт яркость реальных возвратов с тех же лучей, признак стал неинформативным, и все цифры выше получены уже так. Заявленные до этого разбора 100 м рабочей дальности и P@100 = 0.53 были получены на полигоне с артефактом: после починки модели сенсора те же замеры дали 80 м и 0.31. Нынешние 100 м и 0.53 — совпадение по величине, но получены они уже на честном полигоне и другими средствами (гашение знакомости и порог по лучам, зависящий от дальности). Разбор с таблицами — [EXPERIMENTS](docs/EXPERIMENTS.md), п. 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](docs/EXPERIMENTS.md), п. 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](docs/EXPERIMENTS.md), п. 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](docs/EXPERIMENTS.md), п. 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](docs/EXPERIMENTS.md), п. 13. **Про дальность честно.** Паспортный максимум Pandar128 — 200 м, максимальное эхо в датасете — 208.8 м. Но реальная **прямая видимость в этих тоннелях 121–167 м**: тоннели кривые (радиусы 1300–8700 м), и дальше линия взгляда упирается в стену. Заявленные в ТЗ «300 м → отлично» на предоставленных участках физически недостижимы никаким алгоритмом. Что мешает дотянуться до 200 м там, где видимость позволяет, тоже измерено и оказалось не тем, чего ожидаешь. Человек на 170 м освещён **в каждом кадре** (5 лучей) и за проход набирает около 84 попаданий в одну точку мира; кандидат формируется, размер определяется верно. Не растёт улика: кольцо окружения ламины на 170 м упирается в стену тоннеля, которая там же, и локальный контраст обнуляется. Лечится не порогом, а накоплением лучей в координатах пути и геометрической картой линии — разбор в [docs/EXPERIMENTS.md](docs/EXPERIMENTS.md), п. 7.3. **Про стекло честно.** Лидар светит на 905 нм, и прозрачное стекло для него почти прозрачно: луч уходит насквозь и возвращается от того, что за стеклом, а гладкая поверхность отражает зеркально — в сторону, а не назад к прибору. Стеклянный предмет виден только тем, что в нём не прозрачно: этикеткой, пробкой, рамой, грязью, бликом там, где луч падает на поверхность почти по нормали. Отдельного приёма для стекла у нас нет, и честно его не сделать: из пустоты предмет не восстановить. Для масштаба — даже непрозрачная бутылка (0.03 м²) на полигоне почти не видна: на 40–55 м на ней 3 луча, а ниже примерно десяти лучей предмет не отличить от шума (EXPERIMENTS п. 9). --- ## Разработка без ROS Весь конвейер работает и офлайн, прямо по `.db3`, без установленного ROS — это удобно для экспериментов на Windows и для воспроизведения метрик: ```bash 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`. Обучение памяти тоннеля (без единой метки, на пустых проездах): ```bash 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.