Lidar_Muxa/README.md

740 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.