ML-ядро детектора: конвейер на схемах мозга дрозофилы

Ретина, ламина, медулла, лобула, грибовидное тело, веерное тело,
центральный комплекс, нисходящие нейроны. Обучение памяти тоннеля и
считывания MBON, оценка leave-one-bag-out, полигон дальности, 24 теста.

Реальный объект на 55 м — 98.9 % кадров, ложных 7.5 трека на км,
кадр обрабатывается за 33 мс на CPU.
This commit is contained in:
Данил Омелечко 2026-09-21 17:24:25 +03:00
parent 5ad311f78a
commit b8e95eccbe
45 changed files with 8020 additions and 61 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
data/
artifacts/mbon_folds/
docs/figures/*.png
docs/figures/*.mp4

198
README.md
View file

@ -1,84 +1,160 @@
# Кейс 05 · Обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара
# FlyGuard · ML-ядро
> Хакатон «Лидеры цифровой трансформации» (ЛЦТ) 2026 — конкурс Мэра Москвы для лучших ИТ-специалистов мира.
Обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара
Hesai Pandar128. Кейс 05, ЛЦТ-2026.
 
## 📋 Общая информация
Это **ядро обработки**: облако точек на входе, решение о препятствии на выходе.
Узел ROS 2, транспорт, контейнер и визуализация живут отдельно и сюда не
входят — ядро от них не зависит и проверяется без ROS вообще.
| Параметр | Значение |
|---|---|
| **Номинация** | Компьютерное зрение |
| **Заказчик** | Московский транспорт (Департамент транспорта и развития дорожно-транспортной инфраструктуры города Москвы), ГУП «Московский Метрополитен» |
| **Ссылка на задачу** | [i.moscow/hackaton/lct/1233bb5506bc455f86d534b3b40171f1](https://i.moscow/hackaton/lct/1233bb5506bc455f86d534b3b40171f1) |
| **Формат продукта** | Программный комплекс (ROS2) |
---
 
## 🏆 Призовой фонд
## Чем это не является
| Место | Сумма |
|---|---|
| 🥇 1 место | 1 000 000 ₽ |
| 🥈 2 место | 600 000 ₽ |
| 🥉 3 место | 400 000 ₽ |
Не нейросетевой детектор общего назначения. Конвейер собран по схемам
зрительной системы дрозофилы, и каждая стадия — это конкретный нейропиль с
конкретной функцией, а не слой, подобранный перебором:
 
## 🎯 Актуальность
```
облако точек 0.3–0.9 млн, 10 Гц
│
├─ RETINA омматидиальная решётка → дальностный образ 128 × N
├─ HALTERES плоскость рельсов: крен, тангаж, высота сенсора
├─ LAMINA диспаритет 1/R → ON/OFF, центр-окружение на 3 масштабах
├─ MEDULLA / LP T4/T5 → LPTC: скорость поезда без одометрии
├─ LOBULA LC11: кандидаты; разрез компоненты по контрасту
├─ MUSHROOM BODY KC → APL → MBON: новизна формы (без меток)
│ + обученное считывание MBON (с метками из физики)
├─ FAN-SHAPED BODY накопление лучей в координатах пути
├─ CENTRAL COMPLEX накопление улик в координатах пути, треки
└─ DESCENDING два порога с гистерезисом → решение
```
В настоящее время активно ведётся разработка **беспилотного поезда метро**, для которого одной из ключевых функций является контроль пространства по ходу движения. Особенно важна задача мониторинга тоннеля: необходимо своевременно выявлять посторонние объекты, попадающие в габарит поезда и потенциально представляющие угрозу безопасности движения.
Ничего про геометрию сенсора не захардкожено: решётка лучей, высота установки,
крен и тангаж **калибруются по самим данным** на первых кадрах. В записях
встречаются две раскладки скана (3600 азимутов на 360° и 1200 на 120°) и две
высоты установки (1.31 и 1.70 м) — ядро работает с обеими без правок.
Использование 3D-лидара позволяет получать информацию о пространстве перед поездом вне зависимости от условий освещённости. При этом требуется разработать алгоритм, который сможет надёжно обнаруживать препятствия на максимально возможной дальности, сохраняя низкий уровень ложно-положительных срабатываний. Решение данной задачи критически важно для построения безопасной и устойчивой системы автономного управления поездом метро.
---
 
## 🧩 Описание задачи
## Что нужно интеграции: один класс, один вызов
Разработайте программный комплекс на базе методов обработки 3D-лидарных данных, который:
```python
from flyguard.pipeline import FlyGuard, Params
from flyguard.mushroom_body import MushroomBody
from flyguard.mbon_readout import MbonReadout
1. Обрабатывает облака точек, получаемые с 3D-лидара, установленного на беспилотном поезде метро;
2. Выполняет мониторинг пространства перед поездом в тоннеле в реальном времени;
3. Обнаруживает посторонние объекты, попадающие в зону, ограниченную габаритом поезда;
4. Обеспечивает детекцию объектов на максимально возможной дальности до препятствия;
5. Минимизирует количество ложно-положительных детектов при сохранении высокой чувствительности к реальным препятствиям;
6. Формирует выходные данные, пригодные для интеграции в существующий пайплайн компьютерного зрения и системы принятия решений беспилотного поезда.
fg = FlyGuard(Params(),
memory=MushroomBody.load("artifacts/mushroom_body.npz"),
readout=MbonReadout.load("artifacts/mbon_readout.npz"))
 
## 📦 Ресурсы
res = fg.process(cloud) # cloud: flyguard.cdr.PointCloud2
if res is None:
... # первые 12 кадров уходят на калибровку решётки
else:
d = res.decision # detected, distance, confidence, emergency, objects
res.total_ms # время обработки кадра
```
- Файлы с лидарными данными, записанными в условиях движения поезда метро в тоннеле.
Требования к входу: `flyguard.cdr.PointCloud2` — поля `x, y, z, intensity`,
порядок точек как в сыром CDR. Конвейер **хранит состояние между кадрами**
(решётка, плоскость пути, ось пути, треки, накопитель), поэтому один экземпляр
обслуживает один поток данных; для параллельных сценариев нужны разные
экземпляры.
 
## 🚀 Описание итогового продукта
Выход `Decision`: `detected`, `distance` (м), `confidence` (0…1),
`emergency` (флаг экстренного торможения), `objects` (список подтверждённых
треков с id, дистанцией и габаритами).
Программный комплекс, включающий:
Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 33 мс
при бюджете 100 мс.
1. **ROS2-ноды** для загрузки и обработки лидарных облаков точек;
2. Алгоритмы фильтрации, сегментации и анализа 3D-данных;
3. Модуль детекции посторонних объектов в зонах, ограниченных габаритом поезда;
4. Интерфейсы или сообщения для передачи результатов детекции в существующие подсистемы беспилотного поезда.
---
 
## 👥 Рекомендуемые роли в команде
## Структура
- Системный аналитик
- Инженер компьютерного зрения
- Разработчик робототехники (ROS2)
- Специалисты по работе с данными
- Разработчик программного обеспечения (C++/Python)
```
flyguard/ ядро: стадии обработки, память, считывание
bag.py cdr.py чтение rosbag2 и разбор CDR без ROS
retina.py geometry.py решётка лучей, плоскость рельсов, ось пути
lamina.py medulla.py контраст, движение
lobula.py кандидаты
mushroom_body.py память тоннеля (без меток)
mbon_readout.py обученное считывание (с метками)
fan_body.py накопление в координатах пути
central_complex.py треки и улики
descending.py решение
pipeline.py сборка
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор
tests/ 27 тестов, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```
 
 
## 💬 Контакты и поддержка
---
- Телеграм: [@help_lct](https://t.me/help_lct)
- Почта: info.leaders@develop.mos.ru
- Модератор задачи: **Горбатова Ольга** — [@gorbatovaol](https://t.me/gorbatovaol)
## Как запустить
 
## 🗓️ Ключевые этапы конкурса
```bash
pip install -r requirements.txt
pytest tests -q
```
| Этап | Сроки | Формат |
Записи лидара в репозиторий не кладутся. Положите их рядом
(`../data/for_hackathon/...`) или укажите путь:
```bash
set FLYGUARD_DATA=D:\lidar\data
```
Обучение и оценка:
```bash
python tools/make_training_set.py # размеченная выборка
python tools/train_mbon.py --device cuda --baseline # считывание MBON
python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тревоги
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz
python tools/plot_benchmark.py # дальность обнаружения
```
---
## Где мы сейчас
| Метрика | Значение | Чем измерено |
|---|---|---|
| Приём заявок | до 14 сентября | онлайн |
| Разработка решений | 15–29 сентября | онлайн |
| Техническая экспертиза | 30 сентября – 14 октября | онлайн |
| Презентация проектов | 23 октября | онлайн |
| Церемония награждения | 30 октября | офлайн в Москве |
| Реальный объект 0.67 × 1.35 м на 55 м | **98.9 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **7.5 трека на км**, 16.7 % кадров | `tools/evaluate.py` |
| То же на новой линии, без обученной памяти | 21.8 на км | там же, холодный старт |
| Рабочая дальность (полигон, 14 004 наблюдения) | **100 м** человек стоя, P@50 = 0.70 | `tools/plot_benchmark.py` |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» | ROC AUC 0.905 | `tools/tune_memory.py` |
Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и
запомнил, умеет кто угодно, а на приватном тесте будет новый участок.
---
## Что честно не работает
Разобрано замерами, подробности — в `docs/EXPERIMENTS.md`:
* **За 200 м на этих участках не увидит никто**: прямая видимость в тоннелях
121–167 м, дальше линия взгляда упирается в стену кривой. На отдельных
перегонах и того меньше — 49–90 м.
* **Мелкие предметы на большой дальности невозможны с этим сенсором**: ведро
(0.1 м²) на 160–190 м даёт один луч, каска и бутылка — ноль.
* **За 80–90 м прирельсовая зона не наблюдается вовсе**: луч скользит по
полотну, и самая низкая видимая точка у оси пути оказывается выше головки
рельса на 0.1–0.6 м.
* Привыкание внутри проезда сделано и **отвергнуто замером** — п. 10.
---
## Документация
* `docs/ALGORITHM.md` — что делает каждая стадия и почему именно так.
* `docs/EXPERIMENTS.md` — все замеры, включая отрицательные результаты.
* `docs/CONNECTOME.md` — что взято из коннектома как число, а что как идея.

1
artifacts/benchmark.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,92 @@
[
{
"bag": "doubleT_obstacle",
"frames": 190,
"path_m": 0.0,
"alarm_frames": 183,
"alarm_rate": 0.9631578947368421,
"fp_objects": 322,
"fp_tracks": 5,
"fp_per_km": NaN,
"fp_median_d": 76.65631835070762,
"obj_rate": 0.9894736842105263,
"ms_p50": 33.958500005610404,
"ms_p95": 37.71089500642119,
"train_size": 37073
},
{
"bag": "doubleT_platform",
"frames": 239,
"path_m": 200.74905739412534,
"alarm_frames": 56,
"alarm_rate": 0.23430962343096234,
"fp_objects": 56,
"fp_tracks": 1,
"fp_per_km": 4.981343439320496,
"fp_median_d": 62.79671678753603,
"obj_rate": null,
"ms_p50": 40.0374000055308,
"ms_p95": 43.40603000018746,
"train_size": 36151
},
{
"bag": "roundT_doubleT",
"frames": 239,
"path_m": 200.9598713551979,
"alarm_frames": 3,
"alarm_rate": 0.012552301255230125,
"fp_objects": 3,
"fp_tracks": 1,
"fp_per_km": 4.976117835149752,
"fp_median_d": 59.10944286836971,
"obj_rate": null,
"ms_p50": 39.84129999298602,
"ms_p95": 49.274240005979664,
"train_size": 36584
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 0,
"alarm_rate": 0.0,
"fp_objects": 0,
"fp_tracks": 0,
"fp_per_km": 0.0,
"fp_median_d": NaN,
"obj_rate": null,
"ms_p50": 46.89270000017132,
"ms_p95": 52.964819991757395,
"train_size": 36718
},
{
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 86,
"alarm_rate": 0.3598326359832636,
"fp_objects": 89,
"fp_tracks": 3,
"fp_per_km": 10.951145000553437,
"fp_median_d": 108.44065443448855,
"obj_rate": null,
"ms_p50": 46.32040000797133,
"ms_p95": 50.264649999007815,
"train_size": 36083
},
{
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 34,
"alarm_rate": 0.14225941422594143,
"fp_objects": 34,
"fp_tracks": 2,
"fp_per_km": 7.380580405772363,
"fp_median_d": 98.006467832879,
"obj_rate": null,
"ms_p50": 40.29609999633976,
"ms_p95": 47.709809999651036,
"train_size": 33658
}
]

BIN
artifacts/mushroom_body.npz Normal file

Binary file not shown.

303
docs/ALGORITHM.md Normal file
View file

@ -0,0 +1,303 @@
# Алгоритм
Документ отвечает на вопросы из ТЗ, п. 5: какую проблему решает подход, какие данные
использует, как обрабатывается облако, как принимается решение, какие параметры на что влияют
и где границы применимости.
---
## 1. Какую проблему решаем
Тоннель метро — почти пустое однородное пространство. Классическая схема «обучить детектор
классов объектов» здесь не работает: размеченных примеров препятствий нет и быть не может
(нормы безопасности метрополитена), а объектом может оказаться что угодно — от кабеля и бутылки
до человека.
Значит, задача не «найти человека», а **описать нормальный тоннель и заметить всё, что в него
не вписывается**. Ровно этим занимается зрительная система и грибовидные тела дрозофилы,
поэтому архитектура собрана из её вычислительных схем.
Важно: берутся **схемы и параметры** коннектома, а не спайковая симуляция всех 139 тыс.
нейронов. Обоснование — в [CONNECTOME.md](CONNECTOME.md), раздел «Почему не полная симуляция».
---
## 2. Какие данные используются
Только облако точек лидара. Ни одометрии, ни IMU, ни карты, ни разметки.
Измеренные характеристики данных (`tools/inspect_bags.py`):
* 128 колец, элевация от −25.1° до +14.4°, неравномерная: у горизонта шаг 0.127°, по краям 0.45°;
* азимут: 3600 столбцов на 360° либо 1200 на 120°, шаг 0.1°, **два эха** на столбец;
* «нет эха» кодируется точным `(0, 0, 0)` — 38…62 % лучей кадра;
* система координат сенсора: вперёд = −Y, вправо = +X, вверх = +Z.
---
## 3. Обработка облака
### 3.1. RETINA — омматидиальная решётка
Фасеточный глаз — регулярная решётка направлений; вращающийся лидар устроен так же. Облако
переводится в **ретинотопический дальностный образ** `R(кольцо, азимут)`, и дальше всё считается
в нём, а не в неупорядоченном облаке. Это даёт и скорость (всё — операции над матрицами),
и возможность применять пространственные фильтры.
Три детали, без которых образ получается неверным:
1. **Слияние эх.** Из двух эх берутся ближнее (`r_near`) и дальнее (`r_far`). Различимы они
у 0.6…1.6 % лучей, но это как раз тонкие предметы и кромки: разнос p50 ≈ 2.5 м.
2. **Выпрямление скоса.** У каждого лазерного канала свой постоянный азимутальный сдвиг,
разброс достигает **15.6° (±78 столбцов)**. В сыром виде «столбец» не является направлением:
соседние кольца одного столбца смотрят в стороны, разнесённые на градусы, и любой
пространственный фильтр считает мусор. Образ выпрямляется целочисленным сдвигом строк;
остаточная угловая ошибка `p99 = 0.028°` — меньше половины шага решётки.
3. **Калибровка по данным.** Углы каналов, шаг развёртки и число эх восстанавливаются из первых
кадров, а не берутся из паспорта. Поэтому решение работает с обеими раскладками, которые
встречаются в датасете.
### 3.2. HALTERES — стабилизация «взгляда»
Прежде чем обрабатывать картинку, муха стабилизирует голову по сигналам жужжалец и оцеллий.
Здесь роль горизонта играет плоскость головок рельсов: она оценивается **в каждом кадре**
робастно (минимум в ячейке сетки → IRLS с мягким Хьюбером → повторная подгонка у найденной
плоскости → экспоненциальное сглаживание по кадрам).
Отсюда — система координат пути `(d, u, h)`: вперёд, поперёк, вверх от рельса. Крепление сенсора
не обязано быть жёстким: в датасете высота установки различается (1.31 м и 1.70 м), крен на
кривых доходит до 3° из-за возвышения наружного рельса, и всё это отслеживается покадрово.
Побочный, но важный продукт — **ожидаемая дальность до пола** для каждого луча:
$$r_\text{пол} = \frac{c}{d_z + a\,d_y - b\,d_x}$$
где `z = a·d + b·u + c` — плоскость пути, `(d_x, d_y, d_z)` — направление луча. Луч с
отрицательной элевацией обязан закончиться на полотне на строго определённом расстоянии; всё,
что обрывает его раньше, — предмет, стоящий на пути.
### 3.3. Осевая линия пути
На дальности 150 м кривая радиуса 1300 м уводит путь на 8.6 м вбок. Прямой коридор там
давно упёрся бы в стену, и вся дальняя зона превратилась бы в сплошное ложное срабатывание.
Ось оценивается по дрейфу центра сечения тоннеля: по срезам дальности берётся середина между
3-м и 97-м процентилями поперечной координаты, и через эти точки проводится дуга
`u(d) = c₁·d + c₂·d²`, где `c₂ ≈ 1/(2R)`.
Две поправки, которые определяют работоспособность:
* веса срезов **равные**, а не по числу точек: у ближних срезов точек в сотни раз больше,
и взвешивание по количеству полностью подавило бы дальние срезы, где как раз и содержится
кривизна;
* за горизонтом наблюдаемой дальности парабола продолжается **линейно**, по касательной, —
экстраполяция кривизны туда, где данных не было, даёт десятки метров ошибки.
Плюс два физических ограничения: радиус круче 300 м на перегоне не встречается, а скорость
изменения оси ограничена. Без них платформа станции, делающая свод резко несимметричным,
уводит оценку центра, и габарит заезжает прямо на платформу — это был источник почти всех
ложных тревог у станций.
### 3.4. LAMINA — ON/OFF и латеральное торможение
Работа идёт не с дальностью, а с **диспаритетом** `δ = 1/R`. Так правильно по двум причинам:
угловой размер предмета пропорционален `1/R`, и шум лидара в диспаритете почти однороден,
тогда как в дальности растёт квадратично.
Клетки L1 и L2 расходятся на два канала:
* **ON** = `max(δ − δ_окружения, 0)` — объект ближе окружения, то есть выступ;
* **OFF** = `max(δ_окружения − δ, 0)` — провал или отсутствие эха, то есть **окклюзионная тень
за предметом**. Тень часто во много раз крупнее самого предмета — именно она даёт шанс увидеть
мелкий объект на большой дальности.
Окружение — кольцо вокруг точки, вычисляемое двумя равномерными фильтрами (большое окно минус
вырезанный центр). Оба разделимы и работают за O(N), поэтому стоимость не зависит от размера окна.
Размер предмета в лучах меняется с дальностью на два порядка: человек даёт ~440 лучей на 20 м
и ~8 на 120 м. Поэтому окружение берётся **на трёх масштабах сразу** и отклики объединяются
максимумом — как у колонковых нейронов лобулы с разными размерами рецептивных полей,
сходящихся на один нисходящий нейрон.
### 3.5. MEDULLA и LOBULA PLATE — движение
**T4/T5** — элементарные детекторы движения, по четыре подтипа на направление; T4 читает ON-канал,
T5 — OFF. Вычислительно это коррелятор Хассенштайна–Райхардта: сигнал одного омматидия
задерживается и умножается на сигнал соседнего, разность двух таких произведений даёт
направленный отклик.
**LPTC (HS/VS)** суммируют выход тысяч T4/T5 и тем самым измеряют собственное движение.
Здесь это критично: колёсной одометрии нет, и скорость поезда неоткуда взять, кроме как из
самого потока. Реализовано в три ступени, от дешёвой к точной:
1. корреляция продольного профиля тоннеля (гистограмма дальностей, обелённая вычитанием
скользящего среднего и без ближней зоны — иначе корреляция залипает на нулевом сдвиге);
2. медианное приближение дальних фронтальных поверхностей (стены отбрасываются по градиенту
дальности вдоль строки);
3. уточнение **перепроекцией**: точки прошлого кадра сдвигаются вперёд на пробное `Δs`,
проецируются в решётку текущего кадра, и выбирается сдвиг с максимальной долей совпавших
лучей. Это и есть проверка широкопольного потока на согласие с моделью собственного
движения — то, чем заняты тангенциальные клетки.
Доля совпавших лучей заодно служит мерой доверия: измеренные 0.76…0.95 означают, что модель
движения описывает сцену, а стоящий поезд даёт ровно 0 км/ч.
**LPLC2** — детектор надвигания. Его дендриты разложены на четыре слоя так, что клетка отвечает
только на поток, расходящийся из центра её рецептивного поля, и подавляется однородным
широкопольным потоком, то есть отделяет «на меня что-то летит» от «я сам двигаюсь».
Вычислительно — дивергенция поля T4/T5.
### 3.6. LOBULA — кандидаты (LC11)
LC11 у мухи — детектор мелкого объекта: возбуждается компактным пятном, выделяющимся из фона,
и подавляется широкопольным узором. Здесь тем же занимается выделение кандидатов.
Лучи, попавшие в габарит, группируются в связные пятна — но с двумя принципиальными деталями:
* **связность с учётом разрыва по глубине.** Обычное соседство склеивает предмет со стеной,
оказавшейся в том же месте изображения, но на сто метров дальше. Два соседних луча
объединяются, только если их дальности близки; допуск растёт с расстоянием, потому что
и разрешение, и шум растут так же. Эта одна правка подняла обнаружение реального объекта
с 16/20 до 20/20 кадров и дала правильные габариты вместо слипшихся 5 × 4 м;
* **кластеризация по расширенной области, проверка членства — по габариту.** Вертикальный
лоскут стены, срезанный границей коридора, неотличим от предмета: обрезка сама создаёт
компактное пятно нужного размера. Поэтому пятна ищутся в объёме с запасом, а доля лучей,
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край
стены. Расширение идёт в стороны и вверх, но **не вниз**: полотно проходит под каждым
предметом и на большой дальности попало бы в тот же допуск по глубине.
Для каждого кандидата считаются: дальность, смещение от оси, высота нижней точки, габариты,
протяжённость вдоль пути, целостность, число лучей и колец, контраст, дефицит до пола,
доля тени и интенсивность.
**Разрез компоненты, растёкшейся вдоль стены.** Допуск по глубине решает задачу «предмет и
далёкая стена», но не обратную: вдоль **гладкой** стены соседние лучи отличаются на
сантиметры, и стена оказывается связной от ближнего поля до горизонта. Предмет у такой
стены попадает в ту же компоненту и отбрасывается вместе с ней правилом «ни один предмет
не тянется на 15 м вдоль пути».
Разорвать такую компоненту по дальности нельзя — предмет и стена рядом с ним стоят на одной
дальности. Зато гладкая стена даёт **нулевой центр-окружение по построению**: как бы сильно
дальность ни менялась вдоль стены, она меняется плавно, и центр равен окружению. Предмет на
стене — ступенька, и ламина её видит. Поэтому переглубокая компонента не выбрасывается, а
пересобирается по лучам с контрастом выше порога (`split_gap`, 6 м).
Выносится **ровно одна, сильнейшая фигура** (`split_top`). Это не косметика: разрез отрезает
фон, и протяжённость кандидата вдоль пути падает с 6.9 до 0.8 м — вместе с ней выключается
множитель компактности в весе улики, который до того давил протяжённые конструкции вдесятеро.
Ограничение — тот же приём глобального торможения, которым APL оставляет активными считанные
проценты клеток Кеньона. Измерено: одна фигура вместо всех даёт 6.4 ложных трека на километр
против 9.4 **при одинаковой дальности обнаружения**.
### 3.7. MUSHROOM BODY — память нормального тоннеля
Геометрия честно сообщает обо всём, что торчит в габарит, и вместе с препятствиями выдаёт
кабельные лотки, ниши, гермозатворы, кромки платформ и стрелочные приводы. Разделить их
геометрическим правилом нельзя — но можно **выучить, что для этого тоннеля привычно**.
Схема взята из коннектома почти без изменений:
1. **PN → KC.** Каждая клетка Кеньона получает вход от ~6 проекционных нейронов, выбранных
случайно, — разрежённая случайная проекция, поднимающая размерность в десятки раз.
2. **APL.** Один гигантский тормозный нейрон собирает активность всех KC и возвращает торможение
всем сразу: «победитель забирает всё», одновременно активны доли процента клеток.
3. **KC → MBON.** Синапсы депрессируются при повторном предъявлении, поэтому знакомый стимул
даёт слабый ответ, а новый — сильный. Ответ MBON и есть новизна.
Обучение идёт **без единой метки**: конвейер прогоняется по проездам пустого тоннеля, все
выданные геометрией кандидаты объявляются знакомой обстановкой.
Ключевая тонкость — **темп депрессии согласуется с размером выборки**. На одну клетку Кеньона
приходится `n · n_active / n_kc` попаданий; если темп не уменьшать вместе с ростом выборки,
после нескольких десятков тысяч примеров подавлены все синапсы и новым не выглядит уже ничто,
включая настоящее препятствие (проверено: при исходных параметрах новизна реального объекта
падала до 0.001). Темп выбирается так, чтобы типичная клетка ослабла в фиксированное число раз;
тогда шкала новизны отражает **частоту** обстановки, а не факт «видел хоть раз».
**Привыкание внутри проезда — сделано и выключено.** Всё вышесказанное работает,
только если память этот тоннель знает; на новом участке ложных треков 21.8 на
километр против 9.1. Попытка закрыть это короткой памятью, гасящей формы, которые
повторяются в разных точках пути, измерена и отвергнута: избирательности у механизма
нет, а видимый эффект оказался насыщением популяции, которое давит предмет сильнее
обстановки. Код и параметры оставлены (`enable_habituation`), разбор — EXPERIMENTS
п. 10.
### 3.8. CENTRAL COMPLEX — накопление улик
Кандидат на 150 м — это 5–10 лучей, и по одному кадру он неотличим от шума. Но поезд едет,
и настоящий объект остаётся **на одном месте в тоннеле**, а не в поле зрения.
Эллипсоидное тело мухи держит кольцевой аттрактор: клетки EPG образуют бугор активности,
клетки PEN сдвигают его по сигналам собственного вращения, взаимное торможение не даёт
возникнуть второму бугру. Здесь тот же механизм: треки живут в координате «путь от начала
записи», сдвигаемой оценкой собственного движения (роль PEN), совпадение подкачивает улику
(локальное возбуждение), несовпадение — утечка, конкуренция за место гасит дубликаты
(глобальное торможение).
Вес одного наблюдения:
$$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1\right)}_{\text{поддержка}}
\cdot \underbrace{\frac{\Delta R}{3}}_{\text{контраст}}
\cdot \underbrace{\frac{c-0.25}{0.45}}_{\text{целостность}}
\cdot \underbrace{\left(1.5-\frac{L}{3s}\right)}_{\text{компактность}}
\cdot \underbrace{\left(1.25-\frac{h_\text{низ}}{1.2}\right)}_{\text{опора снизу}}
\cdot \underbrace{g(\nu)}_{\text{новизна}}$$
Все множители физичны:
* **поддержка** — число лучей нормируется на ожидаемое для этой дальности: дальний объект даёт
мало лучей не потому, что сомнительный, а потому что так устроена решётка;
* **компактность** — посторонний предмет не тянется на десятки метров вдоль пути, а лоток,
стена и полотно тянутся;
* **опора снизу** — упавший предмет, человек, камень стоят на полотне, а знак, лоток или
кронштейн висят на стене, и под ними пусто;
* **новизна** `g(ν)` — резкое отображение ответа MBON с ненулевым полом: даже похожий на
привычную конструкцию предмет должен накапливать улику, просто медленнее.
### 3.9. DESCENDING NEURONS — решение
Весь разбор сцены у мухи сходится на нескольких десятках нисходящих нейронов. Два из них
работают по надвигающемуся объекту: **гигантское волокно (DNp01)** с высоким порогом запускает
немедленный аварийный манёвр, **DNp02/DNp11** с порогом ниже дают раннюю мягкую реакцию.
Поезду нужна такая же пара уровней: заблаговременное предупреждение с запасом по дальности
и экстренное торможение по надёжному близкому объекту. Пороги с гистерезисом — без него трек
на границе даёт дребезг, а дребезжащая команда торможения хуже её отсутствия.
Тормозной путь считается как `v·t_реакции + v²/(2a)` и сравнивается с дистанцией до объекта.
---
## 4. Какие параметры на что влияют
| Параметр | Эффект при увеличении |
|---|---|
| `half_width` | шире габарит → больше находок и больше ложных на кромках платформ |
| `h_lo` | выше порог → перестают ловиться низкие предметы (кабель, камень), меньше ложных от полотна |
| `min_rays` | строже → пропадают дальние мелкие объекты, падает поток кандидатов |
| `fov_deg` | шире сектор → лучше кривые и стрелки, дороже обработка |
| `gain` / `leak` (CX) | быстрее подтверждение против устойчивости к шуму |
| `warn_evidence` | выше → меньше ложных тревог, позже обнаружение |
| темп депрессии MB | выше → сильнее подавление знакомого, но риск заглушить и настоящее |
---
## 5. Ограничения метода
Формулируем прямо, потому что скрывать их бессмысленно — они видны на данных.
1. **Дальность ограничена не алгоритмом, а геометрией.** Прямая видимость в предоставленных
тоннелях 121–167 м: тоннели кривые, дальше линия взгляда упирается в стену. Паспортные
200 м Pandar128 достижимы только на прямых участках.
2. **Память знает только то, что видела.** На новом участке тоннеля незнакомая штатная
конструкция получит высокую новизну. Поэтому новизна входит множителем, а не фильтром,
и решение опирается ещё на геометрию и накопление улик.
3. **Оценка оси пути требует видеть обе стены.** В широких залах и на станциях сечение
перестаёт быть тубусом вокруг пути; спасают физические ограничения на радиус и скорость
изменения оси, но точность там ниже.
4. **Очень низкие предметы на грани.** Кабель Ø 6 см даёт 9 лучей на 20 м и 2 на 40 м —
у самой границы разрешения прибора, не алгоритма.
5. **Скорость оценивается только продольная.** Боковой снос и вертикальные колебания
компенсируются стабилизацией плоскости, но в модель движения не входят.
6. **LPLC2 считается, но пока не влияет на решение** — канал надвигания подготовлен
и визуализируется, его вклад в улику ещё не откалиброван.

84
docs/CASE.md Normal file
View file

@ -0,0 +1,84 @@
# Кейс 05 · Обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара
> Хакатон «Лидеры цифровой трансформации» (ЛЦТ) 2026 — конкурс Мэра Москвы для лучших ИТ-специалистов мира.
 
## 📋 Общая информация
| Параметр | Значение |
|---|---|
| **Номинация** | Компьютерное зрение |
| **Заказчик** | Московский транспорт (Департамент транспорта и развития дорожно-транспортной инфраструктуры города Москвы), ГУП «Московский Метрополитен» |
| **Ссылка на задачу** | [i.moscow/hackaton/lct/1233bb5506bc455f86d534b3b40171f1](https://i.moscow/hackaton/lct/1233bb5506bc455f86d534b3b40171f1) |
| **Формат продукта** | Программный комплекс (ROS2) |
 
## 🏆 Призовой фонд
| Место | Сумма |
|---|---|
| 🥇 1 место | 1 000 000 ₽ |
| 🥈 2 место | 600 000 ₽ |
| 🥉 3 место | 400 000 ₽ |
 
## 🎯 Актуальность
В настоящее время активно ведётся разработка **беспилотного поезда метро**, для которого одной из ключевых функций является контроль пространства по ходу движения. Особенно важна задача мониторинга тоннеля: необходимо своевременно выявлять посторонние объекты, попадающие в габарит поезда и потенциально представляющие угрозу безопасности движения.
Использование 3D-лидара позволяет получать информацию о пространстве перед поездом вне зависимости от условий освещённости. При этом требуется разработать алгоритм, который сможет надёжно обнаруживать препятствия на максимально возможной дальности, сохраняя низкий уровень ложно-положительных срабатываний. Решение данной задачи критически важно для построения безопасной и устойчивой системы автономного управления поездом метро.
 
## 🧩 Описание задачи
Разработайте программный комплекс на базе методов обработки 3D-лидарных данных, который:
1. Обрабатывает облака точек, получаемые с 3D-лидара, установленного на беспилотном поезде метро;
2. Выполняет мониторинг пространства перед поездом в тоннеле в реальном времени;
3. Обнаруживает посторонние объекты, попадающие в зону, ограниченную габаритом поезда;
4. Обеспечивает детекцию объектов на максимально возможной дальности до препятствия;
5. Минимизирует количество ложно-положительных детектов при сохранении высокой чувствительности к реальным препятствиям;
6. Формирует выходные данные, пригодные для интеграции в существующий пайплайн компьютерного зрения и системы принятия решений беспилотного поезда.
 
## 📦 Ресурсы
- Файлы с лидарными данными, записанными в условиях движения поезда метро в тоннеле.
 
## 🚀 Описание итогового продукта
Программный комплекс, включающий:
1. **ROS2-ноды** для загрузки и обработки лидарных облаков точек;
2. Алгоритмы фильтрации, сегментации и анализа 3D-данных;
3. Модуль детекции посторонних объектов в зонах, ограниченных габаритом поезда;
4. Интерфейсы или сообщения для передачи результатов детекции в существующие подсистемы беспилотного поезда.
 
## 👥 Рекомендуемые роли в команде
- Системный аналитик
- Инженер компьютерного зрения
- Разработчик робототехники (ROS2)
- Специалисты по работе с данными
- Разработчик программного обеспечения (C++/Python)
 
 
## 💬 Контакты и поддержка
- Телеграм: [@help_lct](https://t.me/help_lct)
- Почта: info.leaders@develop.mos.ru
- Модератор задачи: **Горбатова Ольга** — [@gorbatovaol](https://t.me/gorbatovaol)
 
## 🗓️ Ключевые этапы конкурса
| Этап | Сроки | Формат |
|---|---|---|
| Приём заявок | до 14 сентября | онлайн |
| Разработка решений | 15–29 сентября | онлайн |
| Техническая экспертиза | 30 сентября – 14 октября | онлайн |
| Презентация проектов | 23 октября | онлайн |
| Церемония награждения | 30 октября | офлайн в Москве |

175
docs/CONNECTOME.md Normal file
View file

@ -0,0 +1,175 @@
# Что именно взято из коннектома
Документ отвечает на два вопроса: какие параметры решения пришли из данных о мозге
дрозофилы, и почему взяты **схемы**, а не полная симуляция.
---
## 1. Почему не полная симуляция мозга
Полный коннектом взрослой дрозофилы (FlyWire) — около 139 тыс. нейронов и порядка
54 млн синапсов. Симуляция такого мозга в виде leaky integrate-and-fire существует
(Shiu et al., 2024), но для нашей задачи не годится по трём причинам.
**Бюджет времени.** Осмысленная спайковая динамика требует шага интегрирования 0.1–1 мс,
то есть 100–1000 шагов на один кадр лидара. Каждый шаг — разрежённое умножение по
десяткам миллионов синапсов с нерегулярным доступом к памяти. На стенде жюри
(i7-9700E, 8 ядер, 2.6 ГГц) это даёт от единиц до десятков секунд на кадр при бюджете
в 100 мс. GPU сокращает разрыв, но не закрывает его и съедает весь ресурс, ничего не
оставляя геометрии, трекингу и выводу.
**Потеря разрешения — главная причина.** У мухи около 800 омматидиев на глаз с угловым
шагом порядка 5°. Чтобы подать данные в настоящий коннектом, лидар пришлось бы проредить
с 460 800 лучей до ~800, то есть с 0.1° до 5°. Предмет 0.5 м на 200 м занимает 0.14°
и исчез бы полностью. Мы выбросили бы ровно то, ради чего в поезде стоит Pandar128.
**Неполнота данных.** Коннектом даёт связность, но не даёт знаки синапсов, веса и
постоянные времени — их всё равно пришлось бы подбирать.
Поэтому берутся **вычислительные схемы** и измеренные параметры, а работают они на полном
разрешении лидара в rate-based виде: один шаг на кадр, состояние популяции — массив.
---
## 2. Параметры, пришедшие из коннектома
### Грибовидное тело: 6 «когтей» на клетку Кеньона
Клетка Кеньона получает вход от небольшого числа проекционных нейронов, выбранных почти
случайно; в реконструкциях hemibrain и FlyWire среднее число входных «когтей» составляет
примерно 5–7. Это значение мы не подбирали, а взяли — и затем проверили перебором
(`tools/tune_memory.py`, leave-one-bag-out, ROC AUC новизны):
| «когтей» на KC | 4 | **6** | 8 | 10 |
|---|---|---|---|---|
| ROC AUC | 0.756 | **0.890** | 0.891 | 0.872 |
Биологическое значение оказалось на плато оптимума. Это не доказательство, но хорошая
проверка того, что схема перенесена осмысленно: случайная разрежённая проекция с малым
числом входов на клетку — не украшение, а работающий механизм.
### Грибовидное тело: разрежённость кода и APL
Гигантский тормозный нейрон APL собирает активность всех клеток Кеньона и возвращает
торможение всем сразу, оставляя активными единицы процентов. Мы сохранили саму схему
(глобальное торможение → отбор сильнейших), но **изменили масштаб**:
| | муха | FlyGuard |
|---|---|---|
| клеток Кеньона | ~2 000 на полушарие | 50 000 |
| активных одновременно | ~5 % | 0.1 % (50 клеток) |
Причина инженерная и измеренная. Муха за жизнь встречает сотни запахов; нам нужно
запомнить десятки тысяч видов тоннельной обстановки. При мушиных 2000 клетках и 5 %
активных память насыщается после нескольких тысяч примеров: подавлены 98 % синапсов,
и новым не выглядит уже ничто — новизна реального препятствия падала до 0.001, и оно
переставало обнаруживаться вовсе. Увеличение популяции и снижение разрежённости
восстанавливают работоспособность: ROC AUC 0.905, новизна объекта 0.49 против 0.13 у фона.
### Грибовидное тело: депрессия KC→MBON
Familiarity suppression — экспериментально описанное свойство выходных нейронов
грибовидного тела (в частности MBON-α′3): повторно предъявленный стимул даёт заметно
меньший ответ. Мы воспроизводим это умножением веса активных синапсов на (1 − rate)
при каждом предъявлении.
Темп депрессии **согласуется с размером выборки**: на одну клетку приходится
`n · n_active / n_kc` попаданий, и без такой поправки любая достаточно большая выборка
обнуляет память целиком. Темп выбирается так, чтобы типичная клетка ослабла в
фиксированное число раз; тогда шкала новизны отражает частоту обстановки, а не факт
«видел хоть раз».
### Дофаминергический контроль пластичности: когда учиться
Депрессия KC→MBON у мухи происходит не всегда, а когда её разрешают дофаминергические
нейроны PPL1 и PAM. Их вход зависит от состояния животного, а не только от стимула:
одно и то же предъявление запоминается или нет в зависимости от того, что происходит
вокруг.
У нас ту же роль играет координата пути из центрального комплекса. Кратковременное
привыкание (`mushroom_body.Habituation`) депрессирует синапсы только тогда, когда
форма встретилась в **новой точке пути**; повторное наблюдение того же предмета с
другой дальности пластичности не вызывает. Это перенос схемы «учитель решает
момент», а не самого нейромедиатора.
**Механизм по умолчанию выключен**, и это честный отрицательный результат, а не
недоделка: замер показал, что узнавания повторов у него нет, а весь видимый эффект
давало насыщение маленькой популяции (EXPERIMENTS п. 10). Сама же наблюдаемая
величина — сила дофаминового сигнала как средняя незнакомость сцены — осталась в
виде мозга: она заранее говорит, что участок новый и ложных тревог будет больше.
### T4/T5: четыре направления, два канала
T4 получает вход из ON-пути, T5 — из OFF-пути; каждый существует в четырёх подтипах,
настроенных на четыре стороны света в поле зрения. Воспроизведено буквально: четыре
направленных коррелятора Хассенштайна–Райхардта, отдельно для ON- и OFF-каналов ламины.
### LPLC2: четырёхслойный дендрит
Дендриты LPLC2 разложены на четыре слоя лобулярной пластинки так, что каждый слой
принимает T4/T5 «своего» направления с той стороны поля, куда поток уходит при
надвигании. Сумма четырёх слоёв — это дивергенция потока, и именно так реализован
детектор. Свойство подавляться однородным широкопольным потоком (то есть собственным
движением) сохраняется автоматически: у равномерного сдвига дивергенция равна нулю.
### Эллипсоидное тело: кольцевой аттрактор
Клетки EPG образуют единственный бугор активности, клетки PEN сдвигают его по сигналам
собственного вращения, взаимное торможение не даёт возникнуть второму бугру. В FlyGuard
эта схема переносится с курса на положение: треки живут в координате пути, сдвигаются
оценкой собственного движения, накапливают улику при совпадении и гасят соседей при
конкуренции за место.
### Нисходящие нейроны: два порога
Гигантское волокно (DNp01) — толстый аксон с высоким порогом, запускающий немедленный
аварийный манёвр; DNp02 и DNp11 реагируют раньше и мягче. Отсюда два уровня решения:
предупреждение и экстренное торможение, с гистерезисом между ними.
---
## 3. Что взято как идея, а не как число
Честное разделение: перечисленное ниже вдохновлено биологией, но конкретные величины
подобраны под задачу, а не измерены у мухи.
* число и размеры масштабов центр-окружения в ламине (три масштаба);
* постоянная времени задержки в корреляторе T4/T5 (1.5 кадра);
* коэффициенты накопления и утечки в центральном комплексе;
* пороги нисходящих нейронов и гистерезис;
* геометрические множители веса улики (целостность, компактность, опора снизу) — они
выведены из физики тоннеля, а не из анатомии;
* ограничение «одна фигура на компоненту» при разрезе по контрасту: приём глобального
торможения взят у APL и у широкопольного подавления LC11, но само число выбрано
замером (одна против двух и против всех: 6.4 / 7.4 / 9.4 ложных трека на километр);
* шаг «разных мест» и путь полузабывания в привыкании (5 м и 800 м) — механизм
выключен, но параметры остались.
---
## 4. Источники
* Dorkenwald S. и др. **Neuronal wiring diagram of an adult brain.** Nature, 2024 —
коннектом FlyWire: около 139 тыс. нейронов, ~54 млн синапсов.
* Scheffer L. и др. **A connectome and analysis of the adult Drosophila central brain.**
eLife, 2020 — hemibrain; статистика входов клеток Кеньона.
* Shiu P. и др. **A leaky integrate-and-fire computational model based on the connectome
of the entire adult Drosophila brain.** Nature, 2024 — оценка стоимости полной симуляции.
* Dasgupta S., Stevens C., Navlakha S. **A neural algorithm for a fundamental computing
problem.** Science, 2017 — FlyHash: разрежённая случайная проекция с отбором победителей.
* Dasgupta S., Sheehan T., Stevens C., Navlakha S. **A neural data structure for novelty
detection.** PNAS, 2018 — грибовидное тело как детектор новизны.
* Hattori D. и др. **Representations of novelty and familiarity in a mushroom body
compartment.** Cell, 2017 — familiarity suppression у MBON-α′3.
* Maisak M. и др. **A directional tuning map of Drosophila elementary motion detectors.**
Nature, 2013 — четыре подтипа T4 и T5.
* Klapoetke N. и др. **Ultra-selective looming detection from radial motion opponency.**
Nature, 2017 — LPLC2 и четырёхслойная организация дендритов.
* Seelig J., Jayaraman V. **Neural dynamics for landmark orientation and angular path
integration.** Nature, 2015 — кольцевой аттрактор эллипсоидного тела.
* von Reyn C. и др. **A spike-timing mechanism for action selection.** Nature Neuroscience,
2014 — гигантское волокно и пороги реакции ухода.
Параметры сенсора взяты из **Pandar128E3X User Manual v4p5** (п. 1.4 «Specifications» и
Приложение A «Channel distribution data»); поканальная таблица извлечена скриптом
`tools/extract_channel_table.py` в `ros2_ws/src/flyguard/flyguard/data/pandar128_channels.csv`.

970
docs/EXPERIMENTS.md Normal file
View file

@ -0,0 +1,970 @@
# Эксперименты
Все числа в документе получены скриптами из `tools/` на предоставленных данных и
воспроизводятся командами, указанными в каждом разделе. Там, где результат оказался
хуже ожидаемого, он приведён как есть.
Машина разработки: Ryzen 5 7600X, 32 ГБ, RTX 5070 Ti. Стенд жюри слабее по CPU
(i7-9700E, 8 ядер, 2.6 ГГц), поэтому замеры задержки приведены с запасом и обсуждаются
отдельно в разделе 7.
---
## 1. Что на самом деле в данных
```bash
python tools/inspect_bags.py --root data/for_hackathon
```
| Бэг | Кадров | Топик | Точек в кадре | Валидных лучей |
|---|---|---|---|---|
| `doubleT_obstacle` | 201 | `/sensing/lidar/hesai128/pointcloud` | 921 600 | 37.6 % |
| `doubleT_platform` | 345 | `/lidar_points` | 307 200 | 60.5 % |
| `roundT_doubleT` | 252 | `/lidar_points` | 307 200 | 61.7 % |
| `roundT_pressureGate_roundT` | 268 | `/lidar_points` | 307 200 | 60.6 % |
| `roundT_squareT_pressureGate_squareT` | 545 | `/lidar_points` | 307 200 | 61.6 % |
| `squareT_platform_squareT_switch` | 877 | `/lidar_points` | 307 200 | 59.4 % |
| `new_data` (221 шард) | 11 271 | `/lidar_points` | 307 200 | — |
Три вещи, о которых README датасета молчит и которые ломают наивную обработку:
1. **Архивы — несжатый tar**, а не `.zst`.
2. **Раскладка скана различается**: 3600 азимутов на 360° против 1200 на 120°.
Ничего нельзя захардкодить.
3. **У каналов постоянный азимутальный сдвиг до 15.6°** (±78 столбцов при шаге 0.1°).
Без выпрямления «столбец» не является направлением, и все пространственные фильтры
считают мусор. Руководство Pandar128E3X это подтверждает прямо: «Each laser channel
has an intrinsic azimuth offset».
Различаются и условия съёмки: высота установки сенсора над головкой рельса составляет
1.31–1.33 м в пяти бэгах и 1.70 м в `doubleT_obstacle`. Решение калибруется по данным
и работает с обоими без правок.
---
## 2. Проверка калибровки по паспорту
```bash
python tools/extract_channel_table.py --pdf <руководство> --out .../pandar128_channels.csv
python tools/validate_calibration.py
```
Решётка лучей восстанавливается **из самих облаков точек**, паспортные углы нигде не
используются. Поэтому таблица каналов из Приложения A руководства служит независимой
проверкой:
| Величина | Измерено по данным | Паспорт | Расхождение |
|---|---|---|---|
| Разброс азимутального сдвига каналов | ±7.80° (размах 15.60°) | −7.811°…+7.804° (15.615°) | 0.015° |
| Диапазон углов места | −25.1°…+14.4° | −25.016°…+14.436° | < 0.1° |
| Угол места, поканально | — | — | медиана 0.065°, макс 0.123° |
| Азимутальный сдвиг, поканально | — | — | медиана 0.044°, макс 0.152° |
Расхождение одинаково на всех шести бэгах. Остаток объясняется тем, что в руководстве
приведены **проектные** значения, а каждый экземпляр прибора поставляется с собственным
файлом угловой коррекции — именно её и восстанавливает калибровка по данным.
Остаточная угловая ошибка после выпрямления образа: **p50 = 0.0000°, p99 = 0.028°,
макс 0.040°** — меньше половины шага решётки (0.05°).
---
## 3. Сколько тоннель вообще позволяет увидеть
```bash
python tools/analyze_corridor.py --frames 50
```
| Бэг | Радиус кривой | Прямая видимость (p99.9) |
|---|---|---|
| `doubleT_obstacle` | 1690 м | 167 м |
| `doubleT_platform` | 7310 м | 143 м |
| `roundT_doubleT` | 1310 м | 126 м |
| `roundT_pressureGate_roundT` | 2970 м | 121 м |
| `roundT_squareT_pressureGate_squareT` | 8660 м | 127 м |
| `squareT_platform_squareT_switch` | 2300 м | 132 м |
Максимальное эхо во всём датасете — **208.8 м**, что совпадает с паспортными
0.3…200 м. Но **реальная прямая видимость 121–167 м**: тоннели кривые, и линия взгляда
упирается в стену раньше, чем кончается дальнобойность прибора.
Это измерение, а не оправдание: заявленные в ТЗ «300 м → отлично» на предоставленных
участках недостижимы **никаким** алгоритмом, потому что от объекта за поворотом
до лидара не доходит ни одного фотона. Проверяется прямо: при вставке синтетического
предмета на 84 м в кадр, где фон в том же направлении стоит на 76 м, ни один луч
до предмета не доходит — он физически закрыт стеной.
---
## 4. Реальное препятствие
Единственный бэг с настоящим посторонним объектом — `doubleT_obstacle`. Поезд стоит,
объект **0.67 × 1.35 м на 54.7…56.9 м**, 47–69 лучей, медленно смещается поперёк пути
(с +1.16 м до −1.98 м и обратно за 20 с, примерно 0.2 м/с).
```bash
python tools/check_obstacle.py --memory artifacts/mushroom_body.npz
```
| Метрика | Значение |
|---|---|
| Попал в кандидаты | 100 % кадров |
| Подтверждён треком | 98.9 % кадров |
| Новизна (ответ MBON) | 0.62 при фоне 0.13 |
---
## 5. Обобщаемость: leave-one-bag-out
```bash
python tools/evaluate.py --device cuda
```
Память тоннеля обучается на всех данных **кроме проверяемого бэга** — иначе цифры лгут:
подавлять конструкции, которые сам же и запомнил, умеет кто угодно, а на приватном тесте
будет новый участок.
| Бэг | Путь | Кадров с тревогой | Разных ложных треков | На километр |
|---|---|---|---|---|
| `doubleT_platform` | 201 м | 18.8 % | 2 | 10.0 |
| `roundT_doubleT` | 201 м | 1.3 % | 1 | 5.0 |
| `roundT_pressureGate_roundT` | 247 м | 0.0 % | 0 | 0.0 |
| `roundT_squareT_pressureGate_squareT` | 274 м | 13.4 % | 1 | 3.7 |
| `squareT_platform_squareT_switch` | 271 м | 13.4 % | 2 | 7.4 |
| **Итого** | **1193 м** | **9.4 %** | **6** | **5.2** (медиана 5.0) |
Реальный объект в `doubleT_obstacle` при этом обнаруживается в **98.9 %** кадров.
Две метрики отличаются принципиально. «Кадров с тревогой» завышает картину: одна и та же
конструкция, попавшая в треки, видна сотню кадров подряд. Для эксплуатации важно другое —
сколько **разных** ложных объектов возникло, потому что именно столько раз поезд
затормозил бы напрасно.
### 5.1. Что дал разбор худшего бэга
`roundT_doubleT` давал 39.8 ложных трека на километр — втрое хуже любого другого. Разбор
(`tools/diagnose_fp.py`) показал, что пять из восьми треков — один и тот же тип объекта:
полоса шириной 0.6 м, ростом ровно с габарит, тёмная (интенсивность 9 из 255), с разрывом
дальности до фона 18–25 м, повторяющаяся вдоль тоннеля каждые 13–20 м.
Снятие ограничений по высоте показало, что это **колонна, идущая от полотна до свода**:
кластер тянется от −0.33 м до 4.45 м, и в габарит 0.28…2.30 попадает лишь **15 %** его
лучей. Признак «наполненность» этого не видел: контекст кластеризации обрывался на
`h_hi + 1.2 = 3.5` м — ровно в талии между колонной и сводом, — поэтому срез конструкции
выглядел целым предметом.
Контекст поднят до `h_hi + 4.0 = 6.3` м (параметр `ctx_up`). Результат:
| | контекст до 3.5 м | контекст до 6.3 м |
|---|---|---|
| `roundT_doubleT`, кадров с тревогой | 39.7 % | **1.3 %** |
| `roundT_doubleT`, ложных треков | 8 | **1** |
| Всего ложных треков на км | 12.4 | **5.2** |
| Реальный объект на 55 м | 98.9 % | **98.9 %** |
Значения 2.5, 4.0 и 12.0 дают одинаковый результат: контекст просто дотягивается до свода
и дальше упирается в пустоту. Взято 4.0 — с запасом на более высокий тоннель.
---
## 6. Абляция: что именно работает
```bash
python tools/ablation.py --device cuda
```
Каждый вариант отличается от полного ровно одним отключённым механизмом; память во всех
вариантах обучена без проверяемого бэга.
| Вариант | Кадров с ложной тревогой | Разных ложных треков | Объект на 55 м |
|---|---|---|---|
| полная система | 10.7 % | 6 | 98.9 % |
| − память тоннеля | 24.1 % | 16 | 98.9 % |
| − ось пути (прямой коридор) | 4.4 % | 3 | 98.9 % |
| − признаки формы | 30.5 % | 21 | 98.9 % |
| − накопление улик | 37.4 % | **81** | 100.0 % |
Что из этого следует.
**Накопление улик в центральном комплексе — самый весомый механизм.** Без него число
разных ложных объектов растёт в 13.5 раза (6 → 81): каждое случайное пятно немедленно
становится «обнаружением». Это прямое подтверждение того, что подтверждение по
нескольким кадрам должно быть не эвристическим фильтром, а накопителем.
**Грибовидное тело снижает ложные тревоги вдвое** (24.1 % → 10.7 %) и при этом **никак
не влияет на обнаружение реального объекта** (98.9 % в обоих случаях). Именно этого
от памяти и ждали: она гасит знакомое, не трогая незнакомое.
**Признаки формы** (целостность, компактность вдоль пути, опора снизу) дают почти такой
же вклад, как память, — втрое меньше ложных треков (21 → 6).
**Ось пути — единственный механизм, который сейчас стоит дороже, чем даёт.** Её
отключение снижает ложные тревоги вдвое (10.7 % → 4.4 %, 6 → 3 трека) и не трогает
обнаружение реального объекта. Так вышло потому, что в двухпутном тоннеле центр свода
смещён относительно пути: ось оценивается со сдвигом, и кривой габарит заводит в зону
поиска куски стены. Держим её ради кривых участков, где без неё объект уезжает из
габарита, — но это осознанная плата, а не выигрыш.
Из этого сделан вывод и изменено решение: габарит теперь **объединение** прямого и
кривого коридоров, а не замена одного другим. Система безопасности не имеет права
сужать зону поиска по неуверенной оценке. Итог замены на объединение:
| | ось заменяет прямой коридор | ось **дополняет** прямой |
|---|---|---|
| Реальный объект на 55 м | 75.3 % | **98.9 %** |
| Кадров с ложной тревогой | 10.6 % | 17.5 % |
| Разных ложных треков на км | 7.5 | 12.4 |
Размен сознательный: +23.6 п.п. обнаружения за +6.9 п.п. ложных тревог. Пропустить
человека на пути существенно хуже, чем лишний раз затормозить.
Таблица выше пересчитана уже на объединённом коридоре, поэтому «полная система»
показывает 98.9 % обнаружения.
---
## 7. Скорость
```bash
python tools/run_pipeline.py --all --memory artifacts/mushroom_body.npz --verbose
```
Медиана по стадиям на кадре 128 × 600 (сектор ±30°), машина разработки:
| Стадия | мс |
|---|---|
| retina (оконная проекция) | 7.0 |
| ламина | 6.4 |
| оценка движения (LPTC) | 5.5 |
| ось пути | 4.7 |
| лобула (кандидаты) | 4.5 |
| стабилизация | 3.5 |
| грибовидное тело | 1.2 |
| центральный комплекс | 0.2 |
| решение | 0.02 |
| **итого** | **p50 ≈ 35, p95 ≈ 45** |
Бюджет по ТЗ — 100 мс на кадр. Запас примерно двукратный, что важно: стенд жюри по CPU
слабее машины разработки. Если запаса не хватит, первыми кандидатами на перенос в
numba являются ламина и оценка движения — вместе это 12 мс почти чистой арифметики.
Отдельно измерена оптимизация ретины: на круговом скане (921 600 точек) оконная
проекция сократила стадию с **29 до 7 мс**, причём результат совпадает с полной
проекцией **побитово** — проверено сравнением массивов.
## 7.3. Почему далёкий предмет теряется — и что нужно для 200 м
ТЗ просит 300 м как «отлично» и 200 м как «очень хорошо». Разберём честно, чего
не хватает, потому что причина не та, которая кажется.
**Фотоны есть.** Вставленный человек на оси пути, по замерам полигона:
| Полоса | Лучей на кадр | Есть эхо | Кадров в полосе | Накоплено лучей | Обнаружено сейчас |
|---|---|---|---|---|---|
| 160–190 м | 5 | 100 % | 17 | ~84 | **0 %** |
| 135–160 м | 7 | 100 % | 17 | ~120 | **0 %** |
| 110–135 м | 10 | 94 % | 18 | ~176 | **0 %** |
| 90–110 м | 16 | 92 % | 14 | ~227 | 25 % |
| 70–90 м | 26 | 87 % | 15 | ~400 | 58 % |
На 170 м предмет освещён в **каждом** кадре и за проход набирает под сотню попаданий
в одну и ту же точку мира. Информация есть — мы её выбрасываем, решая покадрово.
**Кандидат при этом формируется.** Покадровый разбор (`doubleT_platform`, человек
от 200 м) показывает кандидата в большинстве кадров на 140–185 м: 4–9 лучей,
наполненность до 1.00, размер 0.4 × 1.5 м — верный. Но улика трека остаётся 0.00,
и виноват один множитель: **`gap` = 0.0 во всех кадрах без исключения**.
**Почему.** Кольцо окружения ламины берётся ±6 столбцов, то есть ±0.6°. На 170 м этот
угол отвечает боковому смещению 1.8 м, а стена тоннеля на таком смещении находится
на 172 м — там же, где предмет. Центр-окружение перестаёт работать, когда собственный
градиент тоннеля по глубине сравним с шагом от предмета: предмет не «ближе окружения»,
он «на той же дальности, что окружение». В `_quality` это даёт
`contrast = clip(0/3, 0.2, 1) = 0.2`, и улика не набирается ни за 17 кадров, ни за сто.
Это не настройка порога. Локальный контраст на больших дальностях в тоннеле физически
не несёт сигнала, и никакая подстройка ламины этого не изменит.
### Что сделано: накопление в координатах пути
Предмет неподвижен в мире, а тоннель проплывает мимо. Собственное движение мы уже
оцениваем, поэтому лучи из габарита складываются не в кадре, а в сетке, привязанной
к пройденному пути (`fan_body.py`, шаг 2 м вдоль пути × 0.2 м поперёк × 0.25 м по
высоте, забывание с полураспадом 23 кадра). Так устроено веерное тело центрального
комплекса мухи: оно копит вектор к цели в координатах мира, а не текущего кадра.
**Первая версия порождала собственных кандидатов — и это оказалось тупиком.** Ложных
треков стало 39 на километр вместо 5.2. Разбор показал, почему: по геометрии
накопленное скопление предмета и накопленный кусок конструкции тоннеля **неразличимы**.
Медианы (предмет / ложные), 145 против 761 скопления:
| признак | дальность | \|u\| | высота | h_min | ширина | протяжённость | опора | кадров |
|---|---|---|---|---|---|---|---|---|
| предмет | 88 | 1.16 | 1.28 | 0.28 | 0.80 | 4.0 | 1.31 | 22.1 |
| ложные | 100 | 1.10 | 1.27 | 0.28 | 0.80 | 4.0 | 1.44 | 18.5 |
Совпадает всё. Разделяет их только память тоннеля, а ей нужны признаки кадра —
контраст, интенсивность, тень, — которых у скопления нет по построению.
**Рабочая версия.** Накопитель не порождает кандидатов вовсе. Он отвечает на один
вопрос про **уже найденного покадрового кандидата** — возвращались ли лучи из этой
точки мира кадр за кадром — и эта опора подставляется в вес улики вместо недоступного
контраста (`contrast = max(по gap, по накоплению)`). Кандидат при этом остаётся под
судом грибовидного тела со всеми своими признаками, и штатные конструкции по-прежнему
подавляются.
Измеренный результат на вставленном человеке (доля кадров с обнаружением):
| Бэг | 55–75 м | 75–100 | 100–130 | 130–170 |
|---|---|---|---|---|
| `roundT_squareT_pressureGate_squareT` | 1.00 | 0.38 → **1.00** | 0.00 → **1.00** | 0.00 → **0.55** |
| `squareT_platform_squareT_switch` | 1.00 | 1.00 | 0.33 → **1.00** | 0.00 → 0.14 |
| `doubleT_platform` | 1.00 | 0.82 → **0.95** | 0.00 → 0.09 | 0.00 |
| `roundT_doubleT` | 0.00 | 0.00 | 0.00 | 0.00 |
| `roundT_pressureGate_roundT` | 0.00 | 0.00 | 0.00 | 0.00 |
Рабочая дальность там, где кандидат вообще формируется, **выросла вдвое**: с 75–100
до 130–170 м. Два круглых тоннеля накопление не спасает — там предмет слипается со
стеной в одну связную компоненту, кандидата нет, и поддерживать нечего (см. п. 9.3).
На полном полигоне (90 сценариев, 14 004 наблюдения):
| | без накопления | с накоплением |
|---|---|---|
| Рабочая дальность, человек стоя | 62 м | **100 м** |
| P@100 м, человек стоя | 0.24 | **0.57** |
| P@150 м, человек стоя | 0.00 | **0.10** |
| P@100 м, человек сидя | 0.19 | **0.33** |
| Ложных треков на км (leave-one-bag-out) | **5.2** | 9.1 |
| Кадров с тревогой | **9.4 %** | 17.2 % |
| Посторонних тревог на кадр (полигон) | **0.055** | 0.171 |
| Реальный объект на 55 м | 98.9 % | 98.9 % |
| Задержка, медиана | 32 мс | 33 мс |
**Включено по умолчанию.** Размен здесь принципиально лучше, чем у разделения фигуры
и фона (п. 9.4): там было вчетверо больше ложных за +29 % дальности, здесь — в 1.75
раза больше за +61 % рабочей дальности и рост обнаружения на 100 м в 2.4 раза. ТЗ
прямо оценивает дальность (100 м → «хорошо»), а «важно найти баланс между дальностью,
надёжностью и количеством ложных тревог» — этот баланс мы и выбираем осознанно.
Выключается одним параметром: `enable_accumulator: false` возвращает 5.2 ложных
трека на километр при рабочей дальности 62 м.
### Что ещё нужно
**Геометрическая карта линии.** Метро — неизменная среда: за несколько проездов
строится ожидаемый дальностный образ, привязанный к положению вдоль линии. Тогда
«препятствие» = «луч вернулся ближе, чем говорит карта», и это единственный способ
получить **и** дальность, **и** околонулевые ложные тревоги: всё постоянное в карте,
всё остальное — предмет. Грибовидное тело делает то же самое в пространстве
признаков; карта делает это в пространстве геометрии, где на 170 м ещё есть сигнал.
### Чего не будет никогда
* **На предоставленных участках 200 м недостижимы геометрически**: прямая видимость
121–167 м, дальше линия взгляда упирается в стену кривой. Это не свойство алгоритма.
* **Мелкие предметы на 200 м невозможны с этим сенсором**: ведро (0.1 м²) на 160–190 м
даёт 1 луч при видимости 2 %, каска и бутылка — ноль. Накопление не поможет там,
где фотонов нет.
* Реалистичная планка для предмета размером с человека на прямом участке — **около
200 м**, и путь к ней измерен выше: накопление плюс карта.
---
## 8. Что не сработало
Раздел намеренно подробный: ТЗ п. 8.7 просит именно этого.
**Поиск рельсов по интенсивности.** Идея была привязать ось пути к колее 1520 мм.
Не вышло: медианная интенсивность на уровне головок рельсов равна 8 из 255, рельсы
ничем не выделяются на фоне полотна, и пара пиков на расстоянии 1.52 м находится
где попало — оценки прыгали от −1.18 до +1.37 м в соседних срезах одного кадра.
Отказались, ось пути оценивается по дрейфу центра свода.
**Обычная связность при кластеризации.** Соседние лучи объединялись без учёта глубины,
и предмет на 55 м слипался со стеной на 150 м в одно пятно размером 5 × 4 м. Реальный
объект обнаруживался в 16 кадрах из 20. После введения допуска по глубине, растущего
с расстоянием, — 20 из 20 и правильные габариты 0.67 × 1.35 м.
**Грибовидное тело с мушиными параметрами.** 2000 клеток Кеньона и 5 % активных
насыщаются после нескольких тысяч примеров: подавлено 98 % синапсов, новизна реального
препятствия падает до 0.001, и оно перестаёт обнаруживаться совсем. Потребовалось
увеличить популяцию до 50 000 и снизить разрежённость до 0.1 %, а темп депрессии
согласовать с размером обучающей выборки.
**Корреляция продольного профиля «в лоб».** Первая версия оценки скорости залипала
на нулевом сдвиге: в профиль входила ближняя зона, где на метр пути приходятся тысячи
лучей, и её вклад подавлял всё остальное. Помогло исключение ближней зоны и вычитание
скользящего среднего. Отдельно обнаружилась ошибка в перепроекции — использовалась
высота над рельсом вместо z сенсора, из-за чего согласие держалось на 0.12 вместо 0.9.
**Взвешивание срезов по числу точек при оценке оси пути.** У ближних срезов точек
в сотни раз больше, и подгонка полностью игнорировала дальние, где как раз содержится
кривизна. Кривые расходились от +10 до −10 м на 200 м в соседних кадрах. Равные веса
по срезам и линейная (а не квадратичная) экстраполяция за горизонт видимости решили
проблему.
**Оценка оси пути на станции.** Платформа делает сечение резко несимметричным, центр
свода «уезжает», и габарит заезжает прямо на платформу: радиус кривой падал с 999 до
225 м за 4.5 с. Это давало 54 % кадров с ложной тревогой на бэге с платформой и
стрелкой. Помогли два физических ограничения — минимальный радиус 300 м и предел
скорости изменения оси. Стало 5.4 %.
**Срыв оценки скорости на смене типа тоннеля.** На переходе круглого тоннеля
в двухпутный сопоставление кадров теряло опору и выдавало попеременно 0 и 70 км/ч.
Помог фильтр с физическим пределом ускорения 3 м/с²: поезд за 0.1 с так не разгоняется.
**Полигон без учёта кривизны.** Первая версия синтетических сценариев ставила предмет
в поперечных координатах сенсора, а не на ось пути. В кривой это уносило его в стену,
и «рабочая дальность» выходила 32 м вместо реальных 55+. Исправлено привязкой к оси.
---
## 9. Размеченный полигон: дальность обнаружения
```bash
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz
python tools/plot_benchmark.py
```
Разметки в датасете нет, а организаторы предупредили, что приватный тест собран
добавлением синтезированных препятствий. Полигон строится тем же способом: в реальные
кадры пустого тоннеля трассировкой лучей вставляется предмет, стоящий **на оси пути**
в фиксированной точке тоннеля, поезд к нему подъезжает, и на каждом кадре известна
истинная дистанция.
Модель сенсора опирается на руководство: поканальная дальность из Приложения A
(каналы 34–65 берут 200 м, каналы 98–128 смотрят в землю и рассчитаны только на
ближнее поле), вероятность обнаружения на паспортной дальности PoD = 70 %, шум
дальности ±2 см, заполнение пятна луча для мелких предметов.
Проверка модели: настоящий объект 0.67 × 1.35 м на 55 м даёт 47–69 лучей; синтетический
человек 0.44 × 1.71 м на 60 м даёт 50 лучей. Совпадает.
Результаты приводятся в трёх разрезах, потому что смешивать их нельзя:
1. **видимость** — доля кадров, в которых до предмета дошёл хотя бы один луч;
за поворотом она падает до нуля независимо от алгоритма;
2. **обнаружение при условии видимости** — собственно качество алгоритма;
3. **обнаружение как есть** — произведение первых двух, эксплуатационная величина.
### 9.1. Результат
90 сценариев: 9 предметов × 2 поперечных смещения × 5 бэгов, 14 004 наблюдения
с известной истинной дистанцией.
| Предмет | Площадь | Рабочая дальность | P@50 м | P@100 м | P@150 м | Видимость |
|---|---|---|---|---|---|---|
| человек стоя | 0.75 м² | **100 м** | **0.70** | 0.53 | 0.12 | 92.5 % |
| человек сидя | 0.42 м² | 20 м | 0.62 | 0.34 | 0.00 | 89.3 % |
| ящик | 0.36 м² | 20 м | 0.49 | 0.00 | 0.00 | 88.1 % |
| чемодан | 0.25 м² | 62 м | 0.53 | 0.00 | 0.00 | 81.9 % |
| ведро | 0.10 м² | 8 м | 0.00 | 0.00 | 0.00 | 61.2 % |
| каска | 0.07 м² | — | 0.00 | 0.00 | 0.00 | 51.1 % |
| камень | 0.05 м² | — | 0.00 | 0.00 | 0.00 | 45.1 % |
| бутылка | 0.03 м² | — | 0.00 | 0.00 | 0.00 | 41.2 % |
| кабель | ~0 м² | — | 0.00 | 0.00 | 0.00 | 25.7 % |
Предыдущие замеры для сравнения. Без накопления в координатах пути (п. 7.3):
человек — рабочая дальность 62 м, P@50 = 0.56, P@100 = 0.24. С накоплением, но без
разреза по контрасту (п. 9.5): 100 м, P@50 = 0.56, P@100 = 0.57.
**«Рабочая дальность» у предметов около порога неустойчива** и её не надо читать как
физическую величину: метрика идёт от ближнего пояса и обрывается на первом, где доля
падает ниже 0.5, усредняя при этом два поперечных положения — на оси и со смещением
0.9 м к краю габарита. У сидящего человека и ящика пояс 25–40 м даёт 0.48 против
порога 0.50, и число падает с 62 до 20 м, хотя P@50 при этом не ухудшилось.
Содержательны таблицы по поясам (п. 9.2 и 9.5), а не это одно число.
Граница проходит по числу лучей: на 40–55 м человек даёт 68 лучей, чемодан 25, ведро 11,
каска 6, бутылка 3. Ниже примерно **десяти лучей предмет перестаёт отличаться от шума**
решётки, и никакая обработка этого не исправит — нужен либо более плотный сенсор, либо
подъезд ближе.
### 9.2. Почему рабочая дальность 62 м, а реальный объект виден на 98.9 %
Разброс по бэгам огромный, и он объясняет расхождение:
| Бэг (человек стоя, на оси) | 25–40 м | 40–55 м | 55–70 м | 70–90 м | лучей на 50 м |
|---|---|---|---|---|---|
| `doubleT_platform` | 1.00 | 1.00 | 1.00 | 1.00 | 68 |
| `roundT_squareT_pressureGate_squareT` | 1.00 | 1.00 | 1.00 | 0.67 | 68 |
| `squareT_platform_squareT_switch` | 0.00 | 0.18 | 1.00 | 1.00 | 56 |
| `roundT_pressureGate_roundT` | 0.73 | 0.00 | 0.00 | 0.00 | 65 |
| `roundT_doubleT` | 0.27 | 0.00 | 0.00 | 0.00 | 32 |
Не «плохо везде понемногу», а **идеально на одних участках и слепо на других**, причём
при 65 лучах на предмете. То есть дело не в видимости и не в размере.
> Таблица снята до накопления в координатах пути (п. 7.3) и до разреза по контрасту
> (п. 9.5). Актуальные цифры по тем же бэгам — в п. 9.5: `roundT_pressureGate_roundT`
> из полностью слепого за 40 м стал 0.45 / 1.00 / 0.57 на 40–90 м,
> `roundT_doubleT` за 40 м слепым остался.
### 9.3. Найденная причина слепоты: связная компонента течёт вдоль стены
Покадровый разбор провала (`roundT_pressureGate_roundT`, человек на 50 м, 65 лучей
вставлено) показал: кандидата нет вообще. В кадре всего 5 компонент, и одна из них —
**42 059 лучей, протянувшиеся по дальности от 4 до 99 м**, наполненность 0.05.
Кластеризация с разрывом по глубине объединяет соседние лучи, если их дальности
отличаются меньше чем на `0.06·R + 0.35` м. Вдоль гладкой стены тоннеля соседние лучи
отличаются на сантиметры, поэтому стена связна от ближнего поля до горизонта. Предмет,
стоящий у такой стены, попадает в ту же компоненту и **вместе с ней отбрасывается**
правилом «ни один предмет не тянется на 15 м вдоль пути».
Это не регрессия: мерж одинаков при любой верхней границе контекста, включая исходную.
**Попытка первая: разрезать по дальности.** Переглубокая компонента не выбрасывается,
а пересобирается с более строгим допуском. Предмет при этом действительно выделяется —
57 лучей, наполненность 1.00. Измеренный размен на `roundT_pressureGate_roundT`
(человек на 25…90 м) и глобально:
| Допуск разреза | Обнаружение | Ложных кадров (бэг) | Ложных треков (бэг) |
|---|---|---|---|
| выключен | 28.2 % | 0.0 % | 0 |
| 0.045 | 28.2 % | 27.2 % | 1 |
| 0.040 | 61.5 % | 21.5 % | 2 |
| **0.030** | **94.9 %** | 57.5 % | 5 |
| 0.015 | 94.9 % | 73.7 % | 16 |
| 0.006 | 94.9 % | 96.5 % | 47 |
Глобально при 0.030: ложных треков **25.8 на км против 5.2**, кадров с тревогой
**52 % против 9.4 %**. Половина кадров с тревогой — это непрерывное торможение, поэтому
разрез по умолчанию **выключен**.
Проверялось и то, можно ли отделить осколок стены от предмета по признакам: ни один
не разделяет их. Медианы (предмет / стена): ширина 0.21 / 0.20 м, наполненность
1.00 / 1.00, лучей 12 / 8, новизна 0.50 / 0.41. Строгий разрез делает стену
геометрически неотличимой от предметов — потому и цена такая. Этот вариант убран.
### 9.4. Разделение фигуры и фона по движению
Разрезать по дальности нельзя: предмет и стена рядом с ним стоят на одной дальности.
Зато они по-разному **приближаются**, и это чистая геометрия. Вдоль фиксированного луча
стена, параллельная движению, не приближается вовсе: поезд едет, точка пересечения
скользит по стене, дальность не меняется. Предмет, обращённый к поезду, приближается
ровно на пройденный путь.
Отсюда признак: `advance = (r_прошлый − r_текущий) / ds`. Ноль у фона, единица у фигуры.
Ничего перепроецировать не нужно — столбец решётки отвечает фиксированному азимуту, а
рысканье в кривой за кадр (0.06° при радиусе 1300 м) меньше шага решётки. Это тот самый
канал T4/T5 → LPTC: широкопольный поток задаёт ожидание, а что движется иначе — фигура.
Замер на вставленном человеке подтверждает физику: у предмета `advance` = 0.99…1.01,
у стены на той же дальности — 0.39…0.82 и падает по мере приближения.
Разрез по этому признаку (`Params.split_adv`, `lobula.split_by_figure`) не крошит стену:
из склеенной компоненты в 42 000 лучей остаётся 2.6–5.5 тысяч и **6–15 кандидатов**
вместо 1259 у разреза по дальности.
**Важно для честности замера.** Первая проверка дала 70 % ложных кадров, но она была
некорректной: память тоннеля обучена на кандидатах **старого** генератора, а разрез
порождает формы, которых она никогда не видела, — всё выглядит новым. После пересбора
кэша и переобучения памяти на тех же настройках (`tune_candidates_adv.npz`,
`new_data_candidates_adv.npz`, 70 273 кандидата) картина такая:
**Что это даёт — полигон:**
| | без разделения | с разделением |
|---|---|---|
| Рабочая дальность, человек стоя | 62 м | **80 м** |
| P@50 м | 0.56 | **0.71** |
| P@100 м | 0.24 | **0.36** |
| Чемодан, P@50 м | 0.57 | 0.67 |
По бэгам (человек на оси, доля кадров с обнаружением):
| Бэг | 25–40 м | 40–55 | 55–70 | 70–90 | 90–110 |
|---|---|---|---|---|---|
| `squareT_platform_squareT_switch` | 0.00 → **1.00** | 0.18 → **1.00** | 1.00 → 1.00 | 1.00 | 1.00 |
| `roundT_doubleT` | 0.27 → **0.64** | 0.00 | 0.00 | 0.00 | 0.00 |
| `roundT_pressureGate_roundT` | 0.73 → **0.80** | 0.00 | 0.00 | 0.00 | 0.00 |
| `roundT_squareT_pressureGate_squareT` | 1.00 | 1.00 | 1.00 | 0.67 → **0.89** | 0.00 |
| `doubleT_platform` | 1.00 | 1.00 | 1.00 | 1.00 | 0.25 |
**Что это стоит:**
| | без разделения | с разделением |
|---|---|---|
| Кадров с ложной тревогой | 9.4 % | 30.3 % |
| Разных ложных треков на км | **5.2** | **21.5** |
| Ложных тревог на полигоне, на кадр | 0.055 | 0.348 |
| Задержка, медиана | 31 мс | 39 мс |
| Реальный объект на 55 м | 98.9 % | 98.9 % |
**Порогом это не лечится.** Проверены значения 0.6 / 0.8 / 0.9: ложных треков
21.5 / 21.6 / 21.6 на километр, а доля кадров с тревогой только растёт (30 → 35 → 40 %).
Причина в том, что ложные срабатывания здесь — **не шум, а настоящие поверхности,
обращённые к поезду**: рамы гермозатворов, торцы, порталы. По одному признаку движения
они от предмета неотличимы, потому что физически ведут себя так же.
**Решение: разделение по умолчанию выключено** (`split_adv: 0.0`). 21.5 ложного трека
на километр — это напрасное торможение каждые 47 метров, система в таком виде
неработоспособна, и +18 м рабочей дальности этого не окупают. Два бэга из пяти
разделение к тому же не спасает: за 40 м они остаются слепыми.
**Чем это лечится по-настоящему.** Не порогом, а памятью: приближающиеся конструкции
тоннеля постоянны и повторяются от проезда к проезду. Здесь память обучена на пяти
бэгах и 20 минутах записи; на реальной линии с многими проездами грибовидное тело
подавило бы их так же, как подавляет всё остальное штатное. Это проверяемое
предсказание, а не надежда: переобучение памяти уже снизило ложные кадры с 70 % до
30 % — просто за счёт того, что она увидела эти формы один раз.
---
## 9.5. Разделение фигуры и фона по контрасту — третья попытка, и она работает
Разрез по допуску (п. 9.3) и разрез по движению (п. 9.4) вернули зрение и оба
оказались слишком дороги. Оставался третий признак фигуры, и он всё это время
уже вычислялся — просто не участвовал в сегментации.
**Физика.** Гладкая стена даёт **нулевой центр-окружение по построению**. Как бы
сильно дальность ни менялась вдоль стены, она меняется плавно: центр равен своему
окружению, и контраст равен нулю. Предмет на стене — это ступенька, и она видна.
Именно этим занята ламина, и её выход `gap` («насколько ближе окружения», м) наш
конвейер считал с самого начала — но использовал только как **признак кандидата**.
Сегментация же шла по связности с допуском по глубине, и переглубокая компонента
отбрасывалась целиком ещё до того, как признак кому-то пригождался.
**Замер разделимости** (вставленный человек, два круглых тоннеля, 55 кадров):
| Порог `gap` | Лучей предмета | Лучей фона | Компонент фона в кадре |
|---|---|---|---|
| 2 м | 90.8 % / 87.5 % | 4.15 % / 3.10 % | 24.6 / 16.3 |
| 4 м | 89.2 % / 83.4 % | 0.68 % / 0.62 % | 13.7 / 13.5 |
| **6 м** | **85.7 % / 75.1 %** | **0.13 % / 0.22 %** | **5.0 / 8.9** |
| 9 м | 80.7 % / 61.7 % | 0.02 % / 0.05 % | 0.7 / 2.6 |
Для сравнения: разрез по допуску давал 1259 кандидатов в кадре. Здесь фон
распадается на 5–9 компонент — на два порядка меньше, и это **настоящие выступы**,
которые память способна выучить, а не произвольные осколки гладкой стены.
Разделение держится до 75 м и разваливается к 90 м: доля лучей предмета, прошедших
порог 6 м, по полосам — 88 / 96 / 97 / 89 / 61 / 14 / 0 % на 15 / 30 / 45 / 60 / 75 /
90 / 105 м. Дальше 90 м кольцо окружения снова упирается в стену на той же
дальности (п. 7.3), и контраста нет.
### Почему первая версия всё равно была дорогой
Без ограничений разрез дал **9.4 ложных трека на км против 7.4** — и причина
нашлась замером, а не рассуждением. Медианы кандидатов на `roundT_doubleT`:
| признак | без разреза | с разрезом |
|---|---|---|
| протяжённость вдоль пути | 2.82 м | **1.00 м** |
| то же, дальше 55 м | 6.87 м | **0.81 м** |
| наполненность | 0.47 | 0.57 |
| контраст `gap` | 1.24 м | 3.29 м |
Разрез **отрезает фон**, и вместе с фоном исчезает протяжённость. В весе улики
(`central_complex._quality`) за неё отвечает множитель компактности
`clip(1.5 − depth/(3·span))`: при depth 6.9 м и размере 0.65 м он равен 0.1, при
depth 0.8 м — 1.0. То есть каждое наблюдение вырезанной фигуры стало весить
**вдесятеро больше**, и подавление протяжённых конструкций, работавшее до разреза,
выключилось.
Порог этого не лечит — проверено сквозным замером:
| Порог разреза | Ложных треков на км |
|---|---|
| выключен | 7.4 |
| 6 м | 9.4 |
| 9 м | 10.4 |
| 12 м | 9.4 |
### Что решило: одна фигура на компоненту
Раз каждая лишняя фигура стоит вдесятеро дороже прежнего, их число надо
ограничить. Из переглубокой компоненты выносится только **сильнейшая** фигура по
сумме превышения порога. Это тот же приём глобального торможения, которым APL
оставляет активными считанные проценты клеток Кеньона, а широкопольное торможение —
считанные колонки LC11.
| Фигур на компоненту | Ложных треков на км | Кадров с тревогой |
|---|---|---|
| без ограничения | 9.4 | 16.9 % |
| 2 | 7.4 | 15.5 % |
| **1** | **6.4** | **15.0 %** |
Дальность обнаружения при этом **не меняется вовсе**: и при одной фигуре, и при
двух, и без ограничения все три конфигурации дают одинаковые доли по полосам.
Лишние фигуры не добавляли зрения — только ложные тревоги.
Разрез вдобавок не применяется ближе 55 м (`split_near`): там покадровый тракт
видит предмет и без него (100 % на всех пяти бэгах до 70 м), а обстановки,
дающей контраст, в ближнем поле на порядок больше. Без этого ограничения 75 %
добавленных ложных треков приходили именно оттуда.
### Итог
Цифры ниже — leave-one-bag-out с пересобранным кэшем кандидатов и переобученной
памятью: без этого замер лжёт (п. 9.4).
| Конфигурация | Ложных треков на км | Кадров с тревогой | Объект 55 м |
|---|---|---|---|
| как было | 9.1 | 17.2 % | 98.9 % |
| **с разрезом по контрасту** | **7.5** | **16.7 %** | **98.9 %** |
То есть разрез не только вернул зрение там, где его не было, но и **снизил** ложные
тревоги — за счёт того, что переглубокая компонента перестала выбрасываться целиком
и вместо неё в память попадает одна осмысленная фигура, которую есть чему выучить.
Обнаружение вставленного человека на оси, доля кадров. Протокол полигона: предмет
стоит в точке тоннеля, до которой от начала записи 200 м, поезд подъезжает. Память
обучена и на этом бэге — как и во всём полигоне.
| Бэг | 15–25 м | 25–40 | 40–55 | 55–70 | 70–90 |
|---|---|---|---|---|---|
| `roundT_pressureGate_roundT` | 1.00 | 0.73 | 0.00 → **0.45** | 0.00 → **1.00** | 0.00 → **0.57** |
| `squareT_platform_squareT_switch` | 0.00 | 0.00 | 0.18 → **0.45** | 1.00 | 1.00 |
| `roundT_doubleT` | 1.00 | 0.27 | 0.00 | 0.00 | 0.00 |
**Это ровно тот бэг, который разбирался в п. 9.3.** `roundT_pressureGate_roundT` —
запись, где компонента из 42 000 лучей течёт вдоль стены от 4 до 99 м и уносит
предмет с собой. Разрез по контрасту превращает полностью слепую полосу 40–90 м в
0.45 / 1.00 / 0.57. Разрез по допуску (п. 9.3) и по движению (п. 9.4) добивались
там же 0.94 и 0.80 ценой 25.8 и 21.5 ложного трека на километр; здесь ложных треков
стало **меньше**, чем было до разреза.
**Второй слепой бэг разрез не спасает**, и причины там две, обе разобраны
покадрово в п. 9.6: за 50 м до предмета не доходит линия взгляда (98 % лучей
упираются в преграду ближе него), а на 35–52 м мешает уже наш собственный порог
`split_near`, снижать который оказалось слишком дорого.
**Осторожно с «рабочей дальностью».** Метрика в `plot_benchmark.py` идёт от ближнего
пояса и останавливается на первом, где доля падает ниже 0.5, а усредняет она два
поперечных положения сразу — на оси и со смещением 0.9 м к краю габарита. У предметов,
стоящих около порога, она поэтому скачет: у сидящего человека пояс 25–40 м даёт 0.48
против 0.50, и «рабочая дальность» падает с 62 до 20 м при том, что P@50 м
выросло с 0.58 до 0.62. Смотреть надо на таблицу по поясам, а не на одно число.
**Где разрез стоит денег — 1: холодный старт.** Всё сказанное верно, когда память
обучена. На совершенно новой линии, где памяти нет вовсе, разрез, наоборот, дорог:
21.8 → **31.8** ложных трека на километр. Это логично — он порождает кандидатов из
настоящих выступов тоннеля, и без памяти отличить их не от чего. Реальный сценарий —
именно с обученной памятью, она поставляется в образе; но если участок настолько
новый, что память к нему неприменима, `split_gap: 0.0` возвращает прежнее поведение.
**Где разрез стоит денег — 2: стоянка.** На записи со **стоящим** поездом (`doubleT_obstacle`)
он добавляет один устойчивый трек на 76.6 м, и доля кадров с посторонней тревогой
растёт с 63 % до 96 %. Это не случайность: при нулевом пройденном пути не работают
ни накопитель, ни привыкание — оба живут в координатах пути. Число разных ложных
треков на этом бэге растёт всего с 4 до 5; раздувается именно доля кадров, потому
что на стоянке ничто не уходит из поля зрения.
---
---
## 9.6. Почему `roundT_doubleT` остаётся слепым за 40 м
Разрез по контрасту (п. 9.5) открыл `roundT_pressureGate_roundT`, но второй слепой
бэг не тронул. Покадровый разбор цепочки «лучи → кандидат → улика → тревога» на
штатной постановке полигона (предмет в точке, до которой от начала записи 200 м)
показал **две разные причины в двух разных полосах дальности**. Одна физическая,
вторая — наша.
### Дальше 50 м: смотреть не на что
Считаем, сколько лучей доходит до предмета, по этапам:
| Дальность до предмета | Геометрически попали | Пережили модель сенсора | Не заслонены |
|---|---|---|---|
| 55–105 м | 29 | **29** | **1** |
| 30–55 м | 109 | 109 | 86 |
Модель сенсора не теряет **ни одного** луча: предмет крупный, дальность паспортная.
Но за 55 м **98 % лучей упираются в эхо, которое ближе предмета**. Дело не в
отражательной способности и не в числе каналов — до предмета просто не доходит
линия взгляда.
Это не артефакт постановки. Предмет пробовали ставить четырьмя способами — на
оценённую ось коридора, прямо по оси сенсора, на половину смещения и со сдвигом
+0.8 м; заслонение одинаково во всех четырёх, и дальность преграды тоже одна и та же:
| Дальность до предмета | 104 | 94 | 84 | 74 | 64 | 54 | 45 |
|---|---|---|---|---|---|---|---|
| Преграда, м | 90 | 84 | 76 | 66 | 58 | 52 | **49** |
| Лучей видно (ось коридора) | 3/17 | 1/19 | 0/25 | 0/37 | 1/41 | 8/61 | **63/89** |
Преграда приближается вместе с поездом и в какой-то момент **обгоняет** предмет:
на 45 м обзор доходит до 49 м, предмет оказывается ближе преграды — и 63 луча из 89
приходят разом. Ровно с этого места и начинается обнаружение.
Для сравнения, на том же замере `roundT_pressureGate_roundT` преграда всегда **за**
предметом (предмет на 104 м — преграда на 113 м; предмет на 56 м — преграда на 70 м),
поэтому там предмет виден, и разрез по контрасту может ему помочь.
**Почему видимость на этом перегоне такая короткая.** По бэгу в целом вдоль оси пути
видно на 106 м (медиана), но полигон ставит предмет в точку за 200 м от начала
записи, а это самое начало проезда — как раз худший его участок. Плюс общий для всех
бэгов эффект: луч, идущий вдоль пути, **скользит по полотну**, и с какой-то дальности
прирельсовая зона перестаёт наблюдаться вовсе. Нижняя наблюдаемая точка у оси пути
(5-й процентиль высоты над головкой рельса):
| Дальность | 20 м | 40 | 60 | 80 | 90 | 100 |
|---|---|---|---|---|---|---|
| `roundT_doubleT` | −0.35 | −0.32 | −0.21 | −0.07 | **+0.36** | **+0.64** |
| `roundT_pressureGate_roundT` | −0.32 | −0.32 | −0.06 | +0.18 | +0.10 | −0.11 |
| `doubleT_platform` | −0.35 | −0.36 | −0.34 | −0.06 | +0.08 | +0.35 |
За 80–90 м самое низкое, что видно у оси, находится уже **выше рельса** на 0.1–0.6 м.
Это и есть предел скользящего луча, и он объясняет, почему у мелких лежащих предметов
видимость в полигоне 25–61 %: их просто нечем осветить.
Никакой обработкой это не лечится. Лечится геометрической картой линии (п. 7.3) —
или вторым сенсором, поднятым выше.
### 35–52 м: лучи есть, кандидата нет — и это наш порог
Здесь картина обратная: лучей 83…146, а кандидата нет.
| Дальность | 41.6 | 38.7 | 35.9 | 33.3 | 30.7 | 28.3 |
|---|---|---|---|---|---|---|
| Лучей на предмете | 83 | 101 | 126 | 146 | 168 | 200 |
| Кандидат при `split_near = 55` | нет | нет | нет | нет | есть | есть |
| Кандидат при `split_near = 20` | есть | есть | есть | есть | есть | есть |
Причина — тот же мерж со стеной, что в п. 9.3, и наш собственный порог: разрез по
контрасту по умолчанию не применяется ближе 55 м. Порог был введён из соображения
«ближе покадровый тракт видит предмет и сам» — на этом бэге это неверно.
Со сниженным порогом первая тревога наступает на **37.3 м вместо 28.3 м**, а доля
кадров без кандидата в окне 20–42 м падает с 44 % до 6 %.
**Порог всё равно оставлен 55 м.** Цена снижения измерена честно — с пересбором кэша
кандидатов при `split_near = 20` и переобучением памяти на нём:
| `split_near` | Ложных треков на км | Кадров с тревогой |
|---|---|---|
| **55 м** | **7.4** | **16.7 %** |
| 30 м | 14.1 | 25.6 % |
| 20 м (память не пересобрана) | 18.1 | 28.2 % |
| 20 м (**честно**, память переобучена) | 17.1 | 27.9 % |
Переобучение на этот раз почти ничего не вернуло (18.1 → 17.1): ближние вырезанные
фигуры от препятствий действительно неотличимы. А выигрыш — девять метров на
дальности, где он ничего не меняет: ТЗ оценивает 100 / 200 / 300 м, и поезд на
50 км/ч не останавливается ни за 28, ни за 37 м. Платить за это 2.3-кратным ростом
ложных тревог нельзя.
`split_near: 20.0` в конфиге доступен для линии, где обстановка беднее и ложные
тревоги дешевле.
---
## 10. Новый участок: привыкание внутри проезда — отрицательный результат
Обученная память отвечает на вопрос «этот тоннель я уже видел». На новом участке
она бесполезна по определению, и это измеренная величина, а не абстрактный риск:
| | Ложных треков на км | Кадров с тревогой |
|---|---|---|
| память обучена на других бэгах (leave-one-bag-out) | 9.1 | 17.2 % |
| **памяти нет вовсе (новая линия)** | **21.8** | **33.1 %** |
Приватный тест — это как раз новый участок, поэтому вопрос важный. Механизм был
сделан, доведён до работы и **отвергнут по результатам замера**. Ниже — почему,
потому что отрицательный результат здесь содержательнее положительного.
### Замысел
**Тоннельная обстановка повторяется вдоль пути, а посторонний предмет — нет.**
Кабельный кронштейн, рама крепи, стык тюбингов встречаются каждые несколько метров
в одном и том же виде; разбор худшего бэга (п. 5.1) прямо это показал — пять из
восьми ложных треков были колоннами, повторяющимися каждые 13–20 м. Упавший же
предмет лежит в одном месте.
Отсюда привыкание, считающее не по времени и не по числу кадров, а по **числу
разных точек пути**, где встретилась эта форма. Настоящий предмет виден сто кадров
подряд, но всё это время стоит в одной точке мира: его код депрессируется один раз
и остаётся новым до конца подъезда. Биологически это familiarity suppression
MBON-α′3, а момент депрессии разрешает координата пути из центрального комплекса —
роль, которую у мухи играют дофаминергические PPL1/PAM.
### Что пришлось починить, чтобы механизм вообще заработал
Обе ошибки найдены замером и сами по себе поучительны.
**Полный набор признаков не годится.** Первая версия кодировала кандидата тем же
дескриптором, что и долговременная память. За двенадцать разных мест новизна упала
с 1.000 до 0.981 — то есть ни на что. В дескрипторе есть дальность, число лучей,
угловой размер, интенсивность: код одного и того же кронштейна с 90 и с 40 м
разъезжается, повторы не узнаются, а подъезжающий предмет каждый кадр выглядит
новой формой и привыкает сам к себе. Переведено на десять признаков формы, не
зависящих от дальности.
**Нормировка обязана замирать.** Пока среднее и разброс пересчитываются, вместе с
ними плывёт код. У неподвижного предмета, чьи признаки формы не меняются вовсе,
набор активных клеток обновлялся настолько, что предмет засчитывался как
**двадцать шесть разных мест** и гасил сам себя. При пороге заморозки 4000
кандидатов эффект оставался живуч: в `roundT_doubleT` (около 570 кандидатов за
проезд) нормировка не замирала никогда, привыкание стояло на 0.20, медианная
новизна кандидата 1.00, и ложных треков было ровно столько же, сколько без него.
Порог снижен до 300.
### Почему всё равно отвергнуто
После починок механизм начал давать цифры: leave-one-bag-out 7.5 → **5.9** ложных
трека на км, новая линия 31.8 → **26.8**. Выглядело как успех — до проверки
обратной стороны.
**Привыкание глушило сам предмет.** Замер на вставленных предметах
(`doubleT_platform`, подъезд с 200 м):
| | Новизна кандидата, 20–60 м | Обнаружение, 70–90 м |
|---|---|---|
| привыкание выключено | 0.75 | 95 % |
| привыкание включено | **0.24** | **74 %** |
И это не «предмет заодно с обстановкой»: медианная новизна обычного кандидата в тех
же прогонах — 0.43…0.71, то есть **предмет подавлялся сильнее, чем тоннельная
обстановка**. Избирательность не просто мала, она отрицательна.
**Причина — насыщение популяции, а не узнавание повторов.** За проезд набирается
около 270 событий депрессии по 80 клеток каждое; при популяции 4000 это 5.4 попадания
на клетку, то есть подавлено всё. Ровно та же ошибка, что описана в п. 8 для
долговременной памяти с мушиными параметрами, — и проверяется она так же, поднятием
ёмкости:
| Ёмкость короткой памяти | Ложных треков на км (LOO) | Новизна предмета | Обнаружение 70–90 м |
|---|---|---|---|
| привыкание выключено | 7.5 | 0.75 | 95 % |
| 4 000 клеток | **5.9** | **0.24** | **74 %** |
| 20 000 клеток | 7.5 | 0.47 | 95 % |
| 50 000 клеток | 7.5 | 0.64 | 95 % |
При ёмкости, достаточной чтобы не насыщаться, привыкание не меняет **ничего**: 7.5
против 7.5 на обученной памяти и 31.8 против 31.8 на новой линии. Весь видимый
выигрыш был глобальным глушением — а его и так даёт порог решения, который у нас
уже есть отдельным параметром.
Пробовались два способа сделать отсчёт избирательным, оба измерены и оба ничего не
дали:
* **резкий отсчёт MBON**: вместо среднего по активным клеткам — квантиль 0.75 или
0.90, то есть «знакомо, только если подавлено не меньше трёх четвертей кода».
Новизну предмета это вернуло (0.75), но и весь эффект тоже (7.5 → 7.5);
* **огрубление признаков** (coarse coding) до половины и до целого разброса, чтобы
два похожих кронштейна давали буквально один и тот же код: 7.5 / 7.5 / 7.5.
### Что это на самом деле означает
Коды двух разных экземпляров одной и той же конструкции не перекрываются настолько,
чтобы второй узнавал первого, даже после огрубления до целого стандартного
отклонения. Проще говоря: **в этих данных штатная обстановка повторяется не так
буквально, как предполагалось**. Разброс между экземплярами одной конструкции —
по ракурсу, по числу лучей, по тому, какая часть попала в габарит — больше, чем
разрешение любого разумного кода формы.
Поэтому на новом участке работает то, что и работало: долговременная память,
обученная на других участках, снижает ложные тревоги с 21.8 до 9.1 трека на
километр. Признаки в дескрипторе намеренно смешаны — форма и угловой размер
переносятся на любой тоннель, положение в сечении запоминает конкретную обстановку,
и именно первая половина даёт этот перенос.
**Механизм оставлен в коде и выключен** (`enable_habituation: false`) со всеми
параметрами: ёмкость, квантиль отсчёта, огрубление, шаг «разных мест», путь
полузабывания. На линии, где обстановка стандартизована сильнее, чем в этих записях,
он может заработать — но проверять это надо замером на той линии, а не надеждой.

16
flyguard/__init__.py Normal file
View file

@ -0,0 +1,16 @@
"""FlyGuard — обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара.
Архитектура повторяет вычислительные схемы зрительной системы и грибовидных тел
Drosophila melanogaster, взятые из коннектома (FlyWire / hemibrain):
retina омматидиальная решётка → дальностный образ
stabilizer жужжальца / оцеллии → стабилизация «взгляда»
lamina L1/L2, ON/OFF, center-surround→ локальный контраст
medulla T4/T5, EMD-корреляторы → оптический поток
lobula_plate LPTC (HS/VS), LPLC2 → эго-движение, looming
mushroom_body KC + APL + MBON → новизна / знакомость
central_complex кольцевой аттрактор (EB) → накопление улик, треки
descending Giant Fiber, DNp → решение
"""
__version__ = "1.0.0"

133
flyguard/bag.py Normal file
View file

@ -0,0 +1,133 @@
"""Чтение rosbag2 (storage sqlite3) без установленного ROS.
Поддерживает многошардовые бэги (`new_data` — 221 файл `*.db3`), произвольные
имена топиков и порядок шардов по числовому суффиксу. Метаданные `metadata.yaml`
не требуются: список топиков берётся из самой БД, что снимает зависимость от PyYAML.
"""
from __future__ import annotations
import re
import sqlite3
from contextlib import closing
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
from .cdr import PointCloud2, parse_pointcloud2
_SHARD_RE = re.compile(r"_(\d+)\.db3$")
_POINTCLOUD_TYPE = "sensor_msgs/msg/PointCloud2"
@dataclass(frozen=True)
class BagTopic:
name: str
type: str
count: int
class Bag:
"""Последовательное чтение облаков точек из rosbag2."""
def __init__(self, path: str | Path, topic: str | None = None):
path = Path(path)
if path.is_dir():
shards = sorted(path.glob("*.db3"), key=self._shard_key)
elif path.suffix == ".db3":
shards = [path]
else:
raise FileNotFoundError(f"не бэг и не .db3: {path}")
if not shards:
raise FileNotFoundError(f"в {path} нет файлов *.db3")
self.path = path
self.shards = shards
self.topics = self._scan_topics()
self.topic = topic or self._pick_topic()
@staticmethod
def _shard_key(p: Path) -> tuple[int, str]:
m = _SHARD_RE.search(p.name)
return (int(m.group(1)) if m else 0, p.name)
def _scan_topics(self) -> dict[str, BagTopic]:
found: dict[str, BagTopic] = {}
for shard in self.shards:
with closing(self._connect(shard)) as con:
rows = con.execute(
"SELECT t.name, t.type, count(m.id) FROM topics t "
"LEFT JOIN messages m ON m.topic_id = t.id GROUP BY t.id"
).fetchall()
for name, type_, count in rows:
prev = found.get(name)
found[name] = BagTopic(name, type_, (prev.count if prev else 0) + count)
return found
def _pick_topic(self) -> str:
clouds = [t for t in self.topics.values() if t.type == _POINTCLOUD_TYPE]
if not clouds:
raise ValueError(f"в {self.path} нет топиков {_POINTCLOUD_TYPE}: "
f"{sorted(self.topics)}")
# при нескольких облачных топиках берём самый наполненный
return max(clouds, key=lambda t: t.count).name
@staticmethod
def _connect(shard: Path) -> sqlite3.Connection:
return sqlite3.connect(f"file:{shard.as_posix()}?mode=ro&immutable=1", uri=True)
def __len__(self) -> int:
return self.topics[self.topic].count
def frames(self, start: int = 0, stop: int | None = None,
stride: int = 1) -> Iterator[tuple[int, PointCloud2]]:
"""Выдать (timestamp_ns, облако) для сообщений выбранного топика.
Индексация сквозная по всему бэгу; шарды читаются по порядку, внутри
шарда — по возрастанию времени.
"""
idx = 0
for shard in self.shards:
# closing(), а не сам connection: у sqlite3 `with` управляет
# транзакцией и файл остаётся открытым — на Windows его потом
# невозможно удалить
with closing(self._connect(shard)) as con:
row = con.execute("SELECT id FROM topics WHERE name = ?",
(self.topic,)).fetchone()
if row is None:
continue
topic_id = row[0]
n_here = con.execute(
"SELECT count(*) FROM messages WHERE topic_id = ?", (topic_id,)
).fetchone()[0]
if stop is not None and idx >= stop:
return
if idx + n_here <= start:
idx += n_here
continue
cur = con.execute(
"SELECT timestamp, data FROM messages WHERE topic_id = ? "
"ORDER BY timestamp", (topic_id,))
for ts, blob in cur:
if stop is not None and idx >= stop:
return
if idx >= start and (idx - start) % stride == 0:
yield ts, parse_pointcloud2(blob)
idx += 1
def describe(self) -> str:
lines = [f"бэг: {self.path}",
f"шардов: {len(self.shards)}",
f"топик: {self.topic}"]
for t in sorted(self.topics.values(), key=lambda t: -t.count):
mark = "*" if t.name == self.topic else " "
lines.append(f" {mark} {t.name} [{t.type}] {t.count} сообщений")
return "\n".join(lines)
def find_bags(root: str | Path) -> list[Path]:
"""Найти все каталоги-бэги под указанным корнем."""
root = Path(root)
if not root.exists():
return []
out = {p.parent for p in root.rglob("*.db3")}
return sorted(out)

130
flyguard/cdr.py Normal file
View file

@ -0,0 +1,130 @@
"""Разбор sensor_msgs/msg/PointCloud2 из CDR без зависимости от ROS.
Нужен для двух сценариев:
* офлайн-эксперименты на машине без ROS (Windows);
* прямое чтение rosbag внутри контейнера, минуя `ros2 bag play`.
Внутри ROS-ноды сообщение приходит уже разобранным, и этот модуль не используется.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass
import numpy as np
# sensor_msgs/msg/PointField: код типа -> (numpy dtype, размер в байтах)
_PF_DTYPES = {
1: ("i1", 1), 2: ("u1", 1), 3: ("i2", 2), 4: ("u2", 2),
5: ("i4", 4), 6: ("u4", 4), 7: ("f4", 4), 8: ("f8", 8),
}
@dataclass(frozen=True)
class PointCloud2:
"""Минимальное представление облака точек."""
stamp: float
frame_id: str
height: int
width: int
point_step: int
is_dense: bool
points: np.ndarray # структурированный массив длиной height*width
@property
def n_points(self) -> int:
return int(self.points.shape[0])
class _CdrReader:
"""Чтение little-endian CDR с выравниванием примитивов относительно тела сообщения."""
__slots__ = ("buf", "origin", "pos")
def __init__(self, buf: bytes | memoryview):
self.buf = buf
self.origin = 4 # заголовок инкапсуляции
self.pos = 4
def _align(self, size: int) -> None:
self.pos += (-(self.pos - self.origin)) % size
def u8(self) -> int:
v = self.buf[self.pos]
self.pos += 1
return v
def u32(self) -> int:
self._align(4)
v = struct.unpack_from("<I", self.buf, self.pos)[0]
self.pos += 4
return v
def i32(self) -> int:
self._align(4)
v = struct.unpack_from("<i", self.buf, self.pos)[0]
self.pos += 4
return v
def string(self) -> str:
n = self.u32()
s = bytes(self.buf[self.pos:self.pos + max(n - 1, 0)]).decode("utf-8", "replace")
self.pos += n
return s
def point_dtype(fields: list[tuple[str, int, int, int]], point_step: int) -> np.dtype:
"""Собрать numpy-dtype по описанию полей, явно добивая пропуски паддингом.
Поля лидара невыровнены (`timestamp` float64 по смещению 18), поэтому
структурированный dtype строится вручную, а не через `np.dtype(align=True)`.
"""
spec: list[tuple[str, str]] = []
used = 0
for name, offset, datatype, count in fields:
kind, size = _PF_DTYPES[datatype]
if offset > used:
spec.append((f"_pad{used}", f"V{offset - used}"))
elif offset < used:
raise ValueError(f"перекрывающиеся поля в PointCloud2: {name}")
spec.append((name, kind if count == 1 else f"{count}{kind}"))
used = offset + size * count
if point_step > used:
spec.append((f"_pad{used}", f"V{point_step - used}"))
dt = np.dtype(spec)
if dt.itemsize != point_step:
raise ValueError(f"dtype {dt.itemsize} байт != point_step {point_step}")
return dt
def parse_pointcloud2(blob: bytes | memoryview) -> PointCloud2:
"""Разобрать CDR-сериализованное sensor_msgs/msg/PointCloud2."""
r = _CdrReader(blob)
sec = r.i32()
nsec = r.u32()
frame_id = r.string()
height = r.u32()
width = r.u32()
fields = []
for _ in range(r.u32()):
name = r.string()
offset = r.u32()
datatype = r.u8()
count = r.u32()
fields.append((name, offset, datatype, count))
r.u8() # is_bigendian: в данных всегда 0, little-endian
point_step = r.u32()
r.u32() # row_step
n_bytes = r.u32()
data = memoryview(blob)[r.pos:r.pos + n_bytes]
r.pos += n_bytes
is_dense = bool(r.u8())
dt = point_dtype(fields, point_step)
points = np.frombuffer(data, dtype=dt, count=height * width)
return PointCloud2(stamp=sec + nsec * 1e-9, frame_id=frame_id, height=height,
width=width, point_step=point_step, is_dense=is_dense,
points=points)

236
flyguard/central_complex.py Normal file
View file

@ -0,0 +1,236 @@
"""CENTRAL COMPLEX — накопление улик и треки в мировой системе координат.
Эллипсоидное тело мухи держит **кольцевой аттрактор**: клетки EPG образуют один
«бугор» активности, кодирующий текущий курс, клетки PEN сдвигают этот бугор по
сигналам собственного вращения, а взаимное торможение не даёт возникнуть второму
бугру. Так муха помнит направление, даже когда ориентир пропал из виду.
Здесь тот же механизм решает другую задачу. Кандидат на дальности 150 м — это
пять-десять лучей, и по одному кадру он неотличим от шума. Но поезд едет, и
объект, если он настоящий, остаётся **на одном и том же месте в тоннеле**, а не в
поле зрения. Поэтому треки живут в координате «расстояние по пути от точки
старта», сдвигаемой оценкой собственного движения (роль PEN), каждое совпадение
подкачивает улику (локальное возбуждение), несовпадение — утечка, а конкуренция
за одно и то же место не даёт плодить дубликаты (глобальное торможение APL/Δ7).
Это и есть подтверждение по нескольким кадрам, которого требует ТЗ, — но не как
эвристический фильтр, а как накопитель, который вытягивает слабый сигнал из шума.
"""
from __future__ import annotations
import itertools
from dataclasses import dataclass, field
import numpy as np
from .lobula import Candidate
@dataclass
class Track:
"""Подтверждаемая гипотеза о препятствии."""
id: int
s_world: float # положение вдоль пути от начала записи, м
u: float # смещение от оси пути, м
h: float # высота над рельсом, м
width: float
height: float
evidence: float = 0.0 # накопленная улика, 0…1
hits: int = 0
misses: int = 0
age: int = 0
first_d: float = 0.0 # на какой дальности впервые замечен
last_d: float = 0.0
last_n_rays: int = 0
novelty: float = 1.0
speed_lat: float = 0.0 # поперечная скорость, м/с
history: list = field(default_factory=list)
@property
def confirmed(self) -> bool:
return self.evidence >= 0.5
def distance(self, s_now: float) -> float:
return self.s_world - s_now
class CentralComplex:
"""Накопитель улик и менеджер треков."""
def __init__(self, *, gate_d: float = 4.0, gate_u: float = 1.2,
gain: float = 0.34, leak: float = 0.12,
inhibition: float = 0.05, max_misses: int = 12,
max_tracks: int = 48, use_shape: bool = True,
mbon_power: float = 1.0, mbon_blend: float = 1.0):
self.use_shape = use_shape
self.mbon_power = mbon_power
self.mbon_blend = mbon_blend
self.gate_d = gate_d
self.gate_u = gate_u
self.gain = gain
self.leak = leak
self.inhibition = inhibition
self.max_misses = max_misses
self.max_tracks = max_tracks
self.tracks: list[Track] = []
self.s_world = 0.0
self._ids = itertools.count(1)
# ------------------------------------------------------------------ обновление
def update(self, candidates: list[Candidate], ds: float, dt: float) -> list[Track]:
"""Сдвинуть мир на `ds`, сопоставить кандидатов, обновить улики."""
self.s_world += ds
# допуск по дальности растёт с расстоянием: там и разрешение грубее,
# и ошибка оценки собственного движения успевает накопиться
for t in self.tracks:
t.age += 1
used = set()
for t in self.tracks:
d_pred = t.distance(self.s_world)
best, best_cost = None, None
for k, c in enumerate(candidates):
if k in used:
continue
gd = self.gate_d + 0.05 * max(c.d, 0.0)
dd = abs(c.d - d_pred)
du = abs(c.u - t.u)
if dd > gd or du > self.gate_u + 0.4:
continue
cost = dd / gd + du / (self.gate_u + 0.4)
if best_cost is None or cost < best_cost:
best, best_cost = k, cost
if best is None:
t.misses += 1
t.evidence = max(0.0, t.evidence - self.leak)
continue
c = candidates[best]
used.add(best)
t.hits += 1
t.misses = 0
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend)
t.evidence = min(1.0, t.evidence + self.gain * w)
if dt > 1e-3:
t.speed_lat = 0.6 * t.speed_lat + 0.4 * (c.u - t.u) / dt
# положение сглаживается: ближние наблюдения точнее дальних
a = float(np.clip(0.5 * (60.0 / max(c.d, 20.0)), 0.15, 0.6))
t.s_world = (1 - a) * t.s_world + a * (self.s_world + c.d)
t.u = (1 - a) * t.u + a * c.u
t.h = (1 - a) * t.h + a * c.h
t.width = max(t.width * 0.7, c.width)
t.height = max(t.height * 0.7, c.height)
t.last_d = c.d
t.last_n_rays = c.n_rays
t.novelty = 0.7 * t.novelty + 0.3 * c.novelty
t.history.append((c.d, c.u, c.n_rays))
if len(t.history) > 64:
del t.history[:-64]
# новые гипотезы из несопоставленных кандидатов
for k, c in enumerate(candidates):
if k in used or len(self.tracks) >= self.max_tracks:
continue
t = Track(id=next(self._ids), s_world=self.s_world + c.d, u=c.u, h=c.h,
width=c.width, height=c.height, first_d=c.d, last_d=c.d,
last_n_rays=c.n_rays, novelty=c.novelty)
t.evidence = self.gain * _quality(c, self.use_shape,
self.mbon_power, self.mbon_blend)
t.hits = 1
self.tracks.append(t)
# глобальное торможение: сильный трек подавляет соседей по месту
self._inhibit()
self.tracks = [t for t in self.tracks
if t.misses <= self.max_misses and t.evidence > 0.02
and t.distance(self.s_world) > -5.0]
self.tracks.sort(key=lambda t: -t.evidence)
del self.tracks[self.max_tracks:]
return self.tracks
def _inhibit(self) -> None:
if len(self.tracks) < 2 or self.inhibition <= 0:
return
order = sorted(self.tracks, key=lambda t: -t.evidence)
for i, strong in enumerate(order):
for weak in order[i + 1:]:
if (abs(strong.s_world - weak.s_world) < self.gate_d
and abs(strong.u - weak.u) < self.gate_u):
weak.evidence = max(0.0, weak.evidence - self.inhibition)
def confirmed(self) -> list[Track]:
out = [t for t in self.tracks if t.confirmed and t.distance(self.s_world) > 0]
out.sort(key=lambda t: t.distance(self.s_world))
return out
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
mbon_blend: float = 1.0) -> float:
"""Вес одного наблюдения: сколько улики оно добавляет.
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
так устроена решётка, — поэтому число лучей нормируется на ожидаемое для
этой дальности. Остальные множители отделяют предмет от конструкции тоннеля:
предмет целиком помещается в габарит и компактен вдоль пути, а лоток или
стена тянутся дальше и в стороны. Новизна из грибовидного тела входит сюда
же множителем.
"""
expected = max(3.0, 2500.0 / max(c.d, 5.0) ** 1.4)
support = float(np.clip(c.n_rays / expected, 0.25, 1.0))
# Контраст к фону — главная улика вблизи и недоступная вдали. На 170 м
# кольцо окружения ламины упирается в стену тоннеля, стоящую на той же
# дальности, и `gap` структурно равен нулю: предмет не «ближе окружения»,
# он «на той же дальности, что окружение». Замер это подтверждает —
# у вставленного человека на 140…185 м gap = 0.0 во ВСЕХ кадрах.
#
# Там, где контраст не измеряется, его заменяет опора веерного тела:
# возвращались ли лучи из этой точки пути кадр за кадром. Берётся
# максимум — вблизи решает контраст, вдали накопление.
contrast = float(np.clip(c.gap / 3.0, 0.2, 1.0))
acc = float(c.extra.get("acc_support", 0.0)) if c.extra else 0.0
if acc > 0.0:
contrast = max(contrast, float(np.clip(acc, 0.2, 1.0)))
if use_shape:
whole = float(np.clip((c.containment - 0.25) / 0.45, 0.05, 1.0))
# протяжённость вдоль пути сверх собственного размера — признак конструкции
span = max(c.width, c.height, 0.2)
compact = float(np.clip(1.5 - c.depth / (3.0 * span), 0.1, 1.0))
# опора снизу: упавший предмет, человек, камень стоят на полотне, а знак,
# лоток или кронштейн висят на стене, и под ними пусто
grounded = float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))
else:
whole = compact = grounded = 1.0
novel = novelty_gain(c.novelty)
hand = support * contrast * whole * compact * grounded
# Обученное считывание MBON, если оно есть. Модель видит те же признаки,
# что и шесть множителей выше, плюс тень и интенсивность, которых в ручной
# формуле нет вовсе. Знакомость в неё НЕ входит и остаётся отдельным
# каналом: физику решает модель, конкретный тоннель — память.
#
# `mbon_blend` — геометрическое смешивание с ручной формулой: 1 — только
# модель, 0 — только руками, между ними всё промежуточное. Нужно затем,
# чтобы размен «модель против ручной формулы» мерился, а не объявлялся.
p = c.extra.get("mbon") if c.extra else None
if p is not None and mbon_blend > 0.0:
pm = float(np.clip(p, 1e-4, 1.0)) ** max(mbon_power, 1e-3)
b = float(np.clip(mbon_blend, 0.0, 1.0))
hand = pm ** b * max(hand, 1e-4) ** (1.0 - b)
return float(np.clip(hand * novel, 0.0, 1.0))
def novelty_gain(novelty: float, lo: float = 0.15, hi: float = 0.50,
floor: float = 0.05) -> float:
"""Ответ MBON → множитель улики.
Пороги взяты по измеренному разделению (`tools/tune_memory.py`): знакомая
обстановка даёт новизну около 0.14, реальный объект — около 0.50. Отображение
делает разницу резкой, но оставляет ненулевой пол: даже похожий на привычную
конструкцию предмет должен накапливать улику, просто медленнее.
"""
return float(np.clip((novelty - lo) / (hi - lo), floor, 1.0))

111
flyguard/descending.py Normal file
View file

@ -0,0 +1,111 @@
"""DESCENDING NEURONS — решение.
Весь разбор сцены у мухи сходится на нескольких десятках нисходящих нейронов,
идущих в грудной ганглий. Два из них работают как раз по надвигающемуся объекту:
* **Giant Fiber (DNp01)** — один толстый аксон с высоким порогом. Срабатывает
только на близкое и быстрое надвигание и запускает немедленный аварийный
взлёт, жертвуя устойчивостью ради скорости.
* **DNp02/DNp11** — порог ниже, реакция раньше и мягче: муха успевает
подготовиться, не срываясь в паническое движение.
Поезду нужна ровно такая же пара уровней: заблаговременное предупреждение с
запасом по дальности и экстренное торможение по надёжному близкому объекту.
Гистерезис здесь — не украшение: без него трек на пороге даёт дребезг, а
дребезжащая команда торможения хуже её отсутствия.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .central_complex import CentralComplex, Track
BRAKING_DECEL = 1.0 # м/с², служебное торможение метропоезда (оценка)
REACTION_TIME = 1.5 # с, задержка канала «решение → тормоз»
@dataclass
class DetectedObject:
distance: float
lateral: float
height: float
width: float
size_v: float
confidence: float
novelty: float
n_rays: int
track_id: int
ttc: float
@dataclass
class Decision:
"""Выход системы за один кадр."""
detected: bool = False
emergency: bool = False
distance: float = float("inf")
ttc: float = float("inf")
confidence: float = 0.0
stopping_distance: float = 0.0
objects: list[DetectedObject] = field(default_factory=list)
speed: float = 0.0
@property
def clear(self) -> bool:
return not self.detected
class DescendingNeurons:
"""Два порога с гистерезисом поверх подтверждённых треков."""
def __init__(self, *, warn_evidence: float = 0.5, clear_evidence: float = 0.3,
emergency_evidence: float = 0.75, min_hits: int = 3,
novelty_floor: float = 0.10, max_range: float = 200.0):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence
self.emergency_evidence = emergency_evidence
self.min_hits = min_hits
self.novelty_floor = novelty_floor
self.max_range = max_range
self._latched: set[int] = set()
def decide(self, cx: CentralComplex, speed: float) -> Decision:
out = Decision(speed=speed)
stop = speed * REACTION_TIME + speed * speed / (2 * BRAKING_DECEL)
out.stopping_distance = stop
live: list[tuple[Track, float]] = []
for t in cx.tracks:
d = t.distance(cx.s_world)
if not (0.0 < d <= self.max_range):
self._latched.discard(t.id)
continue
# гистерезис: попавший в тревогу трек держится до нижнего порога
on = self.warn_evidence if t.id not in self._latched else self.clear_evidence
if t.evidence < on or t.hits < self.min_hits or t.novelty < self.novelty_floor:
self._latched.discard(t.id)
continue
self._latched.add(t.id)
live.append((t, d))
if not live:
return out
live.sort(key=lambda p: p[1])
for t, d in live:
ttc = d / speed if speed > 0.5 else float("inf")
out.objects.append(DetectedObject(
distance=d, lateral=t.u, height=t.h, width=t.width, size_v=t.height,
confidence=min(1.0, t.evidence * t.novelty + 0.0),
novelty=t.novelty, n_rays=t.last_n_rays, track_id=t.id, ttc=ttc))
nearest, d0 = live[0]
out.detected = True
out.distance = d0
out.ttc = d0 / speed if speed > 0.5 else float("inf")
out.confidence = max(o.confidence for o in out.objects)
out.emergency = any(
t.evidence >= self.emergency_evidence and (d <= max(stop, 25.0))
for t, d in live)
return out

180
flyguard/fan_body.py Normal file
View file

@ -0,0 +1,180 @@
"""FAN-SHAPED BODY — накопление слабых улик в координатах пути.
Покадровое решение проваливается там, где предмет даёт единицы лучей. На 170 м
человек освещён **в каждом** кадре, но всего пятью лучами: формы из них не
построить, а локальный контраст на такой дальности обнуляется — кольцо
окружения ламины упирается в стену тоннеля, которая на той же дальности.
Зато предмет неподвижен в мире, а тоннель проплывает мимо: за проход одни и те
же пять лучей попадают в одну и ту же точку пространства семнадцать раз подряд.
Именно так устроено веерное тело центрального комплекса мухи: оно копит
вектор к цели в координатах, привязанных к миру, а не к текущему кадру, и
достаёт из слабого повторяющегося сигнала то, чего нет ни в одном отдельном
наблюдении.
Здесь то же самое буквально: сетка, привязанная к пройденному пути, в которую
кадр за кадром складываются лучи из габарита. Сетка сдвигается на пройденное
расстояние, поэтому неподвижный предмет всегда попадает в одну ячейку, а шум
и случайные отражения размазываются.
Своих кандидатов накопитель не порождает — это проверено и отвергнуто
измерением: по геометрии накопленное скопление предмета и накопленный кусок
конструкции тоннеля неразличимы (совпадают все десять признаков, от дальности
до протяжённости). Разделяет их только память тоннеля, а ей нужны признаки
кадра, которых у скопления нет.
Роль накопителя другая и точная: он отвечает про **уже найденного покадрового
кандидата**, возвращались ли лучи из этой точки мира кадр за кадром. На
больших дальностях это единственная доступная улика — локальный контраст там
структурно равен нулю, потому что кольцо окружения ламины упирается в стену
тоннеля, стоящую на той же дальности.
Стоимость — доли миллисекунды: пара тысяч лучей в `bincount` по сетке из
пятнадцати тысяч ячеек.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy import ndimage
# Шаг сетки вдоль пути крупный намеренно: оценка собственного движения копит
# ошибку около 3 %, за семнадцать кадров это уже 0.7 м. Мелкая ячейка размазала
# бы предмет по соседям и убила весь смысл накопления. Для отчёта о дистанции
# 2 м всё равно на порядок точнее допуска (12 % дальности).
DS_BIN = 2.0 # м вдоль пути
DU_BIN = 0.20 # м поперёк
DH_BIN = 0.25 # м по высоте
DECAY = 0.97 # забывание: полураспад около 23 кадров
MIN_FRAMES = 6 # меньше — это вспышка, а не предмет
MAX_SPAN_S = 8.0 # м: длиннее — это стена или лоток, а не предмет
MAX_WIDTH = 1.5 # м: шире — край габарита, а не предмет
BG_WIN_M = 24.0 # м: окно оценки фона вдоль пути
@dataclass
class Accumulated:
"""Скопление попаданий в одной точке пути."""
d: float
u: float
h: float
h_min: float
width: float
height: float
span_s: float
hits: float
frames: float
support: float # попаданий относительно ожидаемого для этой дальности
class FanBody:
"""Сетка в координатах пути, копящая лучи из габарита."""
def __init__(self, *, d_max: float = 220.0, half_width: float = 1.6,
h_lo: float = 0.28, h_hi: float = 2.3,
d_near: float = 55.0, min_support: float = 0.8,
ref_rays: float = 1.4, ref_d: float = 175.0):
self.ds, self.du, self.dh = DS_BIN, DU_BIN, DH_BIN
self.h_lo, self.h_hi = h_lo, h_hi
self.half_width = half_width
self.d_near = d_near # ближе этого покадровый тракт и так справляется
self.min_support = min_support
# Сколько попаданий ждать от настоящего предмета на данной дальности.
# Калибровано по замеру: человек, вставленный в реальный проезд, даёт
# после вычитания фона и разброса по ячейкам эквивалент 1.4 луча в
# кадре на 175 м (пять лучей приходят, но часть уходит в фон и в
# соседние ячейки). Число лучей падает как 1/R², накопление держит
# около 1/(1−DECAY) кадров.
self.k_expect = ref_rays * ref_d ** 2 / (1.0 - DECAY)
self.n_s = int(np.ceil(d_max / self.ds)) + 1
self.n_u = int(np.ceil(2 * half_width / self.du)) + 1
self.n_h = int(np.ceil((h_hi - h_lo) / self.dh)) + 1
self.hits = np.zeros((self.n_s, self.n_u, self.n_h), np.float32)
self.seen = np.zeros((self.n_s, self.n_u), np.float32)
self._off = 0.0 # смещение начала сетки внутри ячейки, м
# ------------------------------------------------------------------ такт
def update(self, d: np.ndarray, u: np.ndarray, h: np.ndarray, ds: float) -> None:
"""Сдвинуть сетку на пройденное `ds` и досыпать лучи текущего кадра."""
self._advance(ds)
if d.size == 0:
return
si = ((self._off + d) / self.ds).astype(np.int32)
ui = ((u + self.half_width) / self.du).astype(np.int32)
hi = ((h - self.h_lo) / self.dh).astype(np.int32)
ok = ((si >= 0) & (si < self.n_s) & (ui >= 0) & (ui < self.n_u)
& (hi >= 0) & (hi < self.n_h))
if not ok.any():
return
flat = (si[ok] * self.n_u + ui[ok]) * self.n_h + hi[ok]
self.hits += np.bincount(flat, minlength=self.hits.size).reshape(self.hits.shape)
# «Кадров подряд» считается по столбцу, а не по ячейке высоты: предмет
# может качнуться на четверть метра, и это не повод обнулять счёт.
col = np.unique(si[ok] * self.n_u + ui[ok])
self.seen.ravel()[col] += 1.0
def _advance(self, ds: float) -> None:
self.hits *= DECAY
self.seen *= DECAY
if ds <= 0:
return
self._off += ds
k = int(self._off // self.ds)
if k <= 0:
return
self._off -= k * self.ds
if k >= self.n_s:
self.hits[:] = 0.0
self.seen[:] = 0.0
return
self.hits[:-k] = self.hits[k:]
self.hits[-k:] = 0.0
self.seen[:-k] = self.seen[k:]
self.seen[-k:] = 0.0
# ------------------------------------------------------------------ съём
def support_at(self, d: np.ndarray, u: np.ndarray) -> np.ndarray:
"""Опора накопителя в точках пути: во сколько раз попаданий больше фона.
Своих кандидатов накопитель не порождает намеренно. Замер показал, что
по геометрии накопленное скопление предмета и накопленный кусок
конструкции тоннеля неразличимы: совпадают дальность, смещение, высота,
ширина, протяжённость — всё. Разделяет их только память тоннеля, а она
работает с признаками кадра (контраст, интенсивность, тень), которых у
скопления нет.
Поэтому накопитель отвечает на один вопрос про уже найденного
покадрового кандидата: **возвращались ли лучи из этой точки мира кадр
за кадром**. На больших дальностях это единственная доступная улика:
локальный контраст там структурно равен нулю, потому что кольцо
окружения упирается в стену тоннеля на той же дальности.
"""
if d.size == 0:
return np.zeros(0, np.float32)
col = self.hits.sum(axis=2)
win = max(int(BG_WIN_M / self.ds) | 1, 3)
base = ndimage.median_filter(col, size=(win, 1), mode="nearest")
resid = np.maximum(col - base, 0.0)
si = np.clip(((self._off + d) / self.ds).astype(np.int32), 0, self.n_s - 1)
ui = np.clip(((u + self.half_width) / self.du).astype(np.int32), 0, self.n_u - 1)
# окно ±1 ячейка: предмет шире одной ячейки, а оценка пути слегка плывёт
tot = np.zeros(d.size, np.float32)
frames = np.zeros(d.size, np.float32)
for ds_ in (-1, 0, 1):
for du_ in (-1, 0, 1):
a = np.clip(si + ds_, 0, self.n_s - 1)
b = np.clip(ui + du_, 0, self.n_u - 1)
tot += resid[a, b]
frames = np.maximum(frames, self.seen[a, b])
expect = self.k_expect / np.maximum(d, 1.0) ** 2
sup = tot / np.maximum(expect, 1e-6)
# без нескольких кадров подряд это не улика, а вспышка
return np.where(frames > MIN_FRAMES, sup, 0.0).astype(np.float32)

324
flyguard/geometry.py Normal file
View file

@ -0,0 +1,324 @@
"""Система координат пути, плоскость рельсов и «ожидаемая дальность до пола».
Соответствие мухе — **жужжальца и оцеллии**. Прежде чем обрабатывать изображение,
муха стабилизирует взгляд: жужжальца дают угловые скорости, оцеллии — направление
на горизонт, и голова доворачивается так, чтобы зрительный мир не «плавал».
Здесь роль горизонта играет плоскость пути: она оценивается по самим данным
в каждом кадре, поэтому крепление сенсора не обязано быть жёстким, а качка
вагона не превращается в ложные срабатывания.
Ключевая величина дальше по конвейеру — **ожидаемая дальность до пола** для
каждого луча. Луч с отрицательной элевацией, если ему ничто не мешает, обязан
закончиться на плоскости пути на строго определённом расстоянии. Всё, что
обрывает его раньше, — предмет, стоящий на пути. Это даёт детектор, не зависящий
от абсолютного размера объекта и работающий на любой дальности.
Система координат пути (используется во всём проекте):
d — вперёд по ходу движения, м (в кадре сенсора это −y)
u — поперёк, вправо, м (в кадре сенсора это x)
h — вверх от плоскости рельсов, м
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .retina import RangeImage, ScanLayout
@dataclass(frozen=True)
class RailPlane:
"""Плоскость головок рельсов в системе сенсора: z = a·d + b·u + c."""
a: float # тангаж: подъём плоскости с расстоянием
b: float # крен: наклон плоскости поперёк
c: float # −высота сенсора над путём (c < 0)
inliers: int
rms: float
@property
def height(self) -> float:
"""Высота сенсора над головкой рельса, м."""
return -self.c
@property
def pitch_deg(self) -> float:
return float(np.degrees(np.arctan(self.a)))
@property
def roll_deg(self) -> float:
return float(np.degrees(np.arctan(self.b)))
def height_of(self, d: np.ndarray, u: np.ndarray, z: np.ndarray) -> np.ndarray:
"""Высота точек над плоскостью пути."""
return z - (self.a * d + self.b * u + self.c)
def floor_range(self, layout: ScanLayout) -> np.ndarray:
"""Дальность, на которой каждый луч упёрся бы в плоскость пути.
Луч r·(dx, dy, dz); подстановка в уравнение плоскости даёт
r = c / (dz + a·dy − b·dx). Лучи, уходящие вверх или параллельно
плоскости, получают +inf.
"""
dx = layout.dirs[..., 0]
dy = layout.dirs[..., 1]
dz = layout.dirs[..., 2]
denom = dz + self.a * dy - self.b * dx
with np.errstate(divide="ignore", invalid="ignore"):
r = self.c / denom
return np.where((denom < -1e-6) & np.isfinite(r), r, np.float32(np.inf)).astype(np.float32)
DEFAULT_PLANE = RailPlane(a=0.0, b=0.0, c=-2.19, inliers=0, rms=0.0)
def fit_rail_plane(img: RangeImage, layout: ScanLayout, *,
d_min: float = 6.0, d_max: float = 45.0,
u_max: float = 1.9, cell_d: float = 1.0, cell_u: float = 0.25,
iters: int = 4, prev: RailPlane | None = None,
smooth: float = 0.25) -> RailPlane:
"""Робастная оценка плоскости пути по ближней зоне.
В каждой ячейке сетки (d, u) остаётся только самая низкая точка — это
отсекает шпалы, кабельные лотки и всё, что стоит на полотне. Затем идут
итерации перевзвешенных наименьших квадратов с мягкой функцией Хьюбера,
после чего подгонка повторяется уже только по точкам у самой плоскости.
`smooth` задаёт постоянную времени экспоненциального сглаживания по кадрам:
плоскость пути физически не может прыгать, и сглаживание играет ту же роль,
что обратная связь от жужжалец, — гасит дрожание оценки.
"""
xyz = img.xyz(layout)
x, y, z = xyz[..., 0], xyz[..., 1], xyz[..., 2]
d = -y
sel = img.valid & (d > d_min) & (d < d_max) & (np.abs(x) < u_max)
if sel.sum() < 200:
return prev or DEFAULT_PLANE
dv = d[sel].astype(np.float64)
uv = x[sel].astype(np.float64)
zv = z[sel].astype(np.float64)
# самая низкая точка в каждой ячейке — грубое выделение полотна
ci = ((dv - d_min) / cell_d).astype(np.int64)
cj = ((uv + u_max) / cell_u).astype(np.int64)
key = ci * 10_000 + cj
order = np.lexsort((zv, key))
key_s = key[order]
first = np.ones(key_s.size, bool)
first[1:] = key_s[1:] != key_s[:-1]
idx = order[first]
if idx.size < 40:
return prev or DEFAULT_PLANE
dd, uu, zz = dv[idx], uv[idx], zv[idx]
coef = _irls_plane(dd, uu, zz, iters)
if coef is None:
return prev or DEFAULT_PLANE
# второй проход: только точки у найденной плоскости, уже без отбора минимумов
res_all = zv - (coef[0] * dv + coef[1] * uv + coef[2])
near = np.abs(res_all) < 0.18
if near.sum() > 300:
c2 = _irls_plane(dv[near], uv[near], zv[near], iters)
if c2 is not None:
coef = c2
res = zv - (coef[0] * dv + coef[1] * uv + coef[2])
keep = np.abs(res) < 0.2
plane = RailPlane(a=float(coef[0]), b=float(coef[1]), c=float(coef[2]),
inliers=int(keep.sum()),
rms=float(np.sqrt(np.mean(res[keep] ** 2))) if keep.any() else 9.9)
# защита от вырождения: высота сенсора над путём физически ограничена
if not (0.5 < plane.height < 5.0) or abs(plane.pitch_deg) > 8 or abs(plane.roll_deg) > 8:
return prev or DEFAULT_PLANE
if prev is not None and smooth > 0:
k = smooth
plane = RailPlane(a=k * plane.a + (1 - k) * prev.a,
b=k * plane.b + (1 - k) * prev.b,
c=k * plane.c + (1 - k) * prev.c,
inliers=plane.inliers, rms=plane.rms)
return plane
def _irls_plane(d: np.ndarray, u: np.ndarray, z: np.ndarray, iters: int):
"""z ≈ a·d + b·u + c с мягким Хьюбером."""
A = np.stack([d, u, np.ones_like(d)], axis=1)
w = np.ones_like(z)
coef = np.array([0.0, 0.0, float(np.median(z))])
for _ in range(iters):
try:
coef, *_ = np.linalg.lstsq(A * w[:, None], z * w, rcond=None)
except np.linalg.LinAlgError:
return None
res = z - A @ coef
s = 1.4826 * np.median(np.abs(res - np.median(res))) + 1e-3
w = 1.0 / np.sqrt(1.0 + (res / (2.0 * s)) ** 2)
return coef
@dataclass
class Corridor:
"""Осевая линия пути впереди: u_c(d) = c0 + c1·d + c2·d².
Оценивается по дрейфу центра сечения тоннеля с расстоянием. В прямом
тоннеле c1 ≈ c2 ≈ 0; в кривой радиуса R член c2 ≈ 1/(2R). Нужна, чтобы
габарит на 150 м впереди не «въезжал» в стену на повороте — иначе вся
дальняя зона кривой превращается в сплошное ложное срабатывание.
"""
coef: np.ndarray # (3,)
d_max_seen: float # дальше этого — экстраполяция
n_slices: int
radius: float # оценка радиуса кривой, м (inf для прямой)
def centre(self, d: np.ndarray) -> np.ndarray:
"""Ось пути на дальности d.
За пределами наблюдавшейся дальности парабола продолжается **линейно**,
по касательной: экстраполировать кривизну туда, где данных не было,
значит получить десятки метров ошибки на ровном месте.
"""
d = np.asarray(d, np.float32)
c0, c1, c2 = self.coef
dm = np.float32(max(self.d_max_seen, 1.0))
d_in = np.minimum(d, dm)
u = c0 + c1 * d_in + c2 * d_in * d_in
slope = c1 + 2.0 * c2 * dm
return (u + slope * np.maximum(d - dm, 0.0)).astype(np.float32)
def sigma(self, d: np.ndarray, base: float = 0.25, rate: float = 0.004) -> np.ndarray:
"""Неопределённость положения оси: растёт с дальностью и за горизонтом видимости."""
d = np.asarray(d, np.float32)
extra = np.maximum(d - np.float32(self.d_max_seen), 0.0)
return (base + rate * d + 0.02 * extra).astype(np.float32)
STRAIGHT = Corridor(np.zeros(3), 0.0, 0, float("inf"))
MIN_TRACK_RADIUS = 300.0 # м, круче на перегонах метрополитена не бывает
MAX_AXIS_RATE = 0.35 # м за кадр, предел изменения оси на дальности 100 м
def fit_corridor(tf: "TrackFrame", *, d_lo: float = 8.0, d_hi: float = 220.0,
n_slices: int = 30, h_lo: float = 0.6, h_hi: float = 3.2,
min_pts: int = 60, d_ref: float = 22.0,
prev: Corridor | None = None, smooth: float = 0.08) -> Corridor:
"""Оценить осевую линию пути по смещению центра сечения тоннеля.
Каждый срез по дальности даёт одну оценку центра свода. Веса берутся
**равными по срезам**, а не по числу точек: у ближних срезов точек в сотни
раз больше, и взвешивание по количеству полностью подавило бы дальние срезы,
в которых как раз и содержится кривизна.
"""
edges = np.geomspace(d_lo, d_hi, n_slices + 1)
ds, us = [], []
for lo, hi in zip(edges[:-1], edges[1:]):
m = tf.valid & (tf.d >= lo) & (tf.d < hi) & (tf.h > h_lo) & (tf.h < h_hi)
if int(m.sum()) < min_pts:
continue
uu = tf.u[m]
lo_u, hi_u = np.percentile(uu, (3.0, 97.0))
if hi_u - lo_u < 1.5: # видна только одна стена — центр не определить
continue
ds.append(0.5 * (lo + hi))
us.append(0.5 * (lo_u + hi_u))
if len(ds) < 5:
return prev or STRAIGHT
d = np.asarray(ds, np.float64)
u = np.asarray(us, np.float64)
# положение поезда в сечении: медиана центров ближней зоны
ref = d <= d_ref
u = u - (np.median(u[ref]) if ref.sum() >= 2 else u[0])
far = d > 12.0
if far.sum() < 4:
return prev or STRAIGHT
d_f, u_f = d[far], u[far]
# u ≈ c1·d + c2·d², равные веса по срезам, две итерации робастного отсева
A = np.stack([d_f, d_f * d_f], axis=1)
w = np.ones_like(u_f)
c = np.zeros(2)
for _ in range(3):
try:
c, *_ = np.linalg.lstsq(A * w[:, None], u_f * w, rcond=None)
except np.linalg.LinAlgError:
return prev or STRAIGHT
res = u_f - A @ c
s = 1.4826 * np.median(np.abs(res - np.median(res))) + 0.05
w = 1.0 / np.sqrt(1.0 + (res / (2.0 * s)) ** 2)
coef = np.array([0.0, c[0], c[1]])
radius = float(abs(1.0 / (2.0 * c[1]))) if abs(c[1]) > 1e-7 else float("inf")
# Радиус круче 300 м на перегоне метрополитена не встречается. Такая оценка
# означает не кривую, а испорченное сечение: на станции платформа делает свод
# резко несимметричным, центр «уезжает», и габарит вместе с ним заезжает
# прямо на платформу — источник почти всех ложных тревог у станций.
if radius < MIN_TRACK_RADIUS:
return prev or STRAIGHT
out = Corridor(coef, float(d.max()), len(ds), radius)
if prev is None or smooth <= 0:
return out
k = smooth
blended = k * out.coef + (1 - k) * prev.coef
# путь физически не может вильнуть: ограничиваем скорость изменения оси
shift_now = blended[1] * 100.0 + blended[2] * 100.0 ** 2
shift_prev = prev.coef[1] * 100.0 + prev.coef[2] * 100.0 ** 2
excess = abs(shift_now - shift_prev)
if excess > MAX_AXIS_RATE:
t = MAX_AXIS_RATE / excess
blended = prev.coef + (blended - prev.coef) * t
r2 = (float(abs(1.0 / (2.0 * blended[2]))) if abs(blended[2]) > 1e-7 else float("inf"))
return Corridor(blended, out.d_max_seen, out.n_slices, r2)
class TrackFrame:
"""Кадр в координатах пути: (d, u, h) плюс ожидаемая дальность до пола."""
__slots__ = ("d", "u", "h", "z", "r", "valid", "inten", "floor_r", "plane",
"layout", "img")
def __init__(self, img: RangeImage, layout: ScanLayout, plane: RailPlane):
xyz = img.xyz(layout)
self.layout = layout
self.img = img
self.plane = plane
self.u = xyz[..., 0]
self.d = -xyz[..., 1]
self.z = xyz[..., 2] # в системе сенсора, не над рельсом
self.h = plane.height_of(self.d, self.u, self.z)
self.r = img.r_near
self.valid = img.valid
self.inten = img.inten
self.floor_r = plane.floor_range(layout)
def lateral(self, corridor: "Corridor | None" = None) -> np.ndarray:
"""Смещение точек от осевой линии пути, м.
Берётся **меньшее по модулю** из двух: отсчёт от прямой оси и от
оценённой кривой. Это объединение двух габаритов, а не замена одного
другим, и сделано осознанно: оценка оси неизбежно неточна, а система
безопасности не имеет права **сужать** зону поиска по неуверенной
оценке. Измерено: замена (а не объединение) поднимала пропуски
реального объекта с 1 % до 25 %, экономя при этом лишь 2.5 % кадров
с ложной тревогой — размен в неверную сторону.
"""
if corridor is None or corridor.n_slices == 0:
return self.u
curved = self.u - corridor.centre(self.d)
return np.where(np.abs(curved) < np.abs(self.u), curved, self.u)
def in_gauge(self, half_width: float = 1.7, h_lo: float = 0.05,
h_hi: float = 2.2, d_min: float = 3.0, d_max: float = 260.0,
corridor: "Corridor | None" = None) -> np.ndarray:
"""Маска лучей, чьи точки лежат внутри габарита приближения."""
return (self.valid & (self.d > d_min) & (self.d < d_max)
& (np.abs(self.lateral(corridor)) < half_width)
& (self.h > h_lo) & (self.h < h_hi))

118
flyguard/lamina.py Normal file
View file

@ -0,0 +1,118 @@
"""LAMINA — локальный контраст, разделение ON/OFF.
Первый нейропиль за фоторецепторами. Клетки L1 и L2 получают один и тот же вход
от R1–R6 и расходятся на два канала: L1 → ON (стало ярче), L2 → OFF (стало
темнее). Оба канала предварительно проходят **латеральное торможение** от
амакриновых клеток и Dm9 — классическое «центр минус окружение», которое
подавляет ровный фон и оставляет только локальные отклонения.
Перенос на лидар:
* «Яркость» — это **диспаритет** δ = 1/R, а не сама дальность. Так правильно
по двум причинам: угловой размер предмета пропорционален 1/R, и шум лидара
в диспаритете почти однороден, тогда как в дальности растёт квадратично.
* **ON** = объект ближе своего окружения — выступ, то есть препятствие.
* **OFF** = дальше окружения или эха нет вовсе — провал, то есть окклюзионная
тень **за** препятствием. Тень часто во много раз крупнее самого предмета,
и именно она даёт шанс увидеть мелкий объект на большой дальности.
* Размер предмета в лучах меняется с дальностью на два порядка, поэтому
окружение берётся **на нескольких масштабах** сразу — как у колонковых
нейронов лобулы с разными размерами рецептивных полей, сходящихся на один
нисходящий нейрон.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy.ndimage import uniform_filter
# (радиус центра, радиус окружения) в лучах: кольца × столбцы
SCALES: tuple[tuple[int, int], ...] = ((1, 6), (3, 14), (7, 30))
@dataclass
class LaminaOutput:
"""Каналы ламины для одного кадра."""
disp: np.ndarray # (H, W) диспаритет 1/R, 0 там, где эха нет
on: np.ndarray # (H, W) ON-контраст, максимум по масштабам, 1/м
off: np.ndarray # (H, W) OFF-контраст, 1/м
on_scale: np.ndarray # (H, W) int8 — на каком масштабе отклик максимален
surround: np.ndarray # (H, W) диспаритет окружения на среднем масштабе
hole: np.ndarray # (H, W) доля «нет эха» в окрестности
def _masked_mean(v: np.ndarray, m: np.ndarray, size: tuple[int, int]) -> np.ndarray:
"""Среднее по прямоугольному окну только по валидным отсчётам."""
num = uniform_filter(v, size=size, mode="nearest")
den = uniform_filter(m, size=size, mode="nearest")
return num, den
def _annulus_mean(v: np.ndarray, m: np.ndarray, r_in: int, r_out: int):
"""Среднее по кольцу: большое окно минус вырезанный центр.
Реализовано через два равномерных фильтра (каждый разделим и работает за
O(N)), поэтому стоимость не зависит от размера окна.
"""
s_in = (2 * r_in + 1, 2 * r_in + 1)
s_out = (2 * r_out + 1, 4 * r_out + 1) # шире по азимуту: решётка анизотропна
n_in = s_in[0] * s_in[1]
n_out = s_out[0] * s_out[1]
num_i, den_i = _masked_mean(v, m, s_in)
num_o, den_o = _masked_mean(v, m, s_out)
num = num_o * n_out - num_i * n_in
den = den_o * n_out - den_i * n_in
out = np.divide(num, den, out=np.zeros_like(num), where=den > 0.5)
return out, den
def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> LaminaOutput:
"""Посчитать ON/OFF-каналы ламины по дальностному образу."""
v = valid.astype(np.float32)
disp = np.zeros_like(r, dtype=np.float32)
np.divide(1.0, r, out=disp, where=valid & (r > 0.05))
disp *= v
on = np.zeros_like(disp)
off = np.zeros_like(disp)
on_scale = np.zeros(disp.shape, np.int8)
surround_mid = None
for k, (r_in, r_out) in enumerate(SCALES):
sur, cnt = _annulus_mean(disp, v, r_in, r_out)
enough = cnt > 8.0
c = np.where(enough, disp - sur, 0.0)
pos = np.maximum(c, 0.0) * v # ближе окружения
# провал считается и там, где эха нет: 1/∞ = 0 — это тоже сигнал
neg = np.maximum(-(disp - sur), 0.0) * enough
better = pos > on
on = np.where(better, pos, on)
on_scale = np.where(better, np.int8(k), on_scale)
off = np.maximum(off, neg)
if k == 1:
surround_mid = sur
# доля лучей без эха в окрестности — мера «дыры» в поверхности
hole = 1.0 - uniform_filter(v, size=(5, 15), mode="nearest")
# диспаритет физически ограничен снизу дальностью прибора
np.clip(on, 0.0, 1.0 / max(r_max, 1.0) * 1e4, out=on)
return LaminaOutput(disp=disp, on=on, off=off, on_scale=on_scale,
surround=surround_mid if surround_mid is not None else np.zeros_like(disp),
hole=hole.astype(np.float32))
def contrast_to_depth_gap(on: np.ndarray, r: np.ndarray) -> np.ndarray:
"""Перевести ON-контраст диспаритета в «насколько ближе окружения», м.
δ − δ_sur = 1/R − 1/R_sur ⇒ R_sur − R = on · R · R_sur. Для оценки берётся
R_sur = R/(1 − on·R), что даёт разрыв по глубине в метрах.
"""
x = np.clip(on * r, 0.0, 0.999)
with np.errstate(divide="ignore", invalid="ignore"):
gap = r * x / (1.0 - x)
return np.nan_to_num(gap, nan=0.0, posinf=1e4).astype(np.float32)

346
flyguard/lobula.py Normal file
View file

@ -0,0 +1,346 @@
"""LOBULA — колонковые детекторы объектов (аналог LC11) и сборка кандидатов.
В лобуле мухи сидят колонковые нейроны LC: каждый смотрит в свой участок поля
зрения и реагирует на **признак**, а не на картинку целиком. LC11 — детектор
мелкого объекта: он возбуждается компактным пятном, выделяющимся из фона, и
подавляется широкопольным движением, то есть собственным перемещением мухи.
Выход LC-популяции — не изображение, а короткий список «где что-то есть».
Здесь тем же занимается `find_candidates`: из ретинотопических масок улик
собираются связные пятна, для каждого считаются геометрические признаки, и
дальше по конвейеру идут уже десятки кандидатов вместо сотен тысяч лучей.
Решение о том, препятствие это или штатная конструкция тоннеля, здесь НЕ
принимается — этим занимаются грибовидное тело (знакомость) и центральный
комплекс (накопление улик по кадрам).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from scipy import ndimage
from .geometry import Corridor, TrackFrame
from .lamina import LaminaOutput, contrast_to_depth_gap
_NEIGHBOURS = np.ones((3, 3), bool)
@dataclass
class Candidate:
"""Кандидат в препятствия — компактная группа лучей."""
d: float # расстояние вперёд, м
u: float # смещение от оси пути, м
h: float # высота над головкой рельса, м
d_min: float # ближняя граница, м
h_min: float # нижняя точка над рельсом, м — есть ли опора снизу
width: float # ширина, м
height: float # высота, м
depth: float # протяжённость вдоль пути, м
containment: float # доля лучей пятна, оставшихся внутри габарита, 0…1
n_rays: int # число лучей
n_rings: int
n_cols: int
gap: float # насколько ближе фона, м
on: float # ON-контраст, 1/м
floor_deficit: float # насколько раньше оборвался луч до полотна, м
shadow: float # доля тени (нет эха) сразу за объектом
inten: float # медианная интенсивность
az_deg: float
el_deg: float
bbox: tuple[int, int, int, int]
score: float = 0.0
novelty: float = 1.0
extra: dict = field(default_factory=dict)
@property
def area(self) -> float:
return self.width * self.height
def cluster_by_depth(mask: np.ndarray, r: np.ndarray, *,
rel_tol: float = 0.06, abs_tol: float = 0.35,
col_reach: int = 3, row_reach: int = 2):
"""Связные компоненты с учётом разрыва по глубине.
Обычная связность по соседству склеивает предмет со стеной, которая
оказалась в том же месте изображения, но на сто метров дальше. Поэтому два
соседних луча объединяются только если их дальности близки — допуск растёт
с расстоянием, потому что и разрешение, и шум растут так же.
Соседство берётся с запасом по азимуту (`col_reach`), чтобы пропуски эха
внутри предмета не дробили его на части.
Возвращает (labels, n_labels); метка 0 — фон.
"""
idx = np.flatnonzero(mask.ravel())
if idx.size == 0:
return np.zeros(mask.shape, np.int32), 0
h, w = mask.shape
lut = np.full(mask.size, -1, np.int64)
lut[idx] = np.arange(idx.size)
ri = r.ravel()[idx]
rows, cols = np.divmod(idx, w)
src, dst = [], []
for dr in range(0, row_reach + 1):
for dc in range(-col_reach, col_reach + 1):
if dr == 0 and dc <= 0:
continue
nr, nc = rows + dr, cols + dc
ok = (nr < h) & (nc >= 0) & (nc < w)
nb = lut[np.where(ok, nr * w + nc, 0)]
ok &= nb >= 0
if not ok.any():
continue
a = np.flatnonzero(ok)
b = nb[ok]
close = np.abs(ri[a] - ri[b]) <= (abs_tol + rel_tol * np.minimum(ri[a], ri[b]))
if close.any():
src.append(a[close])
dst.append(b[close])
n_nodes = idx.size
if src:
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
s = np.concatenate(src)
t = np.concatenate(dst)
g = coo_matrix((np.ones(s.size, np.int8), (s, t)), shape=(n_nodes, n_nodes))
n_comp, comp = connected_components(g, directed=False)
else:
n_comp = n_nodes
comp = np.arange(n_nodes)
labels = np.zeros(mask.size, np.int32)
labels[idx] = comp + 1
return labels.reshape(mask.shape), n_comp
def split_by_figure(labels: np.ndarray, n: int, r: np.ndarray,
figure: np.ndarray, *, max_depth: float, thr: float,
col_reach: int = 3, row_reach: int = 2, top: int = 0):
"""Вырезать фигуру из компоненты, растёкшейся по фону.
Гладкая стена тоннеля связна по глубине от ближнего поля до горизонта:
соседние лучи отличаются на сантиметры, и допуск сшивает их в одну
компоненту длиной сто метров. Предмет у такой стены попадает в неё же
и вместе с ней отбрасывается правилом `max_depth` — система слепнет
именно там, где смотрит вдоль стены, а это опаснее лишней тревоги.
Разорвать такую компоненту по дальности нельзя: предмет и стена рядом с
ним стоят на одной дальности. Зато они по-разному **приближаются**. Вдоль
фиксированного луча стена, параллельная движению, не приближается вовсе:
поезд едет, точка пересечения скользит по стене, дальность не меняется.
Предмет, обращённый к поезду, приближается ровно на пройденный путь.
Отсюда `advance` ≈ 0 у фона и ≈ 1 у фигуры.
Это и есть разделение фигуры и фона, которым у мухи занят канал
T4/T5 → LPTC: широкопольный поток задаёт ожидание, а то, что движется
иначе, становится фигурой.
Второй, более сильный источник того же признака — **контраст ламины**.
Гладкая стена по построению даёт нулевой центр-окружение: сколько бы ни
менялась дальность вдоль стены, она меняется плавно, и центр равен своему
окружению. Предмет на стене — это ступенька, и она видна. Замер на
вставленном человеке (`roundT_pressureGate_roundT` и `roundT_doubleT`):
при пороге 6 м порог проходят 85 % и 75 % лучей предмета против 0.13 % и
0.22 % лучей фона, а сам фон распадается всего на 5–9 компонент в кадре
вместо 1259 у разреза по допуску.
Поэтому `figure` — любой покадровый признак «это не фон»: скорость
сближения вдоль луча или контраст к окружению в метрах. Пересобираются
только переглубокие компоненты и только по лучам с `figure > thr`;
остальные метки не трогаются.
`top` ограничивает число фигур, выносимых из одной компоненты, оставляя
сильнейшие по признаку. Это не косметика, а необходимость: разрез отрезает
фон, и протяжённость кандидата вдоль пути падает с 6.9 до 0.8 м — вместе с
ней исчезает множитель компактности в весе улики, который до того давил
конструкции в десять раз. Каждая лишняя фигура поэтому стоит дорого.
Ограничение — тот же приём глобального торможения, которым APL оставляет
активными считанные проценты клеток Кеньона. 0 — без ограничения.
"""
if n == 0 or figure is None:
return labels, n
flat = labels.ravel()
rr = r.ravel()
adv = figure.ravel()
order = np.argsort(flat, kind="stable")
starts = np.searchsorted(flat[order], np.arange(n + 2))
out, next_id, copied = labels, n, False
for lbl in range(1, n + 1):
idx = order[starts[lbl]:starts[lbl + 1]]
if idx.size < 2:
continue
v = rr[idx]
v = v[np.isfinite(v)]
if v.size < 2 or float(v.max() - v.min()) <= max_depth:
continue
keep = idx[adv[idx] > thr]
if keep.size < 4 or keep.size == idx.size:
continue
sub = np.zeros(labels.shape, bool)
sub.ravel()[keep] = True
parts, n2 = cluster_by_depth(sub, r, col_reach=col_reach, row_reach=row_reach)
if n2 == 0:
continue
lbl_of = parts.ravel()[keep]
if top > 0 and n2 > top:
# сильнейшие по признаку: сумма превышения порога по всем лучам
w = np.bincount(lbl_of, weights=adv[keep] - thr, minlength=n2 + 1)
best = np.argsort(w[1:])[::-1][:top] + 1
alive = np.isin(lbl_of, best)
keep, lbl_of = keep[alive], lbl_of[alive]
if keep.size < 4:
continue
# перенумеровать оставшиеся подряд
_, lbl_of = np.unique(lbl_of, return_inverse=True)
lbl_of += 1
n2 = int(lbl_of.max())
if not copied:
out, copied = labels.copy(), True
# фон этой компоненты уходит из рассмотрения: он и был бы отброшен
out.ravel()[idx] = 0
out.ravel()[keep] = next_id + lbl_of
next_id += n2
return out, next_id
def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
h_lo: float, h_hi: float, d_min: float, d_max: float):
"""Лучи, попавшие в габарит приближения. Считается один раз на кадр.
Нужны и для выделения кандидатов, и для накопителя веерного тела, поэтому
вынесены отдельно: пересчитывать один и тот же набор масок дважды незачем.
"""
lat = tf.lateral(corridor)
in_range = tf.valid & (tf.d > d_min) & (tf.d < d_max)
inside = in_range & (np.abs(lat) < half_width) & (tf.h > h_lo) & (tf.h < h_hi)
return inside, lat, in_range
def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
half_width: float = 1.7, h_lo: float = 0.28, h_hi: float = 2.3,
d_min: float = 4.0, d_max: float = 220.0,
min_rays: int = 4, max_candidates: int = 64,
dilate_cols: int = 2, max_depth: float = 15.0,
ctx_up: float = 4.0, split_adv: float = 0.0,
split_gap: float = 0.0, split_near: float = 0.0,
split_top: int = 0,
advance: np.ndarray | None = None,
masks: tuple | None = None) -> list[Candidate]:
"""Выделить кандидатов: связные пятна лучей, попавших в габарит.
Кластеризация ведётся по **расширенной** области, а членство в габарите
проверяется уже для готового пятна. Без этого вертикальный лоскут стены,
срезанный границей коридора, неотличим от предмета: обрезка сама создаёт
компактное пятно нужного размера. Доля лучей, оставшихся внутри габарита
(`containment`), сразу показывает, предмет это целиком или край стены.
"""
if masks is None:
masks = gauge_mask(tf, corridor, half_width=half_width, h_lo=h_lo,
h_hi=h_hi, d_min=d_min, d_max=d_max)
inside, lat, in_range = masks
if not inside.any():
return []
# Контекст расширяется в стороны и вверх, но НЕ вниз: полотно пути проходит
# прямо под каждым предметом и на большой дальности попадает в тот же допуск
# по глубине, так что расширение вниз склеило бы любой объект с полом.
# Вверх, наоборот, нужно доставать до свода: колонна и рама крепи идут от
# полотна до потолка, и если обрезать контекст на полпути, срез сам выглядит
# компактным предметом ростом с габарит.
context = (in_range & (np.abs(lat) < half_width + 1.6)
& (tf.h > h_lo) & (tf.h < h_hi + ctx_up))
gap_img = contrast_to_depth_gap(lam.on, tf.r)
labels, n = cluster_by_depth(context, tf.r, col_reach=dilate_cols + 1)
if n == 0:
return []
if split_gap > 0:
# Контраст к окружению как признак фигуры. Если задан и порог движения,
# выполняются оба условия сразу: стена должна и приближаться как предмет,
# и торчать из своего окружения.
fig = gap_img if (split_adv <= 0 or advance is None) else np.where(advance > split_adv, gap_img, 0.0)
if split_near > 0:
# Ближе этой дальности покадровый тракт видит предмет и без разреза:
# компонента со стеной там не слипается, лучей много, форма читается.
# Резать там незачем, а обстановки, дающей контраст, в ближнем поле
# на порядок больше — оттуда и берутся лишние кандидаты.
fig = np.where(tf.d > split_near, fig, 0.0)
labels, n = split_by_figure(labels, n, tf.r, fig, max_depth=max_depth,
thr=split_gap, col_reach=dilate_cols + 1,
top=split_top)
elif split_adv > 0 and advance is not None:
labels, n = split_by_figure(labels, n, tf.r, advance, max_depth=max_depth,
thr=split_adv, col_reach=dilate_cols + 1)
flat = labels.ravel()
counts = np.bincount(flat, minlength=n + 1)
counts[0] = 0
inner = np.bincount(flat, weights=inside.ravel().astype(np.float64), minlength=n + 1)
inner[0] = 0
keep = np.flatnonzero(inner >= min_rays)
if keep.size == 0:
return []
if keep.size > max_candidates:
keep = keep[np.argsort(inner[keep])[::-1][:max_candidates]]
with np.errstate(invalid="ignore"):
deficit = np.where(np.isfinite(tf.floor_r), tf.floor_r - tf.r, 0.0)
deficit = np.where(tf.valid & (deficit > 0), deficit, 0.0).astype(np.float32)
order = np.argsort(labels.ravel(), kind="stable")
sorted_lbl = labels.ravel()[order]
starts = np.searchsorted(sorted_lbl, np.arange(n + 2))
inside_flat = inside.ravel()
out: list[Candidate] = []
for lbl in keep:
ctx_idx = order[starts[lbl]:starts[lbl + 1]]
sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите
if sel.size < min_rays:
continue
ii, jj = np.divmod(sel, labels.shape[1])
d = tf.d[ii, jj]
u = lat[ii, jj]
h = tf.h[ii, jj]
nn = d.size
depth = float(d.max() - d.min()) if nn > 1 else 0.0
if depth > max_depth:
# ни один посторонний предмет не тянется на десятки метров вдоль пути:
# это полотно, стена или кабельный лоток, взятые под скользящим углом
continue
cand = Candidate(
d=float(np.median(d)), u=float(np.median(u)), h=float(np.median(h)),
d_min=float(d.min()), h_min=float(h.min()),
width=float(u.max() - u.min()) if nn > 1 else 0.0,
height=float(h.max() - h.min()) if nn > 1 else 0.0,
depth=depth,
containment=float(sel.size / max(ctx_idx.size, 1)),
n_rays=int(nn), n_rings=int(np.unique(ii).size), n_cols=int(np.unique(jj).size),
gap=float(np.median(gap_img[ii, jj])),
on=float(np.median(lam.on[ii, jj])),
floor_deficit=float(np.median(deficit[ii, jj])),
shadow=float(np.median(lam.hole[ii, jj])),
inten=float(np.median(tf.inten[ii, jj])),
az_deg=float(np.mean(tf.layout.az_grid_deg[jj])),
el_deg=float(np.mean(tf.layout.el_deg[ii])),
bbox=(int(ii.min()), int(ii.max()), int(jj.min()), int(jj.max())),
)
# индексы лучей нужны только для отладочной визуализации; они небольшие
cand.extra["rays"] = (ii, jj)
out.append(cand)
out.sort(key=lambda c: c.d)
return out

236
flyguard/mbon_readout.py Normal file
View file

@ -0,0 +1,236 @@
"""MBON с обучением с учителем — считывание, а не новая сеть.
Грибовидное тело в `mushroom_body.py` учится **без меток**: синапсы KC→MBON
депрессируются на всём, что тоннель показывает часто, и выход MBON означает
«незнакомо». Это ровно familiarity suppression MBON-α′3 и ровно то, что нужно,
когда меток нет.
Но у мухи та же схема умеет и другое. При обучении с подкреплением
дофаминергические нейроны PPL1/PAM депрессируют KC→MBON **избирательно** — те
клетки, что были активны вместе с наказанием, — и выход MBON начинает означать
не «незнакомо», а «это предвещает удар». Один и тот же нейропиль, один и тот же
разрежённый код, другой учитель.
Здесь сделано именно это. Слои не меняются:
* вход — те же признаки кандидата плюс опора веерного тела (23 «проекционных
нейрона»);
* KC — та же случайная разрежённая проекция по 6 «когтей» на клетку;
* APL — то же глобальное торможение «победитель забирает всё», но отклик
остаётся **градуальным** и делится на общую активность (дивизивная
нормировка), а не превращается в единицы и нули;
* MBON — один выход, веса которого обучены различать предмет и обстановку.
Метки берутся не из разметки (её нет), а из физики: `flyguard.synth` вставляет
предмет трассировкой лучей, и кандидат считается предметом, если его ядро
состоит из лучей, в которые предмет действительно записан
(`tools/make_training_set.py`).
Зачем это нужно поверх ручной формулы веса улики. В `central_complex._quality`
шесть множителей, придуманных руками, а в дескрипторе 23 признака: **тень и
интенсивность в вес улики не входят вообще**, хотя окклюзионная тень за
предметом на большой дальности во много раз крупнее самого предмета.
Стоимость в инференсе — одно умножение матрицы 23 × n_kc на кандидата, доли
миллисекунды на CPU. GPU нужен только на обучении, и то не обязателен.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from .lobula import Candidate
from .mushroom_body import describe
@dataclass
class MbonConfig:
"""Параметры считывания."""
# Ёмкость выбирается замером (`tools/train_mbon.py --sweep-kc`), а не на
# глаз: отбор «победитель забирает всё» стоит дороже матмула, и в худшем
# кадре с 64 кандидатами 20 000 клеток это 7.3 мс, 8 000 — 2.8 мс, 4 000 —
# 1.3 мс. Умолчание взято средним: платить временем кадра за ёмкость имеет
# смысл, только если развёртка показала, что она что-то даёт.
n_kc: int = 8_000 # клеток Кеньона в этом контуре
claws: int = 6 # входов на клетку (из коннектома)
sparsity: float = 0.0125 # доля активных после торможения APL — 100 клеток
seed: int = 20260921
def describe_full(c: Candidate) -> np.ndarray:
"""Вектор кандидата для считывания: дескриптор памяти плюс опора накопителя."""
acc = float(c.extra.get("acc_support", 0.0)) if c.extra else 0.0
return np.append(describe(c), np.float32(acc)).astype(np.float32)
class MbonReadout:
"""Разрежённый код клеток Кеньона + обученный линейный выход MBON."""
def __init__(self, cfg: MbonConfig | None = None, n_pn: int = 23):
self.cfg = cfg or MbonConfig()
self.n_pn = int(n_pn)
self.n_active = max(1, int(round(self.cfg.n_kc * self.cfg.sparsity)))
rng = np.random.default_rng(self.cfg.seed)
idx = np.stack([rng.choice(self.n_pn, size=self.cfg.claws, replace=False)
for _ in range(self.cfg.n_kc)])
sign = rng.choice((-1.0, 1.0), size=idx.shape)
w = np.zeros((self.cfg.n_kc, self.n_pn), np.float32)
np.put_along_axis(w, idx, sign.astype(np.float32), axis=1)
self.W = w
self.mean = np.zeros(self.n_pn, np.float32)
self.scale = np.ones(self.n_pn, np.float32)
self.w_mbon = np.zeros(self.cfg.n_kc, np.float32)
self.bias = np.float32(0.0)
# калибровка выхода: sigmoid(gain·z + shift). Градиентный спуск по
# разрежённому коду хорошо упорядочивает кандидатов, но масштаб логита
# зависит от ёмкости и числа эпох, а нам нужна осмысленная вероятность —
# она идёт множителем в вес улики.
self.gain = np.float32(1.0)
self.shift = np.float32(0.0)
# ------------------------------------------------------------------ код
def fit_normalizer(self, X: np.ndarray) -> None:
X = np.atleast_2d(np.asarray(X, np.float32))
self.mean = X.mean(0).astype(np.float32)
s = X.std(0).astype(np.float32)
self.scale = np.where(s > 1e-6, s, 1.0).astype(np.float32)
def encode(self, X: np.ndarray, device: str | None = None):
"""Признаки → (индексы активных клеток, их нормированный отклик).
Отклик остаётся градуальным и делится на свою сумму: это дивизивная
нормировка APL. Двоичный код тут заметно хуже — он выбрасывает
«насколько» клетка возбуждена, а для решения это как раз важно.
Нормировка приводит средний отклик к единице, а не сумму: иначе каждая
из сотни активных клеток даёт вклад 0.01, логиты выходят микроскопические
и обучение сваливается в разумное ранжирование при бессмысленной
калибровке (замерено: AUC 0.97 при доле верных 0.37).
"""
X = np.atleast_2d(np.asarray(X, np.float32))
if X.shape[1] != self.n_pn:
raise ValueError(f"считывание обучено на {self.n_pn} признаках, "
f"а дескриптор даёт {X.shape[1]}")
k = self.n_active
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
if device and device != "cpu":
import torch
out_i = np.empty((X.shape[0], k), np.int64)
out_v = np.empty((X.shape[0], k), np.float32)
with torch.no_grad():
m = torch.as_tensor(self.mean, device=device)
s = torch.as_tensor(self.scale, device=device)
w = torch.as_tensor(self.W, device=device).T.contiguous()
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=device)
y = torch.relu(((t - m) / s) @ w)
v, a = torch.topk(y, k, dim=1)
v = v * (k / v.sum(1, keepdim=True).clamp_min(1e-6))
out_i[i:i + chunk] = a.cpu().numpy()
out_v[i:i + chunk] = v.cpu().numpy()
return out_i, out_v
out_i = np.empty((X.shape[0], k), np.int64)
out_v = np.empty((X.shape[0], k), np.float32)
for i in range(0, X.shape[0], chunk):
z = (X[i:i + chunk] - self.mean) / self.scale
y = np.maximum(z @ self.W.T, 0.0)
a = np.argpartition(-y, k - 1, axis=1)[:, :k]
v = np.take_along_axis(y, a, axis=1)
v *= k / np.maximum(v.sum(1, keepdims=True), 1e-6)
out_i[i:i + chunk] = a
out_v[i:i + chunk] = v
return out_i, out_v
# ------------------------------------------------------------------ выход
def score(self, X: np.ndarray) -> np.ndarray:
"""Вероятность «это посторонний предмет», 0…1."""
a, v = self.encode(X)
z = self.logit(X, code=(a, v))
return 1.0 / (1.0 + np.exp(-z))
def logit(self, X: np.ndarray, code=None) -> np.ndarray:
a, v = code if code is not None else self.encode(X)
z = self.bias + (self.w_mbon[a] * v).sum(axis=1)
return self.gain * z + self.shift
def score_of(self, c: Candidate) -> float:
return float(self.score(describe_full(c)[None, :])[0])
def annotate(self, cands: list[Candidate]) -> list[Candidate]:
if not cands:
return cands
X = np.stack([describe_full(c) for c in cands])
for c, p in zip(cands, self.score(X)):
c.extra["mbon"] = float(p)
return cands
# ------------------------------------------------------------------ обучение
def learn(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
lr: float = 4.0, l2: float = 1e-5, device: str | None = None,
verbose: bool = False) -> None:
"""Логистическая регрессия по разрежённому коду — депрессия с учителем.
Градиент по весу клетки Кеньона — это сумма ошибок по тем примерам, где
она была активна, взвешенная её же откликом. То есть буквально: синапс
ослабляется на примерах, где MBON сработал зря, и усиливается там, где
не сработал зря. У мухи это делает дофамин.
"""
y = np.asarray(y, np.float32)
a, v = self.encode(X, device=device)
n, k = a.shape
flat = a.ravel()
for ep in range(epochs):
z = self.bias + (self.w_mbon[a] * v).sum(axis=1)
p = 1.0 / (1.0 + np.exp(-z))
g = (p - y) / n
grad = np.bincount(flat, weights=np.repeat(g, k) * v.ravel(),
minlength=self.cfg.n_kc).astype(np.float32)
self.w_mbon -= lr * (grad + l2 * self.w_mbon)
self.bias -= np.float32(lr * g.sum())
if verbose and (ep + 1) % 50 == 0:
eps = 1e-7
loss = -(y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps)).mean()
print(f" эпоха {ep + 1:4d}: логистическая потеря {loss:.4f}")
self._calibrate(self.bias + (self.w_mbon[a] * v).sum(axis=1), y)
def _calibrate(self, z: np.ndarray, y: np.ndarray, iters: int = 400) -> None:
"""Шкалирование Платта: подобрать наклон и сдвиг по обучающей выборке."""
g, sh = 1.0, 0.0
for _ in range(iters):
p = 1.0 / (1.0 + np.exp(-(g * z + sh)))
e = p - y
g -= 2.0 * float((e * z).mean()) / max(float((z * z).mean()), 1e-6)
sh -= 2.0 * float(e.mean())
self.gain, self.shift = np.float32(g), np.float32(sh)
# ------------------------------------------------------------------ хранение
def save(self, path: str | Path) -> None:
np.savez_compressed(path, w_mbon=self.w_mbon, bias=self.bias,
gain=self.gain, shift=self.shift,
mean=self.mean, scale=self.scale,
n_kc=self.cfg.n_kc, claws=self.cfg.claws,
sparsity=self.cfg.sparsity, seed=self.cfg.seed,
n_pn=self.n_pn)
@staticmethod
def load(path: str | Path) -> "MbonReadout":
d = np.load(path, allow_pickle=False)
cfg = MbonConfig(n_kc=int(d["n_kc"]), claws=int(d["claws"]),
sparsity=float(d["sparsity"]), seed=int(d["seed"]))
m = MbonReadout(cfg, n_pn=int(d["n_pn"]))
m.w_mbon = d["w_mbon"].astype(np.float32)
m.bias = np.float32(d["bias"])
m.gain = np.float32(d["gain"])
m.shift = np.float32(d["shift"])
m.mean = d["mean"].astype(np.float32)
m.scale = d["scale"].astype(np.float32)
return m

398
flyguard/medulla.py Normal file
View file

@ -0,0 +1,398 @@
"""MEDULLA и LOBULA PLATE — движение: T4/T5, LPTC и LPLC2.
Три схемы из коннектома, работающие подряд:
* **T4/T5** — элементарные детекторы движения. Каждый тип существует в четырёх
подтипах, настроенных на четыре стороны света в поле зрения; T4 читает ON-канал,
T5 — OFF. Вычислительно это коррелятор Хассенштайна–Райхардта: сигнал одного
омматидия задерживается и умножается на сигнал соседнего, разность двух таких
произведений даёт направленный отклик.
* **LPTC (HS/VS)** — широкопольные тангенциальные клетки лобулярной пластинки.
Каждая суммирует выход тысяч T4/T5 по своему рецептивному полю и тем самым
измеряет **собственное движение**. Здесь это критично: колёсной одометрии в
задаче нет, и скорость поезда неоткуда взять, кроме как из самого потока.
* **LPLC2** — детектор надвигания. Его дендриты разложены на четыре слоя так,
что клетка отвечает только на поток, расходящийся **из центра её рецептивного
поля**, и подавляется однородным широкопольным потоком. То есть она отделяет
«на меня что-то летит» от «я сам двигаюсь». Выход идёт на гигантское волокно.
Практически: продвижение поезда за кадр оценивается корреляцией продольного
профиля тоннеля (устойчиво и дёшево), а T4/T5 и LPLC2 дают карту остаточного
движения — того, что не объясняется собственным ходом.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
# шаг гистограммы продольного профиля, м
PROFILE_BIN = 0.5
PROFILE_MAX = 250.0
MAX_SPEED = 35.0 # м/с, заведомо выше любого метропоезда
MAX_ACCEL = 3.0 # м/с², предел разгона и экстренного торможения состава
WARMUP_FRAMES = 4 # столько кадров оценка принимается как есть, без фильтра
@dataclass
class EgoMotion:
"""Собственное движение за один кадр."""
ds: float # продвижение вперёд, м
speed: float # м/с
yaw_deg: float # поворот за кадр, °
conf: float # 0…1, качество корреляционного пика
dt: float
@property
def kmh(self) -> float:
return self.speed * 3.6
PROFILE_MIN = 14.0 # ближняя зона в профиль не идёт: там сдвиг кадра не читается
def longitudinal_profile(d: np.ndarray, valid: np.ndarray) -> np.ndarray:
"""Продольная «подпись» тоннеля: сколько лучей оборвалось на каждой дальности.
Тюбинговые кольца, лотки, ниши и стыки дают ей богатый рисунок, поэтому
сдвиг профиля между кадрами читается как пройденный путь.
Две поправки, без которых корреляция залипает на нулевом сдвиге:
ближняя зона исключается (там на один метр пути приходятся тысячи лучей,
и её вклад подавляет всё остальное), а от профиля отнимается скользящее
среднее — остаётся только рисунок структур, без общей огибающей.
"""
n = int(PROFILE_MAX / PROFILE_BIN)
dd = d[valid]
dd = dd[(dd > PROFILE_MIN) & (dd < PROFILE_MAX)]
if dd.size < 50:
return np.zeros(n, np.float32)
p = np.bincount((dd / PROFILE_BIN).astype(np.int32), minlength=n)[:n].astype(np.float32)
p = np.log1p(p)
k = 11 # ≈5 м — крупнее шага структур
kern = np.ones(k, np.float32) / k
env = np.convolve(p, kern, mode="same")
return (p - env).astype(np.float32)
def _norm(x: np.ndarray) -> np.ndarray:
x = x - x.mean()
s = np.linalg.norm(x)
return x / s if s > 1e-6 else x
def match_shift(prev: np.ndarray, cur: np.ndarray, max_bins: int,
prior_bins: float | None = None, prior_w: float = 0.03) -> tuple[float, float]:
"""Сдвиг `cur` относительно `prev` по максимуму нормированной корреляции.
Профиль текущего кадра смещён к меньшим дальностям на пройденный путь,
поэтому ищется такой сдвиг k, при котором cur[i] ≈ prev[i + k], k ≥ 0.
Слабый приор по предыдущей скорости снимает неоднозначность на периодических
структурах вроде тюбинговых колец; его вес нормирован на диапазон поиска,
чтобы он подправлял выбор между близкими пиками, а не диктовал ответ.
Корреляция считается по общей части профилей, поэтому при больших сдвигах
выборка короче — нормировка на длину не даёт этому создать ложный уклон.
"""
n = prev.size
ks = np.arange(0, max_bins + 1)
scores = np.empty(ks.size, np.float32)
for i, k in enumerate(ks):
m = n - k
scores[i] = float(np.dot(_norm(cur[:m]), _norm(prev[k:k + m])))
if prior_bins is not None and max_bins > 0:
scores = scores - prior_w * ((ks - prior_bins) / max_bins) ** 2
i = int(np.argmax(scores))
peak = float(scores[i])
if 0 < i < ks.size - 1: # уточнение параболой по трём точкам
y0, y1, y2 = scores[i - 1], scores[i], scores[i + 1]
den = y0 - 2 * y1 + y2
sub = 0.5 * (y0 - y2) / den if abs(den) > 1e-9 else 0.0
else:
sub = 0.0
return float(ks[i] + np.clip(sub, -1, 1)), peak
class RayIndexer:
"""Обратный поиск по решётке: направление → (кольцо, столбец).
Нужен, чтобы перепроецировать точки предыдущего кадра в текущую решётку.
Элевации каналов заданы убывающей таблицей, поэтому индекс кольца берётся
линейной интерполяцией по ней, а не делением на постоянный шаг.
"""
def __init__(self, layout):
el = np.asarray(layout.el_deg, np.float64)
order = np.argsort(el)
self.el_sorted = el[order]
self.ring_sorted = order.astype(np.float64)
self.az0 = float(layout.az_grid_deg[0])
self.step = float(layout.az_step_deg)
self.n_az = int(layout.n_az)
self.n_rings = int(layout.n_rings)
def __call__(self, az_deg: np.ndarray, el_deg: np.ndarray):
col = np.rint((az_deg - self.az0) / self.step).astype(np.int32)
ring = np.rint(np.interp(el_deg, self.el_sorted, self.ring_sorted)).astype(np.int32)
ok = (col >= 0) & (col < self.n_az) & (ring >= 0) & (ring < self.n_rings)
np.clip(col, 0, self.n_az - 1, out=col)
np.clip(ring, 0, self.n_rings - 1, out=ring)
return ring, col, ok
def advance_score(prev_pts: np.ndarray, r_cur: np.ndarray, valid_cur: np.ndarray,
indexer: RayIndexer, ds: float) -> float:
"""Доля точек прошлого кадра, попавших в текущий кадр при сдвиге вперёд на ds.
Это и есть проверка широкопольного потока на согласие с моделью собственного
движения — то, чем заняты тангенциальные клетки лобулярной пластинки.
"""
d = prev_pts[:, 0] - ds
u = prev_pts[:, 1]
h = prev_pts[:, 2]
m = d > 2.0
if m.sum() < 50:
return 0.0
d, u, h = d[m], u[m], h[m]
rho = np.hypot(d, u)
r = np.sqrt(rho * rho + h * h)
az = np.degrees(np.arctan2(u, d))
el = np.degrees(np.arctan2(h, rho))
ring, col, ok = indexer(az, el)
rc = r_cur[ring, col]
good = ok & valid_cur[ring, col]
if good.sum() < 50:
return 0.0
err = np.abs(rc[good] - r[good])
tol = np.maximum(0.25, 0.015 * r[good])
return float(np.mean(err < tol))
class EgoMotionEstimator:
"""LPTC-аналог: одна широкопольная оценка собственного движения на кадр."""
def __init__(self, dt_nominal: float = 0.1):
self.prev_profile: np.ndarray | None = None
self.prev_az: np.ndarray | None = None
self.prev_stamp: float | None = None
self.prev_ds: float | None = None
self.prev_pts: np.ndarray | None = None
self.indexer: RayIndexer | None = None
self.n_sample = 6000
self.v_filt: float | None = None
self.n_updates = 0
self.dt_nominal = dt_nominal
def update(self, tf, stamp: float) -> EgoMotion:
dt = self.dt_nominal
if self.prev_stamp is not None:
got = stamp - self.prev_stamp
if 0.01 < got < 1.0:
dt = got
prof = longitudinal_profile(tf.d, tf.valid)
az = np.log1p(tf.valid.sum(axis=0)).astype(np.float32)
ds, conf, yaw = 0.0, 0.0, 0.0
if self.prev_profile is not None and np.any(prof):
max_bins = int(MAX_SPEED * dt / PROFILE_BIN) + 2
prior = None if self.prev_ds is None else self.prev_ds / PROFILE_BIN
shift, conf = match_shift(self.prev_profile, prof, max_bins, prior_bins=prior)
ds = shift * PROFILE_BIN
# независимая грубая оценка: дальние фронтальные поверхности приближаются
# ровно на пройденный путь
direct = self._direct_advance(tf)
seeds = [s for s in (ds, direct, self.prev_ds, 0.0) if s is not None]
# уточнение сопоставлением кадров: перебор сдвига с проверкой согласия
refined = self._refine(tf, seeds, dt)
if refined is not None:
ds, conf = refined
elif direct is not None and conf < 0.45:
ds, conf = direct, max(conf, 0.3)
if self.prev_az is not None and self.prev_az.size == az.size:
yaw_bins, _ = _centred_shift(self.prev_az, az, max_shift=40)
yaw = yaw_bins * float(tf.layout.az_step_deg)
ds = self._filter_speed(ds, conf, dt)
self.prev_profile = prof
self.prev_az = az
self.prev_stamp = stamp
self.prev_r = np.where(tf.valid, tf.r, np.nan).astype(np.float32)
self.prev_pts = _sample_points(tf, self.n_sample)
self.prev_ds = ds if self.prev_ds is None else 0.6 * ds + 0.4 * self.prev_ds
return EgoMotion(ds=ds, speed=ds / dt, yaw_deg=yaw, conf=float(conf), dt=dt)
def _filter_speed(self, ds: float, conf: float, dt: float) -> float:
"""Сгладить оценку скорости с учётом физики состава.
Сопоставление кадров иногда «срывается» на резкой смене обстановки —
например на переходе круглого тоннеля в двухпутный — и выдаёт то ноль,
то предел диапазона поиска. Поезд так не умеет: за 0.1 с скорость не
меняется больше чем на a·dt. Измерение принимается с весом, равным
согласию перепроекции, и ограничивается физическим пределом ускорения,
поэтому редкий срыв сглаживается, а настоящее торможение отслеживается
за десятые доли секунды.
"""
self.n_updates += 1
v_meas = ds / max(dt, 1e-3)
if self.v_filt is None or self.n_updates <= WARMUP_FRAMES:
self.v_filt = v_meas
return ds
gain = float(np.clip(conf, 0.05, 0.6))
v = self.v_filt + gain * (v_meas - self.v_filt)
limit = MAX_ACCEL * dt
v = float(np.clip(v, self.v_filt - limit, self.v_filt + limit))
self.v_filt = max(v, 0.0)
return self.v_filt * dt
def _refine(self, tf, seeds: list[float], dt: float):
"""Двухэтапный перебор сдвига вокруг стартовых гипотез."""
pts = getattr(self, "prev_pts", None)
if pts is None or pts.shape[0] < 500:
return None
if self.indexer is None or self.indexer.n_az != tf.layout.n_az:
self.indexer = RayIndexer(tf.layout)
hi = MAX_SPEED * dt
# гипотезы от дешёвых оценок плюс редкая сетка на весь диапазон;
# округление до 10 см убирает дубликаты и держит число проб низким
grid = {round(float(np.clip(s, 0.0, hi)), 1) for s in seeds}
for s in list(grid):
grid.update(round(float(np.clip(s + o, 0.0, hi)), 1) for o in (-0.5, 0.5))
grid.update(round(float(x), 1) for x in np.linspace(0.0, hi, 8))
cand = np.array(sorted(grid))
sc = np.array([advance_score(pts, tf.r, tf.valid, self.indexer, s) for s in cand])
best = float(cand[int(np.argmax(sc))])
fine = np.clip(best + np.linspace(-0.2, 0.2, 5), 0.0, hi)
sf = np.array([advance_score(pts, tf.r, tf.valid, self.indexer, s) for s in fine])
i = int(np.argmax(sf))
if sf[i] < 0.05:
return None
# уточнение параболой по трём точкам вокруг лучшей
ds = float(fine[i])
if 0 < i < fine.size - 1:
y0, y1, y2 = sf[i - 1], sf[i], sf[i + 1]
den = y0 - 2 * y1 + y2
if abs(den) > 1e-9:
ds += 0.5 * (y0 - y2) / den * (fine[1] - fine[0])
return float(np.clip(ds, 0.0, hi)), float(sf[i])
def _direct_advance(self, tf, d_lo: float = 35.0, d_hi: float = 200.0,
grad_max: float = 0.6) -> float | None:
"""Медианное приближение дальних поверхностей, обращённых к сенсору.
Стены тоннеля идут почти вдоль движения, и их дальность при езде почти
не меняется, поэтому они отбрасываются по градиенту дальности вдоль
строки: остаются только фронтальные поверхности, для которых убывание
дальности равно пройденному пути.
"""
cur = np.where(tf.valid, tf.r, np.nan).astype(np.float32)
prev = getattr(self, "prev_r", None)
if prev is None or prev.shape != cur.shape:
return None
with np.errstate(invalid="ignore"):
grad = np.abs(np.gradient(cur, axis=1))
m = (np.isfinite(cur) & np.isfinite(prev) & (cur > d_lo) & (cur < d_hi)
& (grad < grad_max))
if m.sum() < 150:
return None
diff = prev[m] - cur[m]
diff = diff[np.abs(diff) < MAX_SPEED * 0.12]
if diff.size < 100:
return None
return float(np.clip(np.median(diff), 0.0, MAX_SPEED * 0.12))
def _sample_points(tf, n: int) -> np.ndarray:
"""Равномерная выборка точек кадра: (N, 3) = (d, u, z) в системе сенсора.
Берётся именно z сенсора, а не высота над рельсом: перепроекция идёт в
решётку лучей, а она задана относительно сенсора.
"""
m = tf.valid & (tf.d > 8.0) & (tf.d < 200.0)
idx = np.flatnonzero(m.ravel())
if idx.size == 0:
return np.zeros((0, 3), np.float32)
if idx.size > n:
idx = idx[:: max(1, idx.size // n)][:n]
return np.stack([tf.d.ravel()[idx], tf.u.ravel()[idx], tf.z.ravel()[idx]],
axis=1).astype(np.float32)
def _centred_shift(prev: np.ndarray, cur: np.ndarray, max_shift: int) -> tuple[float, float]:
"""Сдвиг в обе стороны — для рыскания."""
a, b = _norm(prev), _norm(cur)
n = a.size
ks = np.arange(-max_shift, max_shift + 1)
sc = np.empty(ks.size, np.float32)
for i, k in enumerate(ks):
if k >= 0:
m = n - k
sc[i] = float(np.dot(b[:m], a[k:k + m]))
else:
m = n + k
sc[i] = float(np.dot(b[-k:-k + m], a[:m]))
i = int(np.argmax(sc))
return float(ks[i]), float(sc[i])
# --------------------------------------------------------------------------- T4/T5
class EmdBank:
"""Коррелятор Хассенштайна–Райхардта на четыре направления.
Работает на прорежённой решётке: широкопольным клеткам мухи тоже не нужна
полная разрешающая способность фасеток, им важна статистика по полю.
"""
DIRECTIONS = ((0, 1), (0, -1), (1, 0), (-1, 0)) # (Δкольцо, Δстолбец)
def __init__(self, decimate: tuple[int, int] = (2, 4), tau_frames: float = 1.5):
self.dec = decimate
self.alpha = float(np.exp(-1.0 / max(tau_frames, 1e-3)))
self.delayed: np.ndarray | None = None
def _down(self, a: np.ndarray) -> np.ndarray:
dh, dw = self.dec
h = a.shape[0] // dh * dh
w = a.shape[1] // dw * dw
return a[:h, :w].reshape(h // dh, dh, w // dw, dw).mean(axis=(1, 3))
def update(self, signal: np.ndarray) -> np.ndarray:
"""Вернуть (4, h, w) откликов на движение в четырёх направлениях."""
s = self._down(signal).astype(np.float32)
if self.delayed is None or self.delayed.shape != s.shape:
self.delayed = s.copy()
return np.zeros((4,) + s.shape, np.float32)
d = self.delayed
out = np.zeros((4,) + s.shape, np.float32)
for k, (di, dj) in enumerate(self.DIRECTIONS):
a = np.roll(s, (di, dj), axis=(0, 1))
ad = np.roll(d, (di, dj), axis=(0, 1))
out[k] = d * a - s * ad # задержанный × соседний, антисимметрично
self.delayed = self.alpha * d + (1.0 - self.alpha) * s
return out
def looming(emd: np.ndarray) -> np.ndarray:
"""LPLC2: отклик на поток, расходящийся из центра рецептивного поля.
Дендриты LPLC2 разложены по четырём слоям так, что каждый слой принимает
T4/T5 «своего» направления с той стороны поля, куда поток должен уходить при
надвигании. Сумма четырёх слоёв и есть дивергенция потока.
"""
right, left, down, up = emd
div = np.zeros_like(right)
div[:, 1:-1] += right[:, 2:] - left[:, :-2]
div[1:-1, :] += down[2:, :] - up[:-2, :]
return np.maximum(div, 0.0)

482
flyguard/mushroom_body.py Normal file
View file

@ -0,0 +1,482 @@
"""MUSHROOM BODY — память нормального тоннеля и детектор новизны.
Схема взята из коннектома напрямую и почти не изменена:
* **PN → KC.** Около 2000 клеток Кеньона на полушарие; каждая получает вход
всего от ~6 проекционных нейронов, выбранных почти случайно (в FlyWire и
hemibrain среднее число «когтей» ≈ 5–7). Это случайная разрежённая проекция,
поднимающая размерность примерно в 40 раз.
* **APL.** Один гигантский тормозный нейрон собирает активность всех KC и
возвращает торможение всем сразу. Результат — «победитель забирает всё»:
одновременно активны около 5 % клеток Кеньона.
* **KC → MBON.** Выход считывается немногими выходными нейронами. Синапсы
KC→MBON **депрессируются** при повторном предъявлении стимула, поэтому
знакомый запах даёт слабый ответ, а новый — сильный. Именно так устроена
«familiarity suppression» у MBON-α′3.
Почему это точно наша задача. Разметки нет, и почти все данные — пустой тоннель.
Геометрический слой честно сообщает обо всём, что торчит в габарит, и вместе с
препятствиями выдаёт кабельные лотки, ниши, гермозатворы, кромки платформ и
стрелочные приводы. Грибовидное тело обучается на пустых проездах **без единой
метки** и после этого молча гасит всё, что тоннель уже показывал, оставляя
громкими только незнакомые формы.
Хранится не образ, а битовый код: поиск знакомости — это индексация массива,
поэтому стадия стоит микросекунды и легко укладывается в реальное время.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from .lobula import Candidate
# имена признаков-«проекционных нейронов»; порядок фиксирован и входит в артефакт
FEATURES = (
"log_d", "lat", "abs_lat", "h", "log_w", "log_h", "aspect",
"log_rays", "fill", "ang_w", "ang_h", "log_gap", "shadow",
"inten", "el", "floor_def", "rings", "cols",
"log_depth", "elongation", "containment", "h_min",
)
def describe(c: Candidate) -> np.ndarray:
"""Вектор признаков кандидата — активность «проекционных нейронов».
Сознательно смешаны две группы: форма и угловой размер (обобщаются на любой
тоннель) и положение в сечении (именно оно делает лоток лотком). Первая даёт
переносимость, вторая — способность запомнить штатную обстановку.
"""
d = max(c.d, 1.0)
w = max(c.width, 0.02)
hh = max(c.height, 0.02)
area = max((c.bbox[1] - c.bbox[0] + 1) * (c.bbox[3] - c.bbox[2] + 1), 1)
return np.array([
np.log(d),
c.u,
abs(c.u),
c.h,
np.log(w),
np.log(hh),
np.log(w / hh),
np.log(max(c.n_rays, 1)),
c.n_rays / area,
np.degrees(w / d),
np.degrees(hh / d),
np.log1p(max(c.gap, 0.0)),
c.shadow,
np.log1p(max(c.inten, 0.0)),
c.el_deg,
np.log1p(max(c.floor_deficit, 0.0)),
np.log(max(c.n_rings, 1)),
np.log(max(c.n_cols, 1)),
np.log1p(max(c.depth, 0.0)),
np.log(max(c.depth, 0.05) / max(w, hh)), # вытянутость вдоль пути
c.containment,
c.h_min, # есть ли опора снизу
], dtype=np.float32)
@dataclass
class MushroomBodyConfig:
"""Параметры памяти.
`claws = 6` взято прямо из коннектома: клетка Кеньона получает в среднем
5–7 входов от проекционных нейронов. Это значение и оказалось лучшим в
подборе (`tools/tune_memory.py`): 4 когтя дают AUC 0.76, 8 — 0.89, 10 —
0.87, шесть — 0.89…0.90.
Число клеток Кеньона, напротив, увеличено против мушиных ~2000. Причина
инженерная: муха за жизнь встречает сотни запахов, а нам нужно запомнить
десятки тысяч видов тоннельной обстановки. При 2000 клетках и 5 % активных
память насыщается после нескольких тысяч примеров — подавлены все синапсы, и
новым не выглядит уже ничто, включая настоящее препятствие. Разрежённость
поэтому тоже снижена: активны 50 клеток из 50 000.
"""
n_kc: int = 50_000 # клеток Кеньона
claws: int = 6 # входов на клетку Кеньона (из коннектома)
sparsity: float = 0.001 # доля активных после торможения APL
seed: int = 20240617
class MushroomBody:
"""Разрежённое кодирование + выученная знакомость."""
def __init__(self, cfg: MushroomBodyConfig | None = None, n_pn: int | None = None):
self.cfg = cfg or MushroomBodyConfig()
self.n_pn = n_pn or len(FEATURES)
self.n_active = max(1, int(round(self.cfg.n_kc * self.cfg.sparsity)))
rng = np.random.default_rng(self.cfg.seed)
# разрежённая случайная проекция PN → KC: по `claws` входов на клетку
idx = np.stack([rng.choice(self.n_pn, size=self.cfg.claws, replace=False)
for _ in range(self.cfg.n_kc)])
sign = rng.choice((-1.0, 1.0), size=idx.shape)
w = np.zeros((self.cfg.n_kc, self.n_pn), np.float32)
np.put_along_axis(w, idx, sign.astype(np.float32), axis=1)
self.W = w
# нормировка входа (аналог дивизивной нормировки на общую интенсивность)
self.mean = np.zeros(self.n_pn, np.float32)
self.scale = np.ones(self.n_pn, np.float32)
# вес синапсов KC → MBON: 1 — «не видели», 0 — «совсем привычно»
self.w_mbon = np.ones(self.cfg.n_kc, np.float32)
self.n_seen = 0
# ------------------------------------------------------------------ кодирование
def fit_normalizer(self, X: np.ndarray) -> None:
self.mean = X.mean(0).astype(np.float32)
s = X.std(0).astype(np.float32)
self.scale = np.where(s > 1e-6, s, 1.0).astype(np.float32)
def encode(self, X: np.ndarray, device: str | None = None) -> np.ndarray:
"""Вектора признаков → индексы активных клеток Кеньона, (N, n_active).
`device="cuda"` переносит проекцию и отбор на GPU: обучение на сотнях
тысяч примеров так идёт в десятки раз быстрее. В инференсе кандидатов
единицы, и путь через CPU заведомо дешевле запуска ядра.
"""
X = np.atleast_2d(np.asarray(X, np.float32))
if X.shape[1] != self.n_pn:
raise ValueError(
f"память обучена на {self.n_pn} признаках, а дескриптор даёт "
f"{X.shape[1]}: набор признаков менялся — переобучите память "
f"(tools/train_mushroom_body.py)")
k = self.n_active
# матрица откликов (N × n_kc) при большой выборке не помещается в память
# целиком, поэтому кодирование идёт порциями фиксированного объёма
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
if device and device != "cpu":
import torch
out = np.empty((X.shape[0], k), np.int64)
with torch.no_grad():
m = torch.as_tensor(self.mean, device=device)
s = torch.as_tensor(self.scale, device=device)
w = torch.as_tensor(self.W, device=device).T.contiguous()
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=device)
y = ((t - m) / s) @ w
out[i:i + chunk] = torch.topk(y, k, dim=1).indices.cpu().numpy()
return out
if X.shape[0] <= chunk:
z = (X - self.mean) / self.scale
y = z @ self.W.T # (N, n_kc)
# APL: глобальное торможение оставляет только сильнейшие
return np.argpartition(-y, k - 1, axis=1)[:, :k]
out = np.empty((X.shape[0], k), np.int64)
for i in range(0, X.shape[0], chunk):
z = (X[i:i + chunk] - self.mean) / self.scale
y = z @ self.W.T
out[i:i + chunk] = np.argpartition(-y, k - 1, axis=1)[:, :k]
return out
# ------------------------------------------------------------------ обучение
def auto_rate(self, n_samples: int, target: float = 1.5) -> float:
"""Темп депрессии, согласованный с размером обучающей выборки.
Каждый пример депрессирует `n_active` клеток, поэтому на одну клетку
Кеньона в среднем приходится `n · n_active / n_kc` попаданий. Если темп
не уменьшать вместе с ростом выборки, после нескольких десятков тысяч
примеров подавлены все синапсы и новым не выглядит уже ничто.
Темп выбирается так, чтобы «типичная» клетка ослабла примерно в e^target
раз. Тогда шкала новизны отражает **частоту** обстановки: постоянно
встречающиеся лотки и ниши уходят в ноль, а редкая форма остаётся
заметной. Это и есть смысл familiarity suppression, а не «видел хоть раз».
"""
mean_hits = max(n_samples * self.n_active / self.cfg.n_kc, 1e-6)
return float(np.clip(target / mean_hits, 1e-4, 0.5))
def learn(self, X: np.ndarray, rate: float = 0.35, device: str | None = None) -> None:
"""Депрессия синапсов KC→MBON на предъявленных примерах.
Каждое предъявление умножает вес активных синапсов на (1 − rate), что
для набора сразу эквивалентно возведению в степень по числу попаданий, —
поэтому цикл по примерам не нужен.
"""
act = self.encode(X, device=device)
cnt = np.bincount(act.ravel(), minlength=self.cfg.n_kc)
self.w_mbon *= np.power(1.0 - rate, cnt).astype(np.float32)
self.n_seen += len(act)
def novelty(self, X: np.ndarray) -> np.ndarray:
"""Ответ MBON: 1 — совершенно ново, 0 — вполне привычно."""
act = self.encode(X)
return self.w_mbon[act].mean(axis=1)
def novelty_of(self, c: Candidate) -> float:
return float(self.novelty(describe(c)[None, :])[0])
def annotate(self, cands: list[Candidate]) -> list[Candidate]:
if not cands:
return cands
X = np.stack([describe(c) for c in cands])
nov = self.novelty(X)
for c, n in zip(cands, nov):
c.novelty = float(n)
return cands
# ------------------------------------------------------------------ хранение
def save(self, path: str | Path) -> None:
np.savez_compressed(path, w_mbon=self.w_mbon, mean=self.mean, scale=self.scale,
n_kc=self.cfg.n_kc, claws=self.cfg.claws,
sparsity=self.cfg.sparsity, seed=self.cfg.seed,
n_pn=self.n_pn, n_seen=self.n_seen,
features=np.array(FEATURES))
@staticmethod
def load(path: str | Path) -> "MushroomBody":
d = np.load(path, allow_pickle=False)
cfg = MushroomBodyConfig(n_kc=int(d["n_kc"]), claws=int(d["claws"]),
sparsity=float(d["sparsity"]), seed=int(d["seed"]))
mb = MushroomBody(cfg, n_pn=int(d["n_pn"]))
mb.w_mbon = d["w_mbon"].astype(np.float32)
mb.mean = d["mean"].astype(np.float32)
mb.scale = d["scale"].astype(np.float32)
mb.n_seen = int(d["n_seen"])
return mb
# Признаки для привыкания. Отличие от `FEATURES` принципиальное: здесь нет
# ничего, что меняется при подъезде к одному и тому же предмету. Ни дальности,
# ни числа лучей, ни углового размера, ни интенсивности (она падает как 1/R²),
# ни контраста в метрах (он растёт с дальностью). Остаются физические свойства
# формы: где она стоит в сечении, какая она и есть ли под ней опора.
#
# Иначе механизм не работает вовсе: код одного и того же кронштейна, снятого с
# 90 и с 40 м, разъезжается, повторы не узнаются, а подъезжающий предмет,
# наоборот, каждый кадр выглядит новой формой и привыкает сам к себе. Замерено:
# на полном наборе признаков за двенадцать разных мест новизна падала с 1.000
# до 0.981 — то есть ни на что.
#
# У мухи ту же роль играет инвариантность к концентрации запаха: APL нормирует
# общий уровень возбуждения, и один и тот же запах слабее или сильнее даёт
# почти один и тот же набор активных клеток Кеньона.
HAB_FEATURES = ("lat", "abs_lat", "h", "h_min", "log_w", "log_h",
"aspect", "log_depth", "elongation", "containment")
def describe_shape(c: Candidate) -> np.ndarray:
"""Вектор формы, не зависящий от дальности до неё."""
w = max(c.width, 0.02)
hh = max(c.height, 0.02)
return np.array([
c.u,
abs(c.u),
c.h,
c.h_min,
np.log(w),
np.log(hh),
np.log(w / hh),
np.log1p(max(c.depth, 0.0)),
np.log(max(c.depth, 0.05) / max(w, hh)),
c.containment,
], dtype=np.float32)
@dataclass
class HabituationConfig:
"""Параметры привыкания внутри одного проезда.
`n_kc` намеренно маленькое — 4000 против 50 000 у долговременной памяти, то
есть примерно как у самой мухи. Задача здесь обратная: не различить десятки
тысяч видов обстановки, а **обобщить** — чтобы седьмой кронштейн гасился
первыми шестью, хотя он и не в точности такой же. Чем меньше популяция и
чем шире разрежённость, тем сильнее перекрываются коды похожих форм.
"""
n_kc: int = 4000
claws: int = 4
sparsity: float = 0.02 # 80 активных клеток
seed: int = 20260214
rate: float = 0.25 # депрессия за одно НОВОЕ место
place_m: float = 5.0 # насколько разнести места, чтобы считать их разными
same_frac: float = 0.30 # доля клеток «с этого же места» — значит, то же самое
read_q: float = 0.75 # квантиль отсчёта MBON: доля клеток, которых мало
quant: float = 0.5 # шаг огрубления признаков, в долях разброса
recover_m: float = 800.0 # путь, за который привыкание сходит наполовину
warmup: int = 60 # кандидатов на разогрев нормировки
norm_n: int = 300 # после стольких нормировка замирает
min_novelty: float = 0.05 # ниже этого привыкание не опускает
class Habituation:
"""Кратковременное привыкание: гасит то, что повторяется в РАЗНЫХ местах пути.
Обученная память решает задачу «этот тоннель я уже видел». На новом участке
она бесполезна по определению: новизна у всего максимальна, улика копится
беспрепятственно, и ложных тревог становится 24.3 на километр вместо 9.1.
А приватный тест — это как раз новый участок.
Но даже на совершенно незнакомой линии есть признак, не требующий ни единого
прошлого проезда. **Тоннельная обстановка повторяется вдоль пути, а
посторонний предмет — нет.** Кабельный кронштейн, рама крепи, стык тюбингов
встречаются каждые несколько метров в одном и том же виде; упавший предмет
лежит в одном месте. Поэтому привыкание считается не по времени и не по
числу кадров, а по **числу разных точек пути**, где встретилась эта форма.
Разница принципиальна. Настоящий предмет виден сто кадров подряд, но всё это
время он стоит в одной точке мира: его код депрессируется один раз и
остаётся новым до конца подъезда. Кронштейны за те же сто кадров дают
десятки разных точек и гаснут.
Биологически это та же схема, только с другим учителем. Синапсы KC→MBON
депрессируются при повторном предъявлении — это и есть familiarity
suppression MBON-α′3. Момент депрессии здесь разрешает координата пути из
центрального комплекса: у мухи дофаминергические PPL1/PAM точно так же
решают, когда пластичности быть, и их вход зависит от состояния животного,
а не только от стимула.
Привыкание постепенно сходит (`recover_m`): выехав с однообразного перегона
на станцию, система снова смотрит в оба.
"""
def __init__(self, cfg: HabituationConfig | None = None):
self.cfg = cfg or HabituationConfig()
self.enc = MushroomBody(
MushroomBodyConfig(n_kc=self.cfg.n_kc, claws=self.cfg.claws,
sparsity=self.cfg.sparsity, seed=self.cfg.seed),
n_pn=len(HAB_FEATURES))
n = self.cfg.n_kc
self.w = np.ones(n, np.float32)
# где в последний раз эта клетка Кеньона участвовала в наблюдении
self.last_s = np.full(n, -1e9, np.float32)
self.places = 0
self._n = 0
self._sum = np.zeros(len(HAB_FEATURES), np.float64)
self._sq = np.zeros(len(HAB_FEATURES), np.float64)
@property
def level(self) -> float:
"""Насколько привыкла популяция: 0 — всё ново, 1 — всё знакомо."""
return float(1.0 - self.w.mean())
# ------------------------------------------------------------------ такт
def advance(self, ds: float) -> None:
"""Забывание по пройденному пути."""
if ds <= 0 or self.cfg.recover_m <= 0:
return
k = 1.0 - 0.5 ** (ds / self.cfg.recover_m)
self.w += (1.0 - self.w) * k
def _fit(self, X: np.ndarray) -> None:
"""Дивизивная нормировка по накопленной статистике проезда.
После `norm_n` кандидатов замирает, и это не мелочь. Пока нормировка
плывёт, вместе с ней плывёт и код: у неподвижного предмета, чьи признаки
формы вообще не меняются, набор активных клеток обновлялся настолько,
что предмет засчитывался как двадцать шесть разных мест и гасил сам
себя. С замороженной нормировкой одна и та же форма даёт один и тот же
код, и правило «то же место — не повтор» наконец работает.
Порог низкий намеренно. При 4000 в бэге `roundT_doubleT` (всего около
570 кандидатов за проезд) нормировка не замирала вовсе: привыкание
осталось на 0.20, медианная новизна кандидата 1.00 и ложных треков
ровно столько же, сколько без привыкания. Трёхсот кандидатов на десять
признаков для оценки среднего и разброса достаточно с запасом.
"""
self._n += X.shape[0]
if self._n > self.cfg.norm_n:
return
self._sum += X.sum(0)
self._sq += (X.astype(np.float64) ** 2).sum(0)
m = self._sum / self._n
v = np.maximum(self._sq / self._n - m * m, 1e-12)
self.enc.mean = m.astype(np.float32)
self.enc.scale = np.sqrt(v).astype(np.float32)
def _coarse(self, X: np.ndarray) -> np.ndarray:
"""Огрубить признаки до шага `quant` разброса — до кодирования.
Без этого механизм не работает вовсе. Два кронштейна, снятые в разных
местах, отличаются на сантиметры, случайная проекция разносит их в
разные наборы клеток, и второй не узнаёт первого. Замер: при ёмкости,
достаточной чтобы не насыщаться (20 000 клеток), привыкание давало
ровно ноль — 7.5 ложных трека на км против тех же 7.5 без него. Весь
видимый эффект маленькой популяции был не узнаванием повторов, а
насыщением, которое одинаково давит и обстановку, и предмет.
Огрубление до половины разброса заставляет близкие формы давать
буквально один и тот же код. Это coarse coding, и у мухи оно тоже есть:
клетка Кеньона реагирует на широкую область пространства запахов, а не
на точку.
"""
q = self.cfg.quant
if q <= 0:
return X
z = (X - self.enc.mean) / self.enc.scale
return (np.round(z / q) * q) * self.enc.scale + self.enc.mean
def update(self, cands: list[Candidate], s_path: float, ds: float) -> None:
"""Приглушить знакомое и запомнить то, что встретилось в новом месте.
`s_path` — пройденный путь, м; положение формы в мире считается как
`s_path + c.d`, поэтому неподвижный предмет остаётся в одной точке,
сколько бы кадров к нему ни ехали. Оценка собственного движения копит
около 3 % ошибки, то есть за подъезд с сотни метров точка уползает на
два-три метра — отсюда и `place_m`: меньший разнос считается тем же
местом, больший — новым.
"""
self.advance(ds)
if not cands:
return
X = np.stack([describe_shape(c) for c in cands])
self._fit(X)
if self._n < self.cfg.warmup:
return # нормировка ещё не осмысленна
act = self.enc.encode(self._coarse(X))
# Отсчёт берётся КВАНТИЛЕМ, а не средним, и это решает главную проблему
# механизма. Коды похожих форм перекрываются частично: кронштейн, гасясь
# сам, задевает часть клеток предмета. При среднем половина подавленных
# клеток уже вдвое снижает новизну предмета — замерено, что новизна
# вставленного человека падала с 0.76 до 0.20, а обнаружение на 70–90 м
# со 100 % до 58 %. Квантиль 0.75 требует, чтобы подавлено было не менее
# трёх четвертей клеток кода: частичное перекрытие форму уже не гасит,
# а настоящий повтор гасит полностью.
fam = np.quantile(self.w[act], self.cfg.read_q, axis=1)
lo = self.cfg.min_novelty
for c, f in zip(cands, fam):
# Берётся минимум, а не произведение: два канала знакомости, и
# достаточно одного, чтобы приглушить. Перемножение давило бы
# предмет дважды за одно и то же.
c.novelty = float(max(min(c.novelty, f), lo))
keep = 1.0 - self.cfg.rate
for i, c in enumerate(cands):
s_obj = np.float32(s_path + c.d)
a = act[i]
same = np.abs(self.last_s[a] - s_obj) <= self.cfg.place_m
# Отметка ставится всегда, в том числе клеткам, впервые попавшим в
# код: иначе подъезжающий предмет каждый кадр вербует свежие клетки
# и депрессирует сам себя.
self.last_s[a] = s_obj
if same.mean() >= self.cfg.same_frac:
continue # то же самое место — это не повтор
self.w[a] *= keep
self.places += 1
class NoMemory:
"""Заглушка: пока память не обучена, всё считается новым."""
n_seen = 0
@staticmethod
def annotate(cands: list[Candidate]) -> list[Candidate]:
return cands
@staticmethod
def novelty_of(_c: Candidate) -> float:
return 1.0

328
flyguard/pipeline.py Normal file
View file

@ -0,0 +1,328 @@
"""Сборка конвейера: от облака точек до решения.
Порядок стадий повторяет путь сигнала в мозге мухи:
ретина → стабилизация → ламина → медулла/лобулярная пластинка
→ лобула → грибовидное тело → центральный комплекс → нисходящие нейроны
Конвейер держит состояние между кадрами (поза, коридор, собственное движение,
треки), поэтому один экземпляр обслуживает один поток лидарных данных.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
import numpy as np
from . import lamina
from .cdr import PointCloud2
from .central_complex import CentralComplex
from .descending import Decision, DescendingNeurons
from .geometry import (STRAIGHT, Corridor, RailPlane, TrackFrame, fit_corridor,
fit_rail_plane)
from .fan_body import FanBody
from .lobula import Candidate, find_candidates, gauge_mask
from .medulla import EgoMotion, EgoMotionEstimator, EmdBank, looming
from .mbon_readout import MbonReadout
from .mushroom_body import Habituation, HabituationConfig, MushroomBody, NoMemory
from .retina import RangeImage, ScanLayout
@dataclass
class Params:
"""Все настройки конвейера в одном месте."""
fov_deg: float = 30.0 # полусектор обработки по азимуту
half_width: float = 1.6 # полуширина габарита, м
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
h_hi: float = 2.3 # верхняя граница, м
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
# порог скорости сближения вдоль луча, в долях пройденного пути.
# Поднимает рабочую дальность с 62 до 80 м, но вчетверо увеличивает ложные
# тревоги: в тоннеле полно поверхностей, которые приближаются так же, как
# предмет. 0 — выключено; рабочее значение 0.6. Кривая — EXPERIMENTS п. 9.4.
split_adv: float = 0.0
# Разделение фигуры и фона по контрасту ламины (см. lobula.split_by_figure):
# порог «насколько ближе своего окружения», м. Гладкая стена даёт ноль по
# построению центр-окружения, предмет на стене — ступеньку. Именно это
# вытаскивает предмет из компоненты, растёкшейся вдоль стены круглого
# тоннеля, где покадровый тракт слеп целиком. 0 — выключено.
split_gap: float = 6.0
split_near: float = 55.0 # ближе этого не резать: там и так видно
split_top: int = 1 # сколько фигур выносить из одной компоненты
# Накопление слабых улик в координатах пути (см. fan_body.py). Заменяет
# недоступный на большой дальности контраст к фону: на 140…185 м `gap`
# структурно равен нулю, и без накопления улика не растёт вовсе.
enable_accumulator: bool = True
acc_near: float = 55.0 # ближе этого покадровый тракт и так справляется
acc_gain: float = 1.5 # опора накопителя, засчитываемая за полный контраст
# Привыкание внутри проезда (см. mushroom_body.Habituation). Замысел: гасить
# формы, встретившиеся в НЕСКОЛЬКИХ разных точках пути, то есть штатную
# повторяющуюся обстановку, без единого прошлого проезда.
#
# ПО УМОЛЧАНИЮ ВЫКЛЮЧЕНО — измерено, что избирательности у механизма нет.
# При ёмкости, достаточной чтобы популяция не насыщалась (20 000 клеток),
# привыкание не меняет ничего: 7.5 ложных трека на км против тех же 7.5.
# Весь эффект маленькой популяции (5.9 на км) оказался насыщением, которое
# давит без разбора: новизна вставленного предмета падала до 0.24 при
# медианной новизне кандидата 0.43…0.71, то есть предмет подавлялся
# СИЛЬНЕЕ обстановки. Огрубление признаков и резкий отсчёт MBON не помогли.
# Разбор — EXPERIMENTS п. 10.
enable_habituation: bool = False
hab_rate: float = 0.25 # депрессия за одно новое место
hab_place_m: float = 5.0 # насколько разнести места, чтобы считать разными
hab_recover_m: float = 800.0 # путь, за который привыкание сходит наполовину
hab_n_kc: int = 20000 # ёмкость короткой памяти, клеток Кеньона
hab_read_q: float = 0.75 # квантиль отсчёта MBON
hab_quant: float = 0.5 # огрубление признаков, в долях разброса
# Обученное считывание MBON (см. mbon_readout.py). Заменяет ручную формулу
# веса улики в центральном комплексе там, где модель передана конвейеру;
# без модели всё работает по-прежнему.
enable_mbon: bool = True
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
d_min: float = 4.0
d_max: float = 220.0
min_rays: int = 4
calib_frames: int = 12
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
enable_motion: bool = True
enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
# схемы мозга; на решение пока не влияют
# Переключатели для абляции: каждый отключает один механизм, оставляя
# остальные. Нужны, чтобы вклад механизма измерялся, а не декларировался.
use_corridor: bool = True # ось пути; False — прямой коридор вперёд
use_shape: bool = True # целостность, компактность, опора снизу
use_tracking: bool = True # накопление улик по кадрам
@dataclass
class FrameResult:
"""Всё, что конвейер узнал за один кадр."""
stamp: float
decision: Decision
candidates: list[Candidate]
ego: EgoMotion | None
plane: RailPlane
corridor: Corridor
tf: TrackFrame | None = None
lam: lamina.LaminaOutput | None = None
emd: np.ndarray | None = None # (4, h, w) отклики T4/T5
loom: np.ndarray | None = None # (h, w) отклик LPLC2
hab: float = 0.0 # привыкание популяции KC, 0…1
timings: dict = field(default_factory=dict)
@property
def total_ms(self) -> float:
return sum(self.timings.values())
class FlyGuard:
"""Полный конвейер обнаружения посторонних объектов."""
def __init__(self, params: Params | None = None,
memory: MushroomBody | None = None,
layout: ScanLayout | None = None,
readout: "MbonReadout | None" = None):
self.p = params or Params()
self.memory = memory if (memory is not None and self.p.enable_memory) else NoMemory()
self.readout = readout if (readout is not None and self.p.enable_mbon) else None
self.layout_full = layout
self.layout: ScanLayout | None = None
self.cols: slice | None = None
self.plane: RailPlane | None = None
self._prev_r: np.ndarray | None = None
self.corridor: Corridor | None = None
self.ego_est = EgoMotionEstimator()
self.fan: FanBody | None = None
self.s_path = 0.0 # пройденный путь, м — координата привыкания
self.hab = (Habituation(HabituationConfig(
n_kc=self.p.hab_n_kc, sparsity=80.0 / max(self.p.hab_n_kc, 1),
rate=self.p.hab_rate, place_m=self.p.hab_place_m,
read_q=self.p.hab_read_q, quant=self.p.hab_quant,
recover_m=self.p.hab_recover_m))
if self.p.enable_habituation else None)
self.emd_bank = EmdBank()
self.cx = CentralComplex(use_shape=self.p.use_shape,
mbon_power=self.p.mbon_power,
mbon_blend=self.p.mbon_blend)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons() if self.p.use_tracking
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1))
self._calib: list[PointCloud2] = []
self.frames_seen = 0
# ------------------------------------------------------------------ калибровка
def _ensure_layout(self, pc: PointCloud2) -> bool:
"""Накопить кадры и откалибровать решётку. False — ещё не готов."""
if self.layout is not None:
return True
if self.layout_full is None:
self._calib.append(pc)
if len(self._calib) < self.p.calib_frames:
return False
self.layout_full = ScanLayout.calibrate(self._calib)
self._calib.clear()
self.cols = self.layout_full.column_slice(self.p.fov_deg)
self.layout = self.layout_full.sub(self.cols)
return True
# ------------------------------------------------------------------ накопление
def _accumulate(self, tf, inside, lat, ds: float,
cands: list[Candidate]) -> None:
"""Досыпать кадр в веерное тело и проставить опору каждому кандидату."""
if not self.p.enable_accumulator:
return
if self.fan is None:
self.fan = FanBody(d_max=self.p.d_max, half_width=self.p.half_width,
h_lo=self.p.h_lo, h_hi=self.p.h_hi,
d_near=self.p.acc_near)
self.fan.update(tf.d[inside], lat[inside], tf.h[inside], ds)
if not cands:
return
d = np.array([c.d for c in cands], np.float32)
u = np.array([c.u for c in cands], np.float32)
g = max(self.p.acc_gain, 1e-3)
for c, s in zip(cands, self.fan.support_at(d, u)):
c.extra["acc_support"] = float(s) / g
# ------------------------------------------------------------------ фигура и фон
MIN_DS = 0.25 # м: меньше — шум дальности забивает разницу
def _advance(self, tf, ds: float):
"""Скорость сближения вдоль каждого луча, в долях пройденного пути.
Вдоль фиксированного луча стена, параллельная движению, не приближается
вовсе: поезд едет, точка пересечения скользит по стене, дальность
остаётся прежней. Предмет, обращённый к поезду, приближается ровно на
пройденный путь. Отсюда 0 у фона и 1 у фигуры.
Сравниваются два соседних кадра в одной решётке: столбец отвечает
фиксированному азимуту, поэтому достаточно вычитания без перепроекции.
Рысканье в кривой за кадр меньше шага решётки (0.06° при радиусе
1300 м против шага 0.1°), поэтому им пренебрегаем.
"""
cur = np.where(tf.valid, tf.r, np.nan).astype(np.float32)
prev, self._prev_r = self._prev_r, cur
if prev is None or prev.shape != cur.shape or ds < self.MIN_DS:
return None
with np.errstate(invalid="ignore"):
a = (prev - cur) / ds
return np.nan_to_num(a, nan=0.0, posinf=0.0, neginf=0.0)
# ------------------------------------------------------------------ основной такт
def process(self, pc: PointCloud2, *, keep_debug: bool = False) -> FrameResult | None:
"""Обработать один кадр. None, пока идёт калибровка решётки."""
t = _Timer()
if not self._ensure_layout(pc):
return None
self.frames_seen += 1
with t("retina"):
img: RangeImage = self.layout_full.project(pc, self.cols)
with t("stabilize"):
self.plane = fit_rail_plane(img, self.layout, prev=self.plane)
tf = TrackFrame(img, self.layout, self.plane)
with t("corridor"):
self.corridor = (fit_corridor(tf, prev=self.corridor) if self.p.use_corridor
else STRAIGHT)
with t("lamina"):
lam = lamina.process(tf.r, tf.valid)
with t("ego"):
ego = (self.ego_est.update(tf, pc.stamp) if self.p.enable_motion
else EgoMotion(0.0, 0.0, 0.0, 0.0, 0.1))
with t("figure"):
advance = self._advance(tf, ego.ds)
emd = loom = None
if self.p.enable_looming:
with t("medulla"):
emd = self.emd_bank.update(lam.on)
loom = looming(emd)
with t("lobula"):
masks = gauge_mask(tf, self.corridor, half_width=self.p.half_width,
h_lo=self.p.h_lo, h_hi=self.p.h_hi,
d_min=self.p.d_min, d_max=self.p.d_max)
cands = find_candidates(tf, lam, self.corridor, masks=masks,
half_width=self.p.half_width, h_lo=self.p.h_lo,
h_hi=self.p.h_hi, ctx_up=self.p.ctx_up,
split_adv=self.p.split_adv,
split_gap=self.p.split_gap,
split_near=self.p.split_near,
split_top=self.p.split_top, advance=advance,
d_min=self.p.d_min,
d_max=self.p.d_max, min_rays=self.p.min_rays)
with t("mushroom"):
cands = self.memory.annotate(cands)
self.s_path += ego.ds
if self.hab is not None:
self.hab.update(cands, self.s_path, ego.ds)
if self.p.novelty_gate > 0:
cands = [c for c in cands if c.novelty >= self.p.novelty_gate]
with t("fan"):
# Накопитель идёт ПОСЛЕ памяти и мимо неё: память обучена на
# покадровых кандидатах, а у накопленного скопления нет ни
# контраста к фону, ни интенсивности — её признаки для него
# не определены. Судить о нём надо по тому, что у него есть:
# по числу попаданий в одну точку пути и по размеру.
self._accumulate(tf, masks[0], masks[1], ego.ds, cands)
with t("readout"):
# Строго после накопителя: опора веерного тела входит в признаки
# считывания, и на большой дальности это его главный вход.
if self.readout is not None:
self.readout.annotate(cands)
with t("central"):
self.cx.update(cands, ego.ds, ego.dt)
with t("descending"):
decision = self.dn.decide(self.cx, ego.speed)
return FrameResult(stamp=pc.stamp, decision=decision, candidates=cands,
ego=ego, plane=self.plane, corridor=self.corridor,
tf=tf if keep_debug else None,
lam=lam if keep_debug else None,
emd=emd, loom=loom,
hab=self.hab.level if self.hab is not None else 0.0,
timings=t.result)
class _Timer:
"""Замер времени по стадиям — нужен для отчёта о задержке."""
def __init__(self):
self.result: dict[str, float] = {}
self._name = None
self._t0 = 0.0
def __call__(self, name: str):
self._name = name
return self
def __enter__(self):
self._t0 = time.perf_counter()
return self
def __exit__(self, *exc):
self.result[self._name] = (time.perf_counter() - self._t0) * 1e3
return False

351
flyguard/retina.py Normal file
View file

@ -0,0 +1,351 @@
"""RETINA — омматидиальная решётка.
Фасеточный глаз дрозофилы — регулярная решётка омматидиев, каждый смотрит в свою
фиксированную сторону. Вращающийся лидар устроен так же: пара (кольцо, столбец)
задаёт направление луча. Поэтому облако точек сразу переводится в *ретинотопический*
дальностный образ `(кольцо, азимут)`, и вся дальнейшая обработка идёт в этой
решётке — как в зрительной системе мухи, а не в неупорядоченном облаке.
Три особенности конкретного сенсора, измеренные по данным (см. `docs/ALGORITHM.md`):
1. **Раскладка различается между бэгами**: 3600 азимутов на 360° против 1200 на
100°. Решётка калибруется по самим данным, ничего не захардкожено.
2. **Двойное эхо**: соседние столбцы делят один азимут. Когда эхо одно, оба слота
содержат одно значение; когда два — ближнее несёт объект, дальнее фон за ним.
Реально различаются ~3 % лучей, и это именно тонкие предметы и кромки.
3. **Скос решётки**: у каждого лазерного канала свой постоянный азимутальный сдвиг,
разброс достигает **15.5°** (≈155 столбцов). В сыром виде «столбец» не является
направлением: соседние кольца одного столбца смотрят в стороны, разнесённые на
градусы. Поэтому образ **выпрямляется** целочисленным сдвигом строк; остаточная
ошибка < половины шага азимута и учитывается в таблице направлений.
"""
from __future__ import annotations
import warnings
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from .cdr import PointCloud2
DEG = np.pi / 180.0
@dataclass
class RangeImage:
"""Выпрямленный дальностный образ в координатах `(кольцо, азимут)`."""
stamp: float
r_near: np.ndarray # (H, W) float32 — ближнее эхо, 0 = нет эха
r_far: np.ndarray # (H, W) float32 — дальнее эхо, 0 = нет эха
inten: np.ndarray # (H, W) float32 — интенсивность ближнего эха
valid: np.ndarray # (H, W) bool
@property
def shape(self) -> tuple[int, int]:
return self.r_near.shape
def xyz(self, layout: "ScanLayout") -> np.ndarray:
"""Декартовы координаты ближнего эха: (H, W, 3)."""
return layout.dirs * self.r_near[..., None]
def crop(self, cols: slice) -> "RangeImage":
return RangeImage(self.stamp, self.r_near[:, cols], self.r_far[:, cols],
self.inten[:, cols], self.valid[:, cols])
class ScanLayout:
"""Калиброванная и выпрямленная решётка лучей.
Азимут отсчитывается от направления движения (вперёд = −Y в системе сенсора),
положительный вправо; элевация — вверх от горизонтали сенсора.
"""
def __init__(self, el_deg: np.ndarray, az_step_deg: float, az0_deg: float,
col_shift: np.ndarray, az_resid_deg: np.ndarray,
n_az: int, n_echo: int, wrap: bool = False):
self.el_deg = np.asarray(el_deg, np.float64) # (H,)
self.az_step_deg = float(az_step_deg) # шаг на азимутальный индекс
self.az0_deg = float(az0_deg)
self.col_shift = np.asarray(col_shift, np.int64) # (H,) выпрямление
self.az_resid_deg = np.asarray(az_resid_deg, np.float64) # (H,) остаток < шага/2
self.n_az = int(n_az)
self.n_echo = int(n_echo)
self.wrap = bool(wrap) # круговой скан: края смыкаются
self.n_rings = self.el_deg.size
self.az_grid_deg = self.az0_deg + self.az_step_deg * np.arange(self.n_az)
# карта выборки для выпрямления: out[h, j] = raw[h, j + shift[h]]
j = np.arange(self.n_az)[None, :]
src = j + self.col_shift[:, None]
if self.wrap:
# у кругового скана «выехавшие» столбцы приходят с другого края
self.gather = np.mod(src, self.n_az).astype(np.intp)
self.gather_ok = np.ones_like(self.gather, bool)
else:
self.gather_ok = (src >= 0) & (src < self.n_az)
self.gather = np.clip(src, 0, self.n_az - 1).astype(np.intp)
self.az_full_deg = self.az_grid_deg[None, :] + self.az_resid_deg[:, None]
self.dirs = self._unit_dirs().astype(np.float32) # (H, W, 3)
# угловой шаг по кольцам: у решётки из одного кольца градиента нет
self.el_step_deg = (np.abs(np.gradient(self.el_deg)) if self.n_rings > 1
else np.full(self.n_rings, 0.125))
# ------------------------------------------------------------------ геометрия
def _unit_dirs(self) -> np.ndarray:
az = self.az_full_deg * DEG
el = (self.el_deg[:, None] * DEG) * np.ones_like(az)
c = np.cos(el)
return np.stack([c * np.sin(az), -c * np.cos(az), np.sin(el)], axis=-1)
def column_slice(self, half_fov_deg: float) -> slice:
"""Непрерывный диапазон столбцов внутри ±half_fov по азимуту."""
inside = np.flatnonzero(np.abs(self.az_grid_deg) <= half_fov_deg)
if inside.size == 0:
return slice(0, self.n_az)
return slice(int(inside[0]), int(inside[-1]) + 1)
def sub(self, cols: slice) -> "ScanLayout":
"""Урезанная по азимуту копия решётки (для обработки только переднего сектора)."""
start = cols.start or 0
stop = cols.stop if cols.stop is not None else self.n_az
out = ScanLayout.__new__(ScanLayout)
out.el_deg = self.el_deg
out.az_step_deg = self.az_step_deg
out.az0_deg = float(self.az_grid_deg[start])
out.col_shift = self.col_shift
out.az_resid_deg = self.az_resid_deg
out.n_az = stop - start
out.n_echo = self.n_echo
out.wrap = False # вырезанный сектор больше не смыкается
out.n_rings = self.n_rings
out.az_grid_deg = self.az_grid_deg[start:stop]
out.gather_ok = self.gather_ok[:, start:stop]
out.gather = self.gather[:, start:stop]
out.az_full_deg = self.az_full_deg[:, start:stop]
out.dirs = np.ascontiguousarray(self.dirs[:, start:stop])
out.el_step_deg = self.el_step_deg
return out
# ------------------------------------------------------------------ проекция
def project(self, pc: PointCloud2, cols: slice | None = None) -> RangeImage:
"""Облако точек → выпрямленный дальностный образ.
`cols` задаёт нужный диапазон **выходных** столбцов. Тяжёлая
арифметика (корень по 900 тыс. точек) выполняется только над теми
сырыми столбцами, которые в этот диапазон попадут с учётом скоса
каналов, — на круговом скане это экономит почти всё время стадии.
"""
n, w, e = self.n_rings, self.n_az, self.n_echo
start = 0 if cols is None else (cols.start or 0)
stop = w if cols is None else (cols.stop if cols.stop is not None else w)
lo = start + int(self.col_shift.min())
hi = stop + int(self.col_shift.max())
if self.wrap:
raw_cols = np.arange(lo, hi) % w
else:
lo = max(lo, 0)
hi = min(hi, w)
raw_cols = None
pts = pc.points
def cube(name: str) -> np.ndarray:
a = pts[name].reshape(w, e, n)
a = a[raw_cols] if raw_cols is not None else a[lo:hi]
return a.transpose(2, 0, 1)
x, y, z = cube("x"), cube("y"), cube("z")
good = (x != 0) | (y != 0) | (z != 0)
r = np.sqrt(x * x + y * y + z * z, dtype=np.float32)
r *= good
if e == 1:
r_near = r[..., 0]
r_far = r[..., 0]
it = cube("intensity")[..., 0]
valid = good[..., 0]
else:
inten = cube("intensity")
near_i = np.argmin(np.where(good, r, np.float32(np.inf)), axis=-1)[..., None]
far_i = np.argmax(r, axis=-1)[..., None]
r_near = np.take_along_axis(r, near_i, -1)[..., 0]
r_far = np.take_along_axis(r, far_i, -1)[..., 0]
it = np.take_along_axis(inten, near_i, -1)[..., 0]
valid = good.any(axis=-1)
# выпрямление скоса каналов, с поправкой на смещение окна
g = self.gather[:, start:stop]
ok = self.gather_ok[:, start:stop]
if raw_cols is not None:
g = (g - lo) % w
else:
g = g - lo
ok = ok & (g >= 0) & (g < (hi - lo))
np.clip(g, 0, hi - lo - 1, out=g)
r_near = np.take_along_axis(r_near, g, 1)
r_far = np.take_along_axis(r_far, g, 1)
it = np.take_along_axis(it, g, 1)
valid = np.take_along_axis(valid, g, 1) & ok
r_near = np.where(valid, r_near, np.float32(0.0))
r_far = np.where(valid, r_far, np.float32(0.0))
return RangeImage(pc.stamp, np.ascontiguousarray(r_near),
np.ascontiguousarray(r_far), np.ascontiguousarray(it),
np.ascontiguousarray(valid))
# ------------------------------------------------------------------ калибровка
@staticmethod
def calibrate(clouds: list[PointCloud2], n_rings: int | None = None) -> "ScanLayout":
"""Восстановить решётку по нескольким кадрам.
Определяются: число колец и эх, элевация каждого кольца, шаг развёртки,
азимутальный сдвиг каждого канала и целочисленное выпрямление образа.
"""
if not clouds:
raise ValueError("нужен хотя бы один кадр для калибровки")
pc0 = clouds[0]
if n_rings is None:
n_rings = int(pc0.points["ring"].max()) + 1
n_cols, rem = divmod(pc0.n_points, n_rings)
if rem:
raise ValueError(f"{pc0.n_points} точек не делится на {n_rings} колец")
# Направление луча задано сенсором и в каждом кадре одно и то же:
# кадры нужны только чтобы закрыть лучи, не вернувшие эхо. Поэтому
# берётся первое конечное значение, а не медиана по стопке кадров —
# та стоила 1.4 с из 2.8 с всей калибровки и ничего не уточняла:
# ниже и шаг развёртки, и сдвиг канала берутся медианой по тысячам
# столбцов, где шум одного отсчёта всё равно усредняется.
az = el = None
for pc in clouds:
x = pc.points["x"].reshape(n_cols, n_rings).T.astype(np.float32)
y = pc.points["y"].reshape(n_cols, n_rings).T.astype(np.float32)
z = pc.points["z"].reshape(n_cols, n_rings).T.astype(np.float32)
ok = (x != 0) | (y != 0) | (z != 0)
with _quiet():
a = np.where(ok, np.degrees(np.arctan2(x, -y)), np.nan)
e = np.where(ok, np.degrees(np.arctan2(z, np.hypot(x, y))), np.nan)
if az is None:
az, el = a, e
continue
gap = np.isnan(az)
if not gap.any():
break
az[gap] = a[gap]
el[gap] = e[gap]
az = az.astype(np.float64)
el = el.astype(np.float64)
n_echo = _detect_echoes(az)
n_az = n_cols // n_echo
if n_echo > 1:
with _quiet():
az = np.nanmean(az.reshape(n_rings, n_az, n_echo), axis=2)
el = np.nanmean(el.reshape(n_rings, n_az, n_echo), axis=2)
# общий шаг развёртки: медиана по кольцам от робастной оценки наклона
slopes = []
for h in range(n_rings):
row = az[h]
idx = np.flatnonzero(np.isfinite(row))
if idx.size < 50:
continue
d = np.diff(np.unwrap(np.radians(row[idx]))) / np.diff(idx)
slopes.append(np.median(np.degrees(d)))
if not slopes:
raise ValueError("недостаточно валидных лучей для калибровки развёртки")
step = float(np.median(slopes))
# смещение каждого канала относительно общей развёртки
j = np.arange(n_az, dtype=np.float64)
base = step * j
with _quiet():
c_ring = np.nanmedian(_wrap180(az - base[None, :]), axis=1) # (H,)
c_ring = _fill_linear(c_ring)
c0 = float(np.median(c_ring))
# круговой скан: развёртка покрывает полные 360°
wrap = abs(step) * n_az > 350.0
# начало отсчёта выбирается так, чтобы «вперёд» (азимут 0) был в середине
# образа — иначе шов ±180° разрезал бы рабочий сектор пополам
if wrap:
k = int(np.rint(-c0 / step)) - n_az // 2
c0 = _wrap180(c0 + step * k)
# разница берётся по кратчайшей дуге: иначе шов ±180° даёт сдвиг в пол-оборота
shift = np.rint(_wrap180(c0 - c_ring) / step).astype(np.int64)
resid = _wrap180(c_ring + step * shift - c0)
with _quiet():
el_ring = _fill_linear(np.nanmedian(el, axis=1))
return ScanLayout(el_ring, step, float(c0), shift, resid, n_az, n_echo, wrap)
# ------------------------------------------------------------------ сериализация
def save(self, path: str | Path) -> None:
np.savez_compressed(path, el_deg=self.el_deg, az_step_deg=self.az_step_deg,
az0_deg=self.az0_deg, col_shift=self.col_shift,
az_resid_deg=self.az_resid_deg, n_az=self.n_az,
n_echo=self.n_echo, wrap=self.wrap)
@staticmethod
def load(path: str | Path) -> "ScanLayout":
d = np.load(path)
return ScanLayout(d["el_deg"], float(d["az_step_deg"]), float(d["az0_deg"]),
d["col_shift"], d["az_resid_deg"], int(d["n_az"]),
int(d["n_echo"]), bool(d["wrap"]) if "wrap" in d else False)
def __repr__(self) -> str:
return (f"ScanLayout(колец={self.n_rings}, азимутов={self.n_az}, эх={self.n_echo}, "
f"сектор {self.az_grid_deg.min():.1f}°…{self.az_grid_deg.max():.1f}°, "
f"шаг {abs(self.az_step_deg):.3f}°, "
f"элевация {self.el_deg.min():.1f}°…{self.el_deg.max():.1f}°, "
f"скос каналов {np.ptp(self.col_shift)} стлб)")
# ---------------------------------------------------------------------- вспомогательное
@contextmanager
def _quiet():
"""Пустые срезы и деление на ноль при калибровке — норма, а не ошибка."""
with warnings.catch_warnings(), np.errstate(all="ignore"):
warnings.simplefilter("ignore", RuntimeWarning)
yield
def _wrap180(a):
"""Привести угол(ы) в градусах к полуинтервалу (−180, 180]."""
return -((-np.asarray(a, np.float64) + 180.0) % 360.0 - 180.0)
def _detect_echoes(az: np.ndarray) -> int:
"""Двойное эхо: соседние столбцы делят азимут (проверяется по каждому кольцу)."""
if az.shape[1] < 4:
return 1
a, b = az[:, 0::2], az[:, 1::2]
m = np.isfinite(a) & np.isfinite(b)
if m.sum() < 100:
return 1
return 2 if np.mean(np.abs(a[m] - b[m]) < 1e-3) > 0.8 else 1
def _fill_linear(a: np.ndarray) -> np.ndarray:
"""Линейно достроить NaN-пропуски по индексу (сетка равномерная)."""
a = np.asarray(a, np.float64).copy()
bad = ~np.isfinite(a)
if bad.all():
raise ValueError("нет ни одного валидного угла для калибровки")
if bad.any():
idx = np.arange(a.size)
a[bad] = np.interp(idx[bad], idx[~bad], a[~bad])
return a

315
flyguard/synth.py Normal file
View file

@ -0,0 +1,315 @@
"""Синтетические препятствия: трассировка лучей в реальные кадры.
Разметки в датасете нет, а организаторы прямо предупредили, что приватный тест
собран добавлением синтезированных препятствий в новые проезды. Поэтому свой
полигон строится тем же способом: берётся настоящий кадр пустого тоннеля,
в него трассировкой лучей вставляется предмет заданного размера на заданной
дистанции, и получается **размеченный** пример с точно известным ответом.
Вставка идёт в исходное облако точек, а не в готовый дальностный образ, поэтому
через конвейер проходит ровно тот же путь, что и настоящие данные, начиная
с ретины.
Модель сенсора намеренно пессимистична: добавляется шум дальности, вероятность
несостоявшегося эха растёт с расстоянием и с углом падения, а интенсивность
считается по ламбертовой модели. Лучше недооценить свой детектор, чем на
защите обнаружить, что полигон был слишком добрым.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from .cdr import PointCloud2
from .geometry import RailPlane
from .retina import ScanLayout
INF = np.float32(np.inf)
# --------------------------------------------------------------------------- тела
@dataclass
class Box:
"""Параллелепипед в координатах пути, стоящий на плоскости рельсов."""
length: float # вдоль пути, м
width: float # поперёк, м
height: float # вверх от рельса, м
h_base: float = 0.0
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
lo = np.array([-self.length / 2, -self.width / 2, self.h_base], np.float32)
hi = np.array([self.length / 2, self.width / 2, self.h_base + self.height], np.float32)
t0 = np.full(od.shape, -INF, np.float32)
t1 = np.full(od.shape, INF, np.float32)
for o, d, a, b in ((od, dd, lo[0], hi[0]), (ou, du, lo[1], hi[1]),
(oh, dh, lo[2], hi[2])):
with np.errstate(divide="ignore", invalid="ignore"):
ta = (a - o) / d
tb = (b - o) / d
lo_t = np.where(d != 0, np.minimum(ta, tb), np.where((o >= a) & (o <= b), -INF, INF))
hi_t = np.where(d != 0, np.maximum(ta, tb), np.where((o >= a) & (o <= b), INF, -INF))
t0 = np.maximum(t0, lo_t)
t1 = np.minimum(t1, hi_t)
hit = (t1 >= np.maximum(t0, 0.0)) & np.isfinite(t0)
return np.where(hit, np.maximum(t0, 0.0), INF)
@dataclass
class Cylinder:
"""Вертикальный цилиндр — человек, столб, ведро."""
radius: float
height: float
h_base: float = 0.0
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
a = dd * dd + du * du
b = 2.0 * (od * dd + ou * du)
c = od * od + ou * ou - self.radius ** 2
disc = b * b - 4 * a * c
ok = (disc > 0) & (a > 1e-9)
sq = np.sqrt(np.where(ok, disc, 0.0))
with np.errstate(divide="ignore", invalid="ignore"):
t = (-b - sq) / (2 * a)
t2 = (-b + sq) / (2 * a)
t = np.where(t > 0, t, t2)
h = oh + t * dh
ok &= (t > 0) & (h >= self.h_base) & (h <= self.h_base + self.height)
return np.where(ok, t, INF)
@dataclass
class Sphere:
radius: float
h_centre: float
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
oz = oh - self.h_centre
a = dd * dd + du * du + dh * dh
b = 2.0 * (od * dd + ou * du + oz * dh)
c = od * od + ou * ou + oz * oz - self.radius ** 2
disc = b * b - 4 * a * c
ok = disc > 0
sq = np.sqrt(np.where(ok, disc, 0.0))
with np.errstate(divide="ignore", invalid="ignore"):
t = (-b - sq) / (2 * a)
return np.where(ok & (t > 0), t, INF)
@dataclass
class ObjectModel:
"""Предмет: набор тел плюс отражательные свойства."""
name: str
parts: list = field(default_factory=list)
reflectivity: float = 0.3 # 0…1, доля отражённого света
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
t = np.full(od.shape, INF, np.float32)
for p in self.parts:
t = np.minimum(t, p.intersect(od, ou, oh, dd, du, dh))
return t
@property
def size(self) -> tuple[float, float]:
"""Грубые габариты (ширина, высота) для отчётов."""
w = h = 0.0
for p in self.parts:
if isinstance(p, Box):
w = max(w, p.width); h = max(h, p.h_base + p.height)
elif isinstance(p, Cylinder):
w = max(w, 2 * p.radius); h = max(h, p.h_base + p.height)
elif isinstance(p, Sphere):
w = max(w, 2 * p.radius); h = max(h, p.h_centre + p.radius)
return w, h
def catalogue() -> dict[str, ObjectModel]:
"""Набор предметов, встречающихся в тоннеле, от крупных к мелким."""
return {
"человек_стоя": ObjectModel("человек_стоя", [
Cylinder(radius=0.22, height=1.45),
Sphere(radius=0.11, h_centre=1.60)], reflectivity=0.35),
"человек_сидя": ObjectModel("человек_сидя", [
Box(0.45, 0.50, 0.85)], reflectivity=0.35),
"ящик": ObjectModel("ящик", [Box(0.60, 0.60, 0.60)], reflectivity=0.40),
"чемодан": ObjectModel("чемодан", [Box(0.25, 0.45, 0.55)], reflectivity=0.30),
"ведро": ObjectModel("ведро", [Cylinder(radius=0.15, height=0.35)], reflectivity=0.45),
"камень": ObjectModel("камень", [Sphere(radius=0.11, h_centre=0.11)], reflectivity=0.20),
"бутылка": ObjectModel("бутылка", [Cylinder(radius=0.045, height=0.30)],
reflectivity=0.25),
"кабель": ObjectModel("кабель", [Box(2.20, 0.06, 0.06)], reflectivity=0.15),
"каска": ObjectModel("каска", [Sphere(radius=0.14, h_centre=0.10)], reflectivity=0.55),
}
# --------------------------------------------------------------------------- вставка
@dataclass
class Placement:
"""Где стоит предмет."""
d: float # вперёд от сенсора, м
u: float = 0.0 # поперёк от оси пути, м
yaw_deg: float = 0.0 # поворот вокруг вертикали (для вытянутых тел)
def ray_dirs_track(layout: ScanLayout, plane: RailPlane):
"""Направления лучей в координатах пути: (dd, du, dh) и начало (0, 0, H)."""
dirs = layout.dirs
dd = -dirs[..., 1].astype(np.float32)
du = dirs[..., 0].astype(np.float32)
dh = (dirs[..., 2] - plane.a * dd - plane.b * du).astype(np.float32)
return dd, du, dh, np.float32(plane.height)
# Паспортные данные Pandar128E3X (руководство v4p5, п. 1.4 и Приложение A):
# дальность 0.3…200 м при отражательной способности 10 %, вероятность
# обнаружения на паспортной дальности PoD = 70 %, точность ±2 см на 1…200 м.
SPEC_REFLECTIVITY = 0.10
SPEC_POD = 0.70
HARD_MAX_RANGE = 230.0 # дальше в датасете возвратов не встречается
# Расходимость луча в руководстве не указана; принята равной угловому шагу
# решётки (0.1° по азимуту, 0.125° по элевации в полосе высокого разрешения) —
# это верхняя оценка, дающая консервативный результат для мелких предметов.
BEAM_DIV_H = 1.75e-3 # рад
BEAM_DIV_V = 2.18e-3 # рад
_CHANNEL_RANGE: np.ndarray | None = None
def channel_max_range() -> np.ndarray:
"""Паспортная дальность каждого канала при 10 % отражения, (128,).
Каналы сильно неравноправны: 34–65 «дальнобойные» и берут 200 м, а 98–128
смотрят в землю и рассчитаны только на ближнее и среднее поле. Без учёта
этого синтетический полигон завышал бы дальность обнаружения для предметов,
попадающих в нижние каналы.
"""
global _CHANNEL_RANGE
if _CHANNEL_RANGE is None:
import csv
from pathlib import Path
path = Path(__file__).with_name("data") / "pandar128_channels.csv"
try:
rows = sorted(csv.DictReader(open(path, encoding="utf-8")),
key=lambda r: int(r["channel"]))
_CHANNEL_RANGE = np.array([float(r["max_range_10pct_m"]) for r in rows],
np.float32)
except (OSError, KeyError, ValueError):
_CHANNEL_RANGE = np.full(128, 200.0, np.float32)
return _CHANNEL_RANGE
def dropout_probability(r: np.ndarray, reflectivity: float,
ang_w: np.ndarray | float = 1.0,
ang_h: np.ndarray | float = 1.0,
max_range: np.ndarray | float = 200.0) -> np.ndarray:
"""Вероятность, что эхо не вернётся.
Принятая мощность падает как ρ·A/r², где A — доля пятна луча, закрытая
предметом. Отсюда «эффективная дальность» r·√(ρ_паспорт/(ρ·A)), которую
остаётся сравнить с паспортной дальностью **этого канала**. Переход сделан
логистическим и смещён так, чтобы на паспортной дальности вероятность
обнаружения равнялась заявленным 70 %.
Заполнение пятна важно именно для мелких предметов: на 200 м луч шириной
1.75 мрад покрывает 35 см, и бутылка диаметром 9 см отражает лишь четверть
его энергии — поэтому она пропадает намного раньше человека, хотя по
геометрии в неё ещё попадают лучи.
"""
fill = np.clip(ang_w / BEAM_DIV_H, 0.05, 1.0) * np.clip(ang_h / BEAM_DIV_V, 0.05, 1.0)
rho = max(reflectivity, 0.02) * fill
eff = r * np.sqrt(SPEC_REFLECTIVITY / rho)
width = 0.12 * np.asarray(max_range, np.float32)
r50 = np.asarray(max_range, np.float32) + width * np.log(SPEC_POD / (1 - SPEC_POD))
p_detect = 1.0 / (1.0 + np.exp((eff - r50) / np.maximum(width, 1e-3)))
p_detect = np.where(r > HARD_MAX_RANGE, 0.0, p_detect)
return np.clip(1.0 - p_detect, 0.0, 1.0).astype(np.float32)
def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
obj: ObjectModel, place: Placement, *,
rng: np.random.Generator | None = None,
range_noise: float = 0.02, cols: slice | None = None) -> tuple[PointCloud2, dict]:
"""Вставить предмет в облако точек. Возвращает (новое облако, разметка)."""
rng = rng or np.random.default_rng()
n_rings, n_az, n_echo = layout.n_rings, layout.n_az, layout.n_echo
dd, du, dh, H = ray_dirs_track(layout, plane)
# начало луча в системе предмета: сенсор в (0,0,H), предмет в (d, u, 0)
od = np.full(dd.shape, -np.float32(place.d), np.float32)
ou = np.full(dd.shape, -np.float32(place.u), np.float32)
oh = np.full(dd.shape, H, np.float32)
if place.yaw_deg:
c, s = np.cos(np.radians(place.yaw_deg)), np.sin(np.radians(place.yaw_deg))
od, ou = c * od + s * ou, -s * od + c * ou
dd, du = c * dd + s * du, -s * dd + c * du
t = obj.intersect(od, ou, oh, dd, du, dh)
hit = np.isfinite(t) & (t > 1.0)
if not hit.any():
return pc, dict(hit_rays=0, d=place.d, u=place.u, name=obj.name)
# шум дальности и пропуски эха
t = t + rng.normal(0.0, range_noise, t.shape).astype(np.float32)
w_obj, h_obj = obj.size
ang_w = w_obj / np.maximum(t, 1.0)
ang_h = h_obj / np.maximum(t, 1.0)
ch_range = channel_max_range()
per_ray_range = (ch_range[:n_rings, None] if ch_range.size >= n_rings
else np.float32(200.0))
p_drop = dropout_probability(t, obj.reflectivity, ang_w, ang_h, per_ray_range)
hit &= rng.random(t.shape) > p_drop
pts = pc.points.copy()
ring_i, col_i = np.nonzero(hit)
raw_col = layout.gather[ring_i, col_i] # выпрямленный столбец → сырой
new_r = t[ring_i, col_i]
# плоский индекс точки: (сырой столбец · число эх + эхо) · число колец + кольцо
base = (raw_col * n_echo) * n_rings + ring_i
fx, fy, fz = pts["x"], pts["y"], pts["z"] # виды на поля, запись идёт в pts
# предмет виден, только если он ближе уже зарегистрированного эха
r_exist = np.full(base.shape, INF, np.float32)
for e in range(n_echo):
idx = base + e * n_rings
ex, ey, ez = fx[idx], fy[idx], fz[idx]
have = (ex != 0) | (ey != 0) | (ez != 0)
r_e = np.where(have, np.sqrt(ex * ex + ey * ey + ez * ez), INF)
r_exist = np.minimum(r_exist, r_e)
closer = new_r < r_exist
ring_i, col_i, base, new_r = ring_i[closer], col_i[closer], base[closer], new_r[closer]
n_written = int(base.size)
if n_written:
dir_sel = layout.dirs[ring_i, col_i]
nx = (dir_sel[:, 0] * new_r).astype(np.float32)
ny = (dir_sel[:, 1] * new_r).astype(np.float32)
nz = (dir_sel[:, 2] * new_r).astype(np.float32)
# ламбертова интенсивность: ρ·cosθ/r², приведена к шкале прибора 0…255
cos_inc = np.clip(np.abs(dir_sel[:, 1]), 0.05, 1.0)
inten = np.clip(2.2e4 * obj.reflectivity * cos_inc / (new_r ** 2), 1, 255)
for e in range(n_echo):
idx = base + e * n_rings
fx[idx] = nx
fy[idx] = ny
fz[idx] = nz
pts["intensity"][idx] = inten.astype(np.float32)
out = PointCloud2(stamp=pc.stamp, frame_id=pc.frame_id, height=pc.height,
width=pc.width, point_step=pc.point_step,
is_dense=pc.is_dense, points=pts)
w, hgt = obj.size
return out, dict(hit_rays=int(n_written), d=float(place.d), u=float(place.u),
name=obj.name, width=w, height=hgt,
reflectivity=obj.reflectivity,
# индексы лучей, в которые предмет реально записан: по ним
# разметка кандидата точная, а не «по дальности примерно»
rays=(ring_i, col_i))

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
numpy>=1.24
scipy>=1.10
# только для контрольного бустинга в tools/train_mbon.py --baseline
lightgbm>=4.0
# только для обучения на GPU (tools/train_mbon.py --device cuda); ядру не нужен
# torch>=2.0
pytest>=7.0

326
tests/test_pipeline.py Normal file
View file

@ -0,0 +1,326 @@
"""Тесты конвейера, не требующие ROS.
Проверяется то, что легко сломать незаметно: разбор CDR, порядок точек,
выпрямление скоса каналов, геометрия плоскости пути, связность с учётом
глубины, кодирование грибовидного тела и вставка синтетического предмета.
pytest ros2_ws/src/flyguard/test
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from flyguard.cdr import point_dtype # noqa: E402
from flyguard.geometry import RailPlane # noqa: E402
from flyguard.lobula import cluster_by_depth # noqa: E402
from flyguard.mushroom_body import MushroomBody, MushroomBodyConfig # noqa: E402
from flyguard.retina import ScanLayout # noqa: E402
DATA = ROOT / "data" / "for_hackathon"
if not DATA.exists():
DATA = ROOT.parent / "data" / "for_hackathon"
# --------------------------------------------------------------------------- CDR
def test_point_dtype_handles_unaligned_timestamp():
"""У лидара поле timestamp (float64) лежит по смещению 18 — без выравнивания."""
fields = [("x", 0, 7, 1), ("y", 4, 7, 1), ("z", 8, 7, 1), ("intensity", 12, 7, 1),
("ring", 16, 4, 1), ("timestamp", 18, 8, 1)]
dt = point_dtype(fields, 26)
assert dt.itemsize == 26
assert dt["timestamp"].itemsize == 8
def test_point_dtype_pads_trailing_gap():
"""Хвост до point_step добивается паддингом — так устроено само сообщение."""
assert point_dtype([("x", 0, 7, 1)], 26).itemsize == 26
def test_point_dtype_rejects_overlapping_fields():
with pytest.raises(ValueError, match="перекрыва"):
point_dtype([("x", 0, 8, 1), ("y", 4, 7, 1)], 26)
# --------------------------------------------------------------------------- решётка
def _layout(n_rings=8, n_az=40, n_echo=2, shift=None):
el = np.linspace(6.0, -6.0, n_rings)
shift = np.zeros(n_rings, np.int64) if shift is None else shift
return ScanLayout(el, -0.1, 2.0, shift, np.zeros(n_rings), n_az, n_echo)
def test_rectification_undoes_channel_skew():
"""Сдвиг строк должен ровно компенсировать азимутальный сдвиг канала."""
shift = np.array([-2, -1, 0, 1, 2, 0, -1, 1], np.int64)
lay = _layout(shift=shift)
j = np.arange(lay.n_az)
for h in range(lay.n_rings):
expected = np.clip(j + shift[h], 0, lay.n_az - 1)
assert np.array_equal(lay.gather[h], expected)
assert lay.gather_ok[2].all() # нулевой сдвиг — потерь нет
def test_azimuth_grid_is_monotonic_and_centred():
lay = _layout(n_az=41)
d = np.diff(lay.az_grid_deg)
assert np.allclose(d, -0.1)
assert lay.dirs.shape == (lay.n_rings, lay.n_az, 3)
assert np.allclose(np.linalg.norm(lay.dirs, axis=-1), 1.0, atol=1e-5)
def test_forward_direction_is_minus_y():
"""Азимут 0 смотрит вперёд, а вперёд в системе сенсора — это −Y."""
lay = ScanLayout(np.array([0.0]), -0.1, 0.0, np.zeros(1, np.int64),
np.zeros(1), 1, 1)
assert lay.dirs[0, 0, 1] == pytest.approx(-1.0, abs=1e-6)
# --------------------------------------------------------------------------- геометрия
def test_floor_range_matches_flat_plane():
"""Луч вниз под углом θ проходит до полотна H/sin θ — это дальность вдоль
луча, а не горизонтальное расстояние: именно её сравнивают с измеренной."""
height = 2.0
plane = RailPlane(a=0.0, b=0.0, c=-height, inliers=0, rms=0.0)
for el in (-1.0, -3.0, -10.0):
lay = ScanLayout(np.array([el]), -0.1, 0.0, np.zeros(1, np.int64),
np.zeros(1), 1, 1)
got = plane.floor_range(lay)[0, 0]
assert got == pytest.approx(height / np.sin(np.radians(-el)), rel=1e-3)
def test_upward_rays_never_hit_floor():
plane = RailPlane(a=0.0, b=0.0, c=-2.0, inliers=0, rms=0.0)
lay = ScanLayout(np.array([5.0]), -0.1, 0.0, np.zeros(1, np.int64), np.zeros(1), 1, 1)
assert not np.isfinite(plane.floor_range(lay)[0, 0])
def test_height_above_plane_accounts_for_tilt():
plane = RailPlane(a=0.01, b=-0.02, c=-1.5, inliers=0, rms=0.0)
d = np.array([10.0]); u = np.array([2.0]); z = np.array([0.0])
expected = 0.0 - (0.01 * 10.0 + (-0.02) * 2.0 + (-1.5))
assert plane.height_of(d, u, z)[0] == pytest.approx(expected)
# --------------------------------------------------------------------------- кластеризация
def test_depth_aware_clustering_splits_on_range_gap():
"""Предмет перед далёкой стеной не должен слипнуться с ней в одно пятно."""
mask = np.zeros((4, 10), bool)
mask[:, :] = True
r = np.full((4, 10), 150.0, np.float32)
r[:, 3:6] = 55.0 # предмет на 55 м на фоне 150 м
labels, n = cluster_by_depth(mask, r, col_reach=1, row_reach=1)
assert n >= 2
assert len(set(labels[:, 3:6].ravel())) == 1
assert labels[0, 0] != labels[0, 4]
def test_clustering_tolerance_grows_with_range():
"""Допуск относительный: разрыв 1 м слитен на 150 м и разделим на 5 м."""
mask = np.ones((1, 6), bool)
far = np.array([[150.0, 150.0, 151.0, 151.0, 150.0, 150.0]], np.float32)
near = np.array([[5.0, 5.0, 6.0, 6.0, 5.0, 5.0]], np.float32)
_, n_far = cluster_by_depth(mask, far, col_reach=1, row_reach=0)
_, n_near = cluster_by_depth(mask, near, col_reach=1, row_reach=0)
assert n_far == 1
assert n_near >= 2
def test_empty_mask_is_handled():
labels, n = cluster_by_depth(np.zeros((3, 3), bool), np.zeros((3, 3), np.float32))
assert n == 0 and labels.sum() == 0
# --------------------------------------------------------------------------- память
def test_mushroom_body_code_is_sparse_and_deterministic():
mb = MushroomBody(MushroomBodyConfig(n_kc=2000, sparsity=0.01, seed=1))
X = np.random.default_rng(0).normal(size=(5, mb.n_pn)).astype(np.float32)
mb.fit_normalizer(X)
a, b = mb.encode(X), mb.encode(X)
assert a.shape == (5, mb.n_active)
assert mb.n_active == 20
assert np.array_equal(a, b)
assert len(set(a[0].tolist())) == mb.n_active # без повторов
def test_learning_suppresses_only_what_was_shown():
mb = MushroomBody(MushroomBodyConfig(n_kc=4000, sparsity=0.01, seed=2))
rng = np.random.default_rng(3)
familiar = rng.normal(size=(200, mb.n_pn)).astype(np.float32)
novel = (rng.normal(size=(50, mb.n_pn)) + 8.0).astype(np.float32)
mb.fit_normalizer(familiar)
mb.learn(familiar, rate=0.3)
assert mb.novelty(familiar).mean() < mb.novelty(novel).mean()
def test_auto_rate_shrinks_with_sample_size():
mb = MushroomBody(MushroomBodyConfig(n_kc=10_000, sparsity=0.001))
assert mb.auto_rate(1_000) > mb.auto_rate(100_000)
assert 0 < mb.auto_rate(10 ** 7) <= 0.5
def test_feature_count_mismatch_is_explicit():
mb = MushroomBody()
with pytest.raises(ValueError, match="признак"):
mb.encode(np.zeros((1, mb.n_pn + 1), np.float32))
# --------------------------------------------------------- фигура и фон, привыкание
def _cand(**kw):
from flyguard.lobula import Candidate
base = dict(d=60.0, u=0.1, h=1.0, d_min=59.7, h_min=0.3, width=0.4, height=1.0,
depth=0.5, containment=0.9, n_rays=30, n_rings=6, n_cols=5,
gap=8.0, on=0.001, floor_deficit=0.2, shadow=0.2, inten=40.0,
az_deg=0.5, el_deg=-1.0, bbox=(10, 16, 20, 25))
base.update(kw)
return Candidate(**base)
def test_contrast_split_keeps_step_and_drops_smooth_wall():
"""Гладкая стена не даёт фигуры, ступенька на ней — даёт."""
from flyguard.lobula import cluster_by_depth, split_by_figure
# стена: дальность плавно растёт вдоль строки от 20 до 120 м
r = np.tile(np.linspace(20.0, 120.0, 200, dtype=np.float32), (12, 1))
mask = np.ones(r.shape, bool)
labels, n = cluster_by_depth(mask, r, col_reach=3)
assert n == 1 # стена связна целиком
# контраст гладкой стены равен нулю — резать нечего
flat = np.zeros_like(r)
out, n_out = split_by_figure(labels, n, r, flat, max_depth=15.0, thr=6.0)
assert n_out == n and out is labels
# предмет: ступенька, торчащая из стены на 10 м
gap = np.zeros_like(r)
gap[4:8, 90:98] = 10.0
out, n_out = split_by_figure(labels, n, r, gap, max_depth=15.0, thr=6.0)
kept = out[out > 0]
assert kept.size == 32 # ровно лучи ступеньки
assert np.unique(kept).size == 1 # и это одна компонента
def test_habituation_suppresses_shape_repeating_at_different_places():
from flyguard.mushroom_body import Habituation, HabituationConfig
hab = Habituation(HabituationConfig(warmup=0, norm_n=1))
seen = []
s = 0.0
for _ in range(14):
s += 25.0
c = _cand(d=60.0)
hab.update([c], s, 25.0)
seen.append(c.novelty)
assert seen[0] > 0.9 and seen[-1] < 0.25
assert hab.places >= 10
def test_habituation_keeps_object_standing_in_one_place_novel():
"""Подъезд к неподвижному предмету — это одно место, а не сто повторов."""
from flyguard.mushroom_body import Habituation, HabituationConfig
hab = Habituation(HabituationConfig(warmup=0, norm_n=1))
s = 0.0
nov = []
for k in range(50):
s += 3.0
nov.append(_cand(d=160.0 - 3.0 * k))
hab.update([nov[-1]], s, 3.0)
assert hab.places <= 2 # одно место (плюс дрейф оценки)
assert nov[-1].novelty > 0.7
def test_habituation_forgets_after_a_long_run():
from flyguard.mushroom_body import Habituation, HabituationConfig
hab = Habituation(HabituationConfig(warmup=0, norm_n=1, recover_m=100.0))
s = 0.0
for _ in range(14):
s += 25.0
hab.update([_cand(d=60.0)], s, 25.0)
low = hab.level
hab.advance(600.0)
assert hab.level < low * 0.2
# --------------------------------------------------------------- считывание MBON
def test_mbon_readout_learns_and_round_trips(tmp_path):
"""Обучается, сохраняется без потерь и даёт калиброванную вероятность."""
from flyguard.mbon_readout import MbonConfig, MbonReadout
rng = np.random.default_rng(3)
X = rng.standard_normal((2000, 23)).astype(np.float32)
y = ((X[:, 0] + 0.5 * X[:, 3]) > 0.3).astype(np.float32)
m = MbonReadout(MbonConfig(n_kc=4000, sparsity=0.02), n_pn=23)
m.fit_normalizer(X)
m.learn(X, y, epochs=60, lr=4.0)
s = m.score(X)
assert s.min() >= 0.0 and s.max() <= 1.0
assert ((s > 0.5) == (y > 0.5)).mean() > 0.8 # калибровка, не только порядок
path = tmp_path / "mbon.npz"
m.save(path)
again = MbonReadout.load(path)
assert np.allclose(again.score(X[:64]), s[:64], atol=1e-6)
def test_mbon_replaces_hand_formula_and_blend_interpolates():
"""Модель входит в вес улики, а смешивание даёт обе крайности."""
from flyguard.central_complex import _quality
c = _cand(gap=0.0, containment=0.3, depth=9.0, h_min=1.4, n_rays=5)
hand = _quality(c) # без модели
c.extra["mbon"] = 0.99
assert _quality(c, mbon_blend=1.0) > hand * 3 # модель вытягивает
assert _quality(c, mbon_blend=0.0) == hand # ручная формула нетронута
mid = _quality(c, mbon_blend=0.5)
assert hand < mid < _quality(c, mbon_blend=1.0)
# уверенное «это обстановка» гасит даже хорошую геометрию
good = _cand(gap=12.0, containment=1.0, depth=0.4, h_min=0.3, n_rays=80)
strong = _quality(good)
good.extra["mbon"] = 0.01
assert _quality(good, mbon_blend=1.0) < strong * 0.2
def test_mbon_absent_leaves_pipeline_unchanged():
"""Без модели вес улики считается ровно как раньше."""
from flyguard.central_complex import _quality
c = _cand()
assert "mbon" not in c.extra
assert _quality(c, mbon_blend=1.0) == _quality(c, mbon_blend=0.0)
# --------------------------------------------------------------------------- данные
@pytest.mark.skipif(not DATA.exists(), reason="датасет не распакован")
def test_real_bag_projects_without_angular_error():
"""На реальном бэге выпрямленная решётка обязана описывать лучи точно."""
from flyguard.bag import Bag
bag = Bag(next(p for p in DATA.iterdir() if p.is_dir()))
clouds = [pc for _, pc in bag.frames(start=2, stop=8)]
lay = ScanLayout.calibrate(clouds)
img = lay.project(clouds[-1])
assert img.shape == (lay.n_rings, lay.n_az)
assert 0.2 < img.valid.mean() < 0.9
r = img.r_near[img.valid]
assert r.min() > 0 and r.max() < 250
assert np.all(img.r_far[img.valid] >= img.r_near[img.valid] - 1e-3)

27
tools/_bootstrap.py Normal file
View file

@ -0,0 +1,27 @@
"""Общий пролог для инструментов: подключить пакет flyguard и найти данные."""
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PKG = ROOT
# Записи лидара в репозиторий не кладутся (десятки гигабайт). По умолчанию
# ищем их рядом: сначала внутри выгрузки, потом на уровень выше — так работает
# и у того, кто держит выгрузку внутри основного проекта, и у того, кто
# распаковал её отдельно. Переопределяется переменной FLYGUARD_DATA.
_env = os.environ.get("FLYGUARD_DATA")
if _env:
DATA = Path(_env)
elif (ROOT / "data" / "for_hackathon").exists():
DATA = ROOT / "data"
else:
DATA = ROOT.parent / "data"
CACHE = DATA / "cache"
ARTIFACTS = ROOT / "artifacts"
DOCS = ROOT / "docs"
FIGURES = DOCS / "figures"
if str(PKG) not in sys.path:
sys.path.insert(0, str(PKG))

108
tools/ablation.py Normal file
View file

@ -0,0 +1,108 @@
"""Абляция: вклад каждого механизма, измеренный в одинаковых условиях.
Каждый вариант отличается от полного ровно одним отключённым механизмом.
Память тоннеля во всех вариантах обучается **без проверяемого бэга**
(leave-one-bag-out), иначе сравнение было бы нечестным.
Меряются две величины, которые и определяют полезность системы:
доля кадров с ложной тревогой на пустых проездах и доля кадров, в которых
найден реальный объект на ~55 м в `doubleT_obstacle`.
python tools/ablation.py --device cuda
"""
from __future__ import annotations
import argparse
import json
import time
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.pipeline import FlyGuard, Params
from evaluate import OBSTACLE_BAG, TRUE_D, train_excluding
VARIANTS: dict[str, dict] = {
"полная система": {},
"− память тоннеля": {"enable_memory": False},
"− ось пути (прямой коридор)": {"use_corridor": False},
"− признаки формы": {"use_shape": False},
"− накопление улик": {"use_tracking": False},
}
def run(bag_path, params: Params, memory, limit: int) -> tuple[int, int, int, int]:
fg = FlyGuard(params, memory=memory)
n = alarm = obj = 0
fp_tracks: set[int] = set()
is_obs = Bag(bag_path).path.name == OBSTACLE_BAG
for _, pc in Bag(bag_path).frames(stop=limit):
res = fg.process(pc)
if res is None:
continue
n += 1
mine = [o for o in res.decision.objects
if is_obs and TRUE_D[0] < o.distance < TRUE_D[1]]
others = [o for o in res.decision.objects if o not in mine]
if mine:
obj += 1
if others:
alarm += 1
fp_tracks.update(o.track_id for o in others)
return n, alarm, obj, len(fp_tracks)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--cache", default=str(B.CACHE / "tune_candidates.npz"))
ap.add_argument("--extra-cache", default=str(B.CACHE / "new_data_candidates.npz"))
ap.add_argument("--limit", type=int, default=200)
ap.add_argument("--device", default="cpu")
ap.add_argument("--out", default=str(B.ARTIFACTS / "ablation.json"))
args = ap.parse_args()
d = np.load(args.cache, allow_pickle=True)
per_bag = {str(k): d[f"X_{k}"].astype(np.float32) for k in d["names"]}
from pathlib import Path
extra = (np.load(args.extra_cache)["X"].astype(np.float32)
if Path(args.extra_cache).exists() else None)
bags = find_bags(args.root)
memories = {p.name: train_excluding(per_bag, extra, p.name, 0.4, args.device)
for p in bags}
print(f"{'вариант':30s} | {'ложных кадров':>14s} {'ложн. треков':>13s} | "
f"{'объект 55 м':>12s}")
print("-" * 78)
rows = []
for label, over in VARIANTS.items():
t0 = time.time()
params = Params(**over)
tot_n = tot_alarm = tot_fp = 0
obj_n = obj_hit = 0
for p in bags:
mem = memories[p.name] if params.enable_memory else None
n, alarm, obj, fp = run(p, params, mem, args.limit)
if p.name == OBSTACLE_BAG:
obj_n, obj_hit = n, obj
else:
tot_n += n
tot_alarm += alarm
tot_fp += fp
row = dict(variant=label, alarm_rate=tot_alarm / max(tot_n, 1),
fp_tracks=tot_fp, obj_rate=obj_hit / max(obj_n, 1),
seconds=round(time.time() - t0, 1))
rows.append(row)
print(f"{label:30s} | {row['alarm_rate']:13.1%} {tot_fp:13d} | "
f"{row['obj_rate']:11.1%}", flush=True)
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=1)
print("\nсохранено:", args.out)
if __name__ == "__main__":
main()

91
tools/analyze_corridor.py Normal file
View file

@ -0,0 +1,91 @@
"""Проверка стабилизации и осевой линии пути на всех бэгах.
Печатает устойчивость плоскости рельсов, оценку радиуса кривой и реальную
дальность прямой видимости; рисует профили осевой линии.
python tools/analyze_corridor.py --frames 60
"""
from __future__ import annotations
import argparse
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402
from flyguard.bag import Bag, find_bags # noqa: E402
from flyguard.geometry import TrackFrame, fit_corridor, fit_rail_plane # noqa: E402
from flyguard.retina import ScanLayout # noqa: E402
def run(bag_path, n_frames: int, stride: int, fov: float, ax):
bag = Bag(bag_path)
layout = ScanLayout.calibrate([pc for _, pc in bag.frames(start=2, stop=14)])
cols = layout.column_slice(fov)
lay = layout.sub(cols)
plane = corridor = None
heights, pitches, rolls, rmss, radii, reach = [], [], [], [], [], []
curves = []
for k, (_, pc) in enumerate(bag.frames(stop=n_frames * stride, stride=stride)):
img = layout.project(pc).crop(cols)
plane = fit_rail_plane(img, lay, prev=plane)
tf = TrackFrame(img, lay, plane)
corridor = fit_corridor(tf, prev=corridor)
heights.append(plane.height); pitches.append(plane.pitch_deg)
rolls.append(plane.roll_deg); rmss.append(plane.rms)
radii.append(corridor.radius)
# дальность прямой видимости: дальше какой дистанции возвраты иссякают
d = tf.d[tf.valid]
reach.append(np.percentile(d, 99.9) if d.size else 0.0)
if k % max(1, n_frames // 6) == 0:
dd = np.linspace(0, 200, 100)
curves.append(corridor.centre(dd))
name = bag.path.name
print(f"=== {name} ({len(heights)} кадров)")
print(f" высота сенсора: {np.mean(heights):.3f} ± {np.std(heights):.3f} м")
print(f" тангаж {np.mean(pitches):+.3f}° ± {np.std(pitches):.3f}° "
f"крен {np.mean(rolls):+.3f}° ± {np.std(rolls):.3f}° "
f"rms {np.mean(rmss):.3f} м")
r = np.array(radii)
fin = np.isfinite(r)
print(f" радиус кривой: медиана {np.median(r[fin]) if fin.any() else float('inf'):.0f} м, "
f"прямых кадров {np.mean(~fin):.0%}")
print(f" дальность прямой видимости p99.9: медиана {np.median(reach):.0f} м, "
f"макс {np.max(reach):.0f} м")
dd = np.linspace(0, 200, 100)
for c in curves:
ax.plot(dd, c, lw=1, alpha=0.7)
ax.axhline(1.7, color="r", ls="--", lw=0.8)
ax.axhline(-1.7, color="r", ls="--", lw=0.8)
ax.set_title(f"{name}\nR≈{np.median(r[fin]) if fin.any() else float('inf'):.0f} м, "
f"видимость {np.median(reach):.0f} м", fontsize=9)
ax.set_xlabel("вперёд, м"); ax.set_ylabel("ось пути, м")
ax.set_ylim(-12, 12)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--frames", type=int, default=60)
ap.add_argument("--stride", type=int, default=4)
ap.add_argument("--fov", type=float, default=35.0)
args = ap.parse_args()
bags = find_bags(args.root)
fig, axes = plt.subplots(1, len(bags), figsize=(3.6 * len(bags), 3.6), dpi=110, squeeze=False)
for ax, b in zip(axes[0], bags):
run(b, args.frames, args.stride, args.fov, ax)
B.FIGURES.mkdir(parents=True, exist_ok=True)
out = B.FIGURES / "corridor.png"
fig.tight_layout(); fig.savefig(out)
print("сохранено:", out)
if __name__ == "__main__":
main()

111
tools/analyze_gauge.py Normal file
View file

@ -0,0 +1,111 @@
"""Что попадает внутрь габарита в нормальном тоннеле.
Накапливает по многим кадрам занятость плоскости (поперёк, высота) в разных
поясах дальности и устойчивость плоскости пути. Нужно, чтобы выбрать границы
габарита и пороги, а не угадывать их.
python tools/analyze_gauge.py --bag data/for_hackathon/roundT_doubleT --frames 60
"""
from __future__ import annotations
import argparse
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402
from flyguard.bag import Bag # noqa: E402
from flyguard.geometry import RailPlane, TrackFrame, fit_rail_plane # noqa: E402
from flyguard.retina import ScanLayout # noqa: E402
BANDS = [(5, 20), (20, 50), (50, 90), (90, 150), (150, 250)]
U_EDGES = np.arange(-4.0, 4.01, 0.05)
H_EDGES = np.arange(-1.0, 4.01, 0.05)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag", required=True)
ap.add_argument("--frames", type=int, default=60)
ap.add_argument("--stride", type=int, default=3)
ap.add_argument("--fov", type=float, default=25.0)
args = ap.parse_args()
bag = Bag(args.bag)
layout = ScanLayout.calibrate([pc for _, pc in bag.frames(start=2, stop=14)])
cols = layout.column_slice(args.fov)
lay = layout.sub(cols)
hist = [np.zeros((U_EDGES.size - 1, H_EDGES.size - 1), np.int64) for _ in BANDS]
planes: list[RailPlane] = []
deficits: list[np.ndarray] = []
prev = None
for _, pc in bag.frames(stop=args.frames * args.stride, stride=args.stride):
img = layout.project(pc).crop(cols)
plane = fit_rail_plane(img, lay, prev=prev)
prev = plane
planes.append(plane)
tf = TrackFrame(img, lay, plane)
for k, (lo, hi) in enumerate(BANDS):
m = tf.valid & (tf.d >= lo) & (tf.d < hi)
if m.any():
hh, _, _ = np.histogram2d(tf.u[m], tf.h[m], bins=(U_EDGES, H_EDGES))
hist[k] += hh.astype(np.int64)
# дефицит до пола: луч должен был дойти до полотна внутри коридора
fr = tf.floor_r
uf = fr * lay.dirs[..., 0]
hit_in = np.isfinite(fr) & (np.abs(uf) < 1.7) & (fr > 10) & (fr < 200)
m = hit_in & tf.valid
if m.any():
deficits.append((fr[m] - tf.r[m]).astype(np.float32))
a = np.array([p.a for p in planes]); b = np.array([p.b for p in planes])
hgt = np.array([p.height for p in planes]); rms = np.array([p.rms for p in planes])
print(f"=== {bag.path.name}: {len(planes)} кадров")
print(f" высота сенсора над путём: {hgt.mean():.3f} ± {hgt.std():.3f} м "
f"(разброс {hgt.min():.3f}…{hgt.max():.3f})")
print(f" тангаж: {np.degrees(np.arctan(a)).mean():+.3f}° ± {np.degrees(np.arctan(a)).std():.3f}°")
print(f" крен: {np.degrees(np.arctan(b)).mean():+.3f}° ± {np.degrees(np.arctan(b)).std():.3f}°")
print(f" невязка плоскости rms: {rms.mean():.3f} м, инлайеров {np.mean([p.inliers for p in planes]):.0f}")
if deficits:
dd = np.concatenate(deficits)
print(f" дефицит до пола внутри коридора, м: "
+ " ".join(f"p{q}={np.percentile(dd, q):+.2f}" for q in (1, 50, 90, 99, 99.9))
+ f" доля > 1 м: {np.mean(dd > 1.0):.3%}")
fig, axes = plt.subplots(2, len(BANDS), figsize=(4 * len(BANDS), 8), dpi=110)
fig.suptitle(f"{bag.path.name}: занятость (поперёк, высота) по поясам дальности; "
f"красное — габарит 3.4 × 2.2 м", fontsize=12)
for k, (lo, hi) in enumerate(BANDS):
hh = hist[k].T
tot = hh.sum()
for row, norm in enumerate(("log", "col")):
ax = axes[row, k]
if tot == 0:
ax.set_title(f"{lo}–{hi} м: пусто"); continue
show = np.log10(1 + hh) if norm == "log" else hh / np.maximum(hh.sum(0, keepdims=True), 1)
ax.imshow(show, origin="lower", aspect="auto", cmap="inferno",
extent=[U_EDGES[0], U_EDGES[-1], H_EDGES[0], H_EDGES[-1]])
ax.add_patch(plt.Rectangle((-1.7, 0.05), 3.4, 2.15, fill=False, ec="r", lw=1.2))
ax.axhline(0, color="c", lw=0.6)
ax.set_title(f"{lo}–{hi} м, n={tot/1e3:.0f}k" + ("" if row == 0 else " (норм. по столбцу)"),
fontsize=9)
ax.set_xlabel("поперёк, м")
if k == 0:
ax.set_ylabel("высота над рельсом, м")
B.FIGURES.mkdir(parents=True, exist_ok=True)
out = B.FIGURES / f"gauge_{bag.path.name}.png"
fig.tight_layout(rect=(0, 0, 1, 0.95))
fig.savefig(out)
print(" сохранено:", out)
if __name__ == "__main__":
main()

81
tools/analyze_geometry.py Normal file
View file

@ -0,0 +1,81 @@
"""Characterise the Pandar128 scan geometry inside the metro tunnel bags."""
from __future__ import annotations
import sys
import numpy as np
sys.path.insert(0, r"C:\Games\Study\AI_Lidar\tools")
from probe_bag import frames # noqa: E402
W, H = 7200, 128
def as_image(p):
"""Return (H,W) arrays: ordering is col-major with ring cycling fastest."""
x = p["x"].reshape(W, H).T.astype(np.float32)
y = p["y"].reshape(W, H).T.astype(np.float32)
z = p["z"].reshape(W, H).T.astype(np.float32)
i = p["intensity"].reshape(W, H).T.astype(np.float32)
return x, y, z, i
def main(db, idx=100):
for ts, m in frames(db, limit=1, start=idx):
x, y, z, inten = as_image(m["points"])
valid = ~((x == 0) & (y == 0) & (z == 0))
r = np.sqrt(x * x + y * y + z * z)
# azimuth measured in the sensor XY plane; forward is -Y
az = np.degrees(np.arctan2(x, -y)) # 0 = forward, + = right
el = np.degrees(np.arcsin(np.clip(z / np.maximum(r, 1e-6), -1, 1)))
print(f"valid {valid.sum()}/{valid.size} = {valid.mean():.1%}")
print("\n--- azimuth per column (median over valid rings) ---")
azc = np.where(valid, az, np.nan)
with np.errstate(all="ignore"):
colaz = np.nanmedian(azc, axis=0)
good = np.isfinite(colaz)
print("columns with any return:", good.sum())
cols = np.arange(W)
for c in [0, 1, 2, 1800, 3599, 3600, 3601, 5400, 7198, 7199]:
print(f" col {c:5d}: az={colaz[c]:8.3f}")
d = np.diff(colaz[good])
d = d[np.abs(d) < 1.0]
print(f" median azimuth step: {np.median(d):.4f} deg")
print("\n--- elevation per ring (median over valid columns) ---")
elr = np.where(valid, el, np.nan)
with np.errstate(all="ignore"):
ringel = np.nanmedian(elr, axis=1)
print(" ring0..9 :", np.round(ringel[:10], 2))
print(" ring60..69:", np.round(ringel[60:70], 2))
print(" ring118..127:", np.round(ringel[118:], 2))
print(f" elevation span: {np.nanmin(ringel):.2f} .. {np.nanmax(ringel):.2f}")
print("\n--- valid-return fraction by azimuth sector ---")
for lo, hi in [(-180, -90), (-90, -30), (-30, -10), (-10, 10), (10, 30), (30, 90), (90, 180)]:
sel = (colaz >= lo) & (colaz < hi)
if sel.sum() == 0:
print(f" [{lo:4d},{hi:4d}) : no columns")
continue
v = valid[:, sel]
print(f" [{lo:4d},{hi:4d}) : {sel.sum():5d} cols, valid {v.mean():.1%}")
print("\n--- forward cone (|az|<3 deg) range distribution ---")
fwd = np.abs(colaz) < 3.0
rf = r[:, fwd][valid[:, fwd]]
print(f" columns {fwd.sum()}, valid pts {rf.size}")
print(" pct:", np.round(np.percentile(rf, [50, 90, 99, 99.9, 100]), 2))
print("\n--- points beyond 100 m, in train gauge (|x|<1.6, -1<z<1.4) ---")
far = valid & (r > 100)
gauge = far & (np.abs(x) < 1.6) & (z > -1.0) & (z < 1.4)
print(f" far {far.sum()}, of them in gauge {gauge.sum()}")
if gauge.sum():
print(" max distance in gauge:", np.round(r[gauge].max(), 1))
if __name__ == "__main__":
main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 100)

65
tools/check_obstacle.py Normal file
View file

@ -0,0 +1,65 @@
"""Опорный тест: реальный объект на ~55 м в бэге `doubleT_obstacle`.
Печатает, в скольких кадрах он попал в кандидаты и в подтверждённые треки,
и сколько при этом было посторонних тревог.
python tools/check_obstacle.py [--memory artifacts/mushroom_body.npz]
"""
from __future__ import annotations
import argparse
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
TRUE_D = (50.0, 62.0) # объект стоит на 54.7…56.9 м всю запись
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag", default=str(B.DATA / "for_hackathon" / "doubleT_obstacle"))
ap.add_argument("--memory")
ap.add_argument("--limit", type=int, default=200)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
memory = MushroomBody.load(args.memory) if args.memory else None
fg = FlyGuard(Params(), memory=memory)
bag = Bag(args.bag)
n = cand_hit = track_hit = other = 0
novelties, evid = [], []
for k, (_, pc) in enumerate(bag.frames(stop=args.limit)):
res = fg.process(pc)
if res is None:
continue
n += 1
cs = [c for c in res.candidates if TRUE_D[0] < c.d < TRUE_D[1]]
if cs:
cand_hit += 1
novelties.append(max(c.novelty for c in cs))
objs = res.decision.objects
mine = [o for o in objs if TRUE_D[0] < o.distance < TRUE_D[1]]
if mine:
track_hit += 1
evid.append(max(o.confidence for o in mine))
other += len(objs) - len(mine)
if args.verbose and k % 20 == 0:
print(f" кадр {k:4d}: канд. в зоне {len(cs)}, "
f"треков всего {len(objs)}, в зоне {len(mine)}, "
f"ближайший {res.decision.distance:.1f} м")
print(f"кадров обработано: {n}")
print(f"объект среди кандидатов: {cand_hit}/{n} = {cand_hit/max(n,1):.1%}"
+ (f" новизна медиана {np.median(novelties):.3f}" if novelties else ""))
print(f"объект подтверждён треком: {track_hit}/{n} = {track_hit/max(n,1):.1%}"
+ (f" уверенность медиана {np.median(evid):.3f}" if evid else ""))
print(f"посторонних подтверждённых объектов: {other} ({other/max(n,1):.2f} на кадр)")
if __name__ == "__main__":
main()

108
tools/diagnose_fp.py Normal file
View file

@ -0,0 +1,108 @@
"""Разбор ложных тревог на одном бэге: кто именно сработал и почему.
Память обучается **без** проверяемого бэга (как в evaluate.py), затем конвейер
гоняется по нему и каждая подтверждённая цель записывается вместе с признаками
породившего её кандидата. На выходе — сводка по трекам: где стоял, сколько
кадров жил, какие у него размеры, новизна и наполненность габарита.
python tools/diagnose_fp.py --bag roundT_doubleT --device cuda
"""
from __future__ import annotations
import argparse
from collections import defaultdict
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.pipeline import FlyGuard, Params
from evaluate import OBSTACLE_BAG, TRUE_D, train_excluding
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--cache", default=str(B.CACHE / "tune_candidates.npz"))
ap.add_argument("--extra-cache", default=str(B.CACHE / "new_data_candidates.npz"))
ap.add_argument("--bag", default="roundT_doubleT")
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--device", default="cpu")
args = ap.parse_args()
from pathlib import Path
d = np.load(args.cache, allow_pickle=True)
per_bag = {str(k): d[f"X_{k}"].astype(np.float32) for k in d["names"]}
extra = (np.load(args.extra_cache)["X"].astype(np.float32)
if Path(args.extra_cache).exists() else None)
bag_path = next(p for p in find_bags(args.root) if p.name == args.bag)
memory = train_excluding(per_bag, extra, args.bag, 0.4, args.device)
fg = FlyGuard(Params(), memory=memory)
bag = Bag(bag_path)
is_obs = args.bag == OBSTACLE_BAG
per_track = defaultdict(list)
s_world = 0.0
n = 0
for k, (_, pc) in enumerate(bag.frames(stop=args.limit)):
res = fg.process(pc)
if res is None:
continue
n += 1
s_world += res.ego.ds if res.ego else 0.0
for o in res.decision.objects:
if is_obs and TRUE_D[0] < o.distance < TRUE_D[1]:
continue
# найти кандидата, породивший эту цель — ближайший по дальности
best, err = None, 1e9
for c in res.candidates:
e = abs(c.d - o.distance)
if e < err:
best, err = c, e
per_track[o.track_id].append(dict(
frame=k, s=s_world, d=o.distance, u=o.lateral, h=o.height,
w=o.width, sv=o.size_v, conf=o.confidence, nov=o.novelty,
rays=o.n_rays,
cd=best.d if best else np.nan,
cu=best.u if best else np.nan,
ch=best.h if best else np.nan,
chmin=best.h_min if best else np.nan,
cdepth=best.depth if best else np.nan,
ccont=best.containment if best else np.nan,
cgap=best.gap if best else np.nan,
caz=best.az_deg if best else np.nan,
cel=best.el_deg if best else np.nan,
cnov=best.novelty if best else np.nan,
cinten=best.inten if best else np.nan,
crings=best.n_rings if best else np.nan,
ccols=best.n_cols if best else np.nan,
cfloor=best.floor_deficit if best else np.nan,
cshadow=best.shadow if best else np.nan,
))
print(f"бэг {args.bag}: {n} кадров, путь {s_world:.0f} м, "
f"ложных треков {len(per_track)}\n")
hdr = (f"{'трек':>5s} {'кадров':>6s} {'кадры':>9s} {'d,м':>10s} {'u,м':>7s} "
f"{'h,м':>6s} {'hmin':>6s} {'shxsv':>11s} {'глуб':>5s} {'напол':>6s} "
f"{'зазор':>6s} {'лучей':>6s} {'кольц':>5s} {'стлб':>5s} "
f"{'нов':>5s} {'инт':>5s} {'az°':>7s} {'el°':>6s} {'пол.деф':>7s}")
print(hdr)
print("-" * len(hdr))
rows = sorted(per_track.items(), key=lambda kv: -len(kv[1]))
for tid, recs in rows:
a = {k: np.array([r[k] for r in recs], float) for k in recs[0]}
med = lambda k: float(np.nanmedian(a[k]))
print(f"{tid:5d} {len(recs):6d} {int(a['frame'][0]):4d}-{int(a['frame'][-1]):<4d} "
f"{med('d'):5.1f}→{a['d'][-1]:4.1f} {med('u'):7.2f} "
f"{med('h'):6.2f} {med('chmin'):6.2f} "
f"{med('w'):5.2f}x{med('sv'):<5.2f} {med('cdepth'):5.2f} "
f"{med('ccont'):6.2f} {med('cgap'):6.2f} {med('rays'):6.0f} "
f"{med('crings'):5.0f} {med('ccols'):5.0f} "
f"{med('nov'):5.2f} {med('cinten'):5.0f} "
f"{med('caz'):7.2f} {med('cel'):6.2f} {med('cfloor'):7.2f}")
if __name__ == "__main__":
main()

183
tools/evaluate.py Normal file
View file

@ -0,0 +1,183 @@
"""Оценка обобщаемости: leave-one-bag-out.
Память тоннеля обучается на всех данных, **кроме** проверяемого бэга, и только
после этого конвейер прогоняется по нему. Иначе цифры лгут: подавлять
конструкции, которые сам же и запомнил, умеет кто угодно, а на приватном тесте
будет новый участок тоннеля.
Отчёт: ложные тревоги на километр пути и доля кадров с тревогой для пустых
бэгов; для `doubleT_obstacle` — ещё и доля кадров, в которых найден настоящий
объект на ~55 м.
python tools/evaluate.py --device cuda --out artifacts/generalisation.json
"""
from __future__ import annotations
import argparse
import json
import time
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody, MushroomBodyConfig
from flyguard.pipeline import FlyGuard, Params
OBSTACLE_BAG = "doubleT_obstacle"
TRUE_D = (50.0, 62.0)
def train_excluding(per_bag: dict[str, np.ndarray], extra: np.ndarray | None,
exclude: str, target: float, device: str) -> MushroomBody:
parts = [v for k, v in per_bag.items() if k not in (exclude, OBSTACLE_BAG)]
if extra is not None:
parts.append(extra)
X = np.concatenate(parts).astype(np.float32)
mb = MushroomBody(MushroomBodyConfig())
mb.fit_normalizer(X)
mb.learn(X, rate=mb.auto_rate(X.shape[0], target), device=device)
return mb
def run_bag(bag_path, memory, limit: int, params: Params | None = None,
readout=None) -> dict:
fg = FlyGuard(params or Params(), memory=memory, readout=readout)
bag = Bag(bag_path)
n = alarms = obj_hits = fp_objects = 0
path_m = 0.0
fp_dists, times = [], []
# один и тот же лоток, попавший в треки, виден сотню кадров подряд; для
# эксплуатации важно не это, а сколько РАЗНЫХ ложных объектов возникло —
# именно столько раз поезд затормозил бы напрасно
fp_tracks: set[int] = set()
for _, pc in bag.frames(stop=limit):
res = fg.process(pc)
if res is None:
continue
n += 1
path_m += res.ego.ds if res.ego else 0.0
times.append(res.total_ms)
d = res.decision
is_obstacle_bag = bag.path.name == OBSTACLE_BAG
mine = [o for o in d.objects if TRUE_D[0] < o.distance < TRUE_D[1]] \
if is_obstacle_bag else []
others = [o for o in d.objects if o not in mine]
if mine:
obj_hits += 1
if others:
alarms += 1
fp_objects += len(others)
fp_tracks.update(o.track_id for o in others)
fp_dists.extend(o.distance for o in others)
km = max(path_m / 1000.0, 1e-6)
return dict(bag=bag.path.name, frames=n, path_m=path_m,
alarm_frames=alarms, alarm_rate=alarms / max(n, 1),
fp_objects=fp_objects, fp_tracks=len(fp_tracks),
fp_per_km=(len(fp_tracks) / km) if path_m > 5 else float("nan"),
fp_median_d=float(np.median(fp_dists)) if fp_dists else float("nan"),
obj_rate=obj_hits / max(n, 1) if bag.path.name == OBSTACLE_BAG else None,
ms_p50=float(np.median(times)) if times else 0.0,
ms_p95=float(np.percentile(times, 95)) if times else 0.0)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--cache", default=str(B.CACHE / "tune_candidates.npz"))
ap.add_argument("--extra-cache", default=str(B.CACHE / "new_data_candidates.npz"))
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--target", type=float, default=0.4)
ap.add_argument("--device", default="cpu")
ap.add_argument("--split-adv", type=float, default=None,
help="порог разделения фигуры и фона; 0 — выключить")
ap.add_argument("--split-gap", type=float, default=None,
help="порог разреза по контрасту ламины, м; 0 — выключить")
ap.add_argument("--split-near", type=float, default=None,
help="ближе этой дальности не резать, м")
ap.add_argument("--split-top", type=int, default=None,
help="сколько фигур выносить из одной компоненты; 0 — все")
ap.add_argument("--no-acc", action="store_true", help="выключить накопитель")
ap.add_argument("--no-hab", action="store_true", help="выключить привыкание")
ap.add_argument("--mbon", default="", help="путь к обученному считыванию MBON")
ap.add_argument("--mbon-dir", default="",
help="каталог с моделями по складкам (mbon_<бэг>.npz): "
"для каждого бэга берётся модель, его не видевшая")
ap.add_argument("--mbon-blend", type=float, default=None,
help="1 — только модель, 0 — только ручная формула")
ap.add_argument("--mbon-power", type=float, default=None,
help="резкость вероятности модели")
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
args = ap.parse_args()
d = np.load(args.cache, allow_pickle=True)
per_bag = {str(k): d[f"X_{k}"].astype(np.float32) for k in d["names"]}
extra = None
from pathlib import Path
if Path(args.extra_cache).exists():
extra = np.load(args.extra_cache)["X"].astype(np.float32)
print(f"дополнительно в обучение: {extra.shape[0]} кандидатов из new_data")
over = {}
if args.split_adv is not None:
over["split_adv"] = args.split_adv
if args.split_gap is not None:
over["split_gap"] = args.split_gap
if args.split_near is not None:
over["split_near"] = args.split_near
if args.split_top is not None:
over["split_top"] = args.split_top
if args.no_acc:
over["enable_accumulator"] = False
if args.no_hab:
over["enable_habituation"] = False
if args.mbon_blend is not None:
over["mbon_blend"] = args.mbon_blend
if args.mbon_power is not None:
over["mbon_power"] = args.mbon_power
params = Params(**over)
readout = None
folds = {}
if args.mbon_dir:
from pathlib import Path as _P
from flyguard.mbon_readout import MbonReadout
for f in _P(args.mbon_dir).glob("mbon_*.npz"):
folds[f.stem[len("mbon_"):]] = MbonReadout.load(f)
print(f"считывание MBON по складкам: {args.mbon_dir} "
f"({len(folds)} моделей)")
elif args.mbon:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
rows = []
for p in find_bags(args.root):
t0 = time.time()
mem = train_excluding(per_bag, extra, p.name, args.target, args.device)
rd = folds.get(p.name, readout) if folds else readout
if folds and p.name not in folds and p.name != OBSTACLE_BAG:
print(f" внимание: для {p.name} нет своей складки")
r = run_bag(p, mem, args.limit, params, readout=rd)
r["train_size"] = int(mem.n_seen)
rows.append(r)
obj = f"объект {r['obj_rate']:6.1%} | " if r["obj_rate"] is not None else ""
print(f"{r['bag']:40s} кадров {r['frames']:4d} путь {r['path_m']:6.0f} м | "
f"{obj}тревог {r['alarm_rate']:6.1%} | ложных треков {r['fp_tracks']:3d} "
f"({r['fp_per_km']:6.1f} на км) | {r['ms_p50']:5.1f}/{r['ms_p95']:5.1f} мс | "
f"{time.time()-t0:5.0f} с", flush=True)
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=1)
empty = [r for r in rows if r["bag"] != OBSTACLE_BAG]
print("\nсводка по пустым бэгам:")
print(f" доля кадров с ложной тревогой: {np.mean([r['alarm_rate'] for r in empty]):.2%}")
fp_km = [r["fp_per_km"] for r in empty if np.isfinite(r["fp_per_km"])]
if fp_km:
print(f" разных ложных треков на километр: {np.mean(fp_km):.1f} "
f"(медиана {np.median(fp_km):.1f})")
print(f" суммарный путь: {sum(r['path_m'] for r in empty):.0f} м")
print("сохранено:", args.out)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,118 @@
"""Извлечение таблицы каналов Pandar128E3X из руководства (Приложение A).
Даёт для каждого из 128 каналов: проектный азимутальный сдвиг и угол места,
диапазон измерения, признак ближнего поля, максимальную дальность при 10 %
отражения, признак «дальнобойного» канала и минимальную различимую
отражательную способность.
Зачем: поканальная дальность делает модель сенсора в синтетическом полигоне
честной (каналы 34–65 видят 200 м, а 98–128 — только ближнее поле), а
проектные углы служат независимой проверкой калибровки решётки по данным.
python tools/extract_channel_table.py --pdf <путь> --out ros2_ws/src/flyguard/flyguard/data/pandar128_channels.csv
"""
from __future__ import annotations
import argparse
import csv
import re
from pathlib import Path
ANGLE = re.compile(r"^-?\d+\.\d+°$")
METERS = re.compile(r"^\(?(\d+(?:\.\d+)?)\s*m\)?$")
# в таблице встречаются типографские варианты: минус, полноширинные скобки
_NORMALISE = str.maketrans({"–": "-", "—": "-", "−": "-", "(": "(", ")": ")", " ": " "})
def _clean(s: str) -> str:
return s.translate(_NORMALISE).strip()
def parse(pdf_path: str) -> list[dict]:
import fitz
doc = fitz.open(pdf_path)
tokens: list[str] = []
for page in doc:
text = page.get_text()
if "Appendix A: Channel distribution data" not in text:
continue
if "Chann" not in text and not re.search(r"^\d+\s*$", text, re.M):
continue
tokens.extend(_clean(line) for line in text.splitlines() if line.strip())
# строки собираются в словарь по номеру канала, а не последовательно:
# один сбой синхронизации на границе страницы иначе обрывает весь разбор
found: dict[int, dict] = {}
i = 0
while i < len(tokens) - 9:
# строка начинается с номера канала, за которым идут два угла
if (tokens[i].isdigit() and ANGLE.match(tokens[i + 1] or "")
and ANGLE.match(tokens[i + 2] or "")):
ch = int(tokens[i])
if not 1 <= ch <= 128 or ch in found:
i += 1
continue
chunk = tokens[i:i + 10]
m_min = METERS.match(chunk[3])
m_max = METERS.match(chunk[4])
near = chunk[5].upper() == "YES"
m_10 = METERS.match(chunk[6])
far = chunk[7].upper() == "YES"
refl = chunk[8]
highres = chunk[9].upper() == "YES"
if not (m_min and m_max and m_10):
i += 1
continue
found[ch] = dict(
channel=ch,
az_offset_deg=float(chunk[1].rstrip("°")),
elevation_deg=float(chunk[2].rstrip("°")),
range_min_m=float(m_min.group(1)),
range_max_m=float(m_max.group(1)),
near_field=int(near),
max_range_10pct_m=float(m_10.group(1)),
far_field=int(far),
min_reflectivity=refl,
high_res=int(highres),
)
i += 10
else:
i += 1
return [found[k] for k in sorted(found)]
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--pdf", required=True)
ap.add_argument("--out", required=True)
args = ap.parse_args()
rows = parse(args.pdf)
if len(rows) != 128:
print(f"ВНИМАНИЕ: разобрано {len(rows)} каналов вместо 128")
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
import numpy as np
az = np.array([r["az_offset_deg"] for r in rows])
el = np.array([r["elevation_deg"] for r in rows])
r10 = np.array([r["max_range_10pct_m"] for r in rows])
print(f"разобрано каналов: {len(rows)} → {out}")
print(f" азимутальный сдвиг: {az.min():+.3f}°…{az.max():+.3f}° "
f"(размах {az.ptp() if hasattr(az,'ptp') else np.ptp(az):.3f}°)")
print(f" угол места: {el.min():+.3f}°…{el.max():+.3f}°")
print(f" макс. дальность @10%: {r10.min():.0f}…{r10.max():.0f} м, "
f"каналов с 200 м: {(r10 >= 200).sum()}")
print(f" ближнего поля: {sum(r['near_field'] for r in rows)}, "
f"дальнобойных: {sum(r['far_field'] for r in rows)}, "
f"высокого разрешения: {sum(r['high_res'] for r in rows)}")
if __name__ == "__main__":
main()

114
tools/inspect_bags.py Normal file
View file

@ -0,0 +1,114 @@
"""Инвентаризация бэгов и калибровка омматидиальной решётки.
Для каждого бэга: состав топиков, раскладка скана, углы, проверка того, что
калиброванная решётка действительно описывает направления лучей.
python tools/inspect_bags.py # все бэги под data/
python tools/inspect_bags.py --bag data/for_hackathon/doubleT_obstacle
"""
from __future__ import annotations
import argparse
import numpy as np
import _bootstrap as B # noqa: F401 (добавляет пакет в sys.path)
from flyguard.bag import Bag, find_bags
from flyguard.retina import ScanLayout
N_CALIB_FRAMES = 12
def check_layout(layout: ScanLayout, bag: Bag, n: int = 3) -> dict:
"""Насколько точно решётка предсказывает направления реальных точек.
Сравнение делается после выпрямления: направления точек проходят ту же
выборку `gather`, что и дальности, и сверяются с таблицей `layout.dirs`.
"""
H, W, E = layout.n_rings, layout.n_az, layout.n_echo
ang_err, rng_err = [], []
for _, pc in bag.frames(start=5, stop=5 + n):
img = layout.project(pc)
raw = np.stack([pc.points[k].reshape(W, E, H).transpose(2, 0, 1)
for k in ("x", "y", "z")], axis=-1) # (H, W, E, 3)
r_raw = np.linalg.norm(raw, axis=-1)
near = np.argmin(np.where(r_raw > 0, r_raw, np.inf), axis=-1)
sel = np.take_along_axis(raw, near[..., None, None], -2)[..., 0, :]
r_sel = np.take_along_axis(r_raw, near[..., None], -1)[..., 0]
g = layout.gather
sel = np.take_along_axis(sel, g[..., None], 1)
r_sel = np.take_along_axis(r_sel, g, 1)
m = img.valid & (r_sel > 1.0)
if not m.any():
continue
unit = sel[m] / r_sel[m][:, None]
cos = np.clip(np.einsum("ij,ij->i", unit, layout.dirs[m]), -1, 1)
ang_err.append(np.degrees(np.arccos(cos)))
rng_err.append(np.abs(r_sel[m] - img.r_near[m]))
if not ang_err:
return {}
ang = np.concatenate(ang_err)
return {
"ang_p50": float(np.percentile(ang, 50)),
"ang_p99": float(np.percentile(ang, 99)),
"ang_max": float(ang.max()),
"rng_max": float(np.concatenate(rng_err).max()),
"n": int(ang.size),
}
def summarise(bag_path) -> None:
bag = Bag(bag_path)
print("=" * 78)
print(bag.describe())
clouds = [pc for _, pc in bag.frames(start=2, stop=2 + N_CALIB_FRAMES)]
layout = ScanLayout.calibrate(clouds)
print(f" раскладка: {layout}")
pc = clouds[0]
img = layout.project(pc)
r = img.r_near[img.valid]
print(f" точек в кадре: {pc.n_points} валидных лучей: {img.valid.mean():6.1%}")
print(f" дальность: p50={np.percentile(r, 50):6.1f} p99={np.percentile(r, 99):6.1f} "
f"max={r.max():6.1f}")
sep = img.r_far - img.r_near
two = img.valid & (sep > 0.05)
print(f" лучей с двумя различимыми эхами: {two.mean():6.2%}"
+ (f", разнос p50={np.median(sep[two]):.2f} м p95={np.percentile(sep[two], 95):.2f} м"
if two.any() else ""))
sh = layout.col_shift
print(f" скос каналов: {sh.min()}…{sh.max()} столбцов "
f"({sh.min() * layout.az_step_deg:+.2f}°…{sh.max() * layout.az_step_deg:+.2f}°), "
f"остаток выпрямления |max| {np.abs(layout.az_resid_deg).max():.4f}°")
chk = check_layout(layout, bag)
if chk:
print(f" ПРОВЕРКА решётки по {chk['n']} лучам: угловая ошибка "
f"p50={chk['ang_p50']:.4f}° p99={chk['ang_p99']:.4f}° max={chk['ang_max']:.4f}°, "
f"ошибка дальности max={chk['rng_max']:.2e} м")
out = B.CACHE / "layouts"
out.mkdir(parents=True, exist_ok=True)
layout.save(out / f"{bag.path.name}.npz")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag", action="append", help="путь к бэгу (можно несколько)")
ap.add_argument("--root", default=str(B.DATA), help="корень для поиска бэгов")
args = ap.parse_args()
bags = [__import__("pathlib").Path(b) for b in args.bag] if args.bag else find_bags(args.root)
if not bags:
raise SystemExit(f"бэги не найдены под {args.root}")
for b in bags:
summarise(b)
if __name__ == "__main__":
main()

143
tools/make_benchmark.py Normal file
View file

@ -0,0 +1,143 @@
"""Размеченный полигон: сценарии сближения с синтетическим препятствием.
Для каждого бэга и каждого типа предмета строится сценарий: предмет ставится
в фиксированную точку тоннеля далеко впереди, поезд к нему подъезжает, и на
каждом кадре известна истинная дистанция. Отсюда получаются именно те цифры,
которые просит ТЗ: с какой дальности предмет уверенно виден, сколько ложных
тревог и как это зависит от размера.
Первый проход считает собственное движение по чистым данным (это и есть
разметка по дистанции), второй — гоняет конвейер по кадрам со вставленным
предметом. Все сценарии одного бэга обрабатываются в одном проходе по файлу:
чтение данных дороже самой обработки.
python tools/make_benchmark.py --out artifacts/benchmark.npz --memory artifacts/mushroom_body.npz
"""
from __future__ import annotations
import argparse
import json
import time
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import Placement, catalogue, inject
HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект
def ego_track(bag: Bag, params: Params, limit: int | None):
"""Первый проход: пройденный путь на каждом кадре (разметка по дистанции)."""
fg = FlyGuard(params, memory=None)
s, stamps = [], []
total = 0.0
for _, pc in bag.frames(stop=limit):
res = fg.process(pc)
if res is None:
s.append(None); stamps.append(pc.stamp); continue
total += res.ego.ds if res.ego else 0.0
s.append(total); stamps.append(pc.stamp)
return s, stamps
def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
laterals: tuple[float, ...], seed: int, readout=None) -> list[dict]:
bag = Bag(bag_path)
s_track, _ = ego_track(bag, params, limit)
have = [x for x in s_track if x is not None]
if len(have) < 20:
return []
travel = have[-1] - have[0]
cat = catalogue()
scen = [(name, lat) for name in cat for lat in laterals]
pipes = [FlyGuard(params, memory=memory, readout=readout) for _ in scen]
rng = np.random.default_rng(seed)
records = [[] for _ in scen]
# решётка и поза нужны для вставки — берутся из отдельного «чистого» конвейера
guide = FlyGuard(params, memory=None)
for k, (_, pc) in enumerate(bag.frames(stop=limit)):
gres = guide.process(pc)
if gres is None or s_track[k] is None:
continue
s_now = s_track[k] - have[0]
for i, (name, lat) in enumerate(scen):
d_true = d_start - s_now
if d_true < 6.0:
continue
# Предмет лежит НА ПУТИ, а путь в кривой уходит вбок: на 150 м при
# радиусе 1300 м это 8.6 м. Если ставить его в поперечных координатах
# сенсора, он окажется в стене, а не в габарите.
u_obj = float(gres.corridor.centre(np.array([d_true], np.float32))[0]) + lat
pc2, lab = inject(pc, guide.layout_full, gres.plane, cat[name],
Placement(d=d_true, u=u_obj), rng=rng)
res = pipes[i].process(pc2)
if res is None:
continue
tol = max(3.0, 0.12 * d_true)
hit = any(abs(o.distance - d_true) < tol for o in res.decision.objects)
fp = sum(1 for o in res.decision.objects if abs(o.distance - d_true) >= tol)
records[i].append((d_true, int(hit), fp, lab["hit_rays"]))
out = []
for (name, lat), rec in zip(scen, records):
if not rec:
continue
a = np.array(rec, np.float32)
out.append(dict(bag=bag.path.name, obj=name, lateral=lat,
d=a[:, 0].tolist(), hit=a[:, 1].tolist(),
fp=a[:, 2].tolist(), rays=a[:, 3].tolist(),
travel=float(travel)))
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--memory")
ap.add_argument("--mbon", default="",
help="обученное считывание MBON; как и память, оно "
"видело эти бэги: полигон меряет дальность, "
"а не обобщаемость")
ap.add_argument("--out", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--d-start", type=float, default=200.0)
ap.add_argument("--laterals", default="0.0,0.9")
ap.add_argument("--seed", type=int, default=12345)
args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
memory = MushroomBody.load(args.memory) if args.memory else None
readout = None
if args.mbon:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
params = Params()
laterals = tuple(float(x) for x in args.laterals.split(","))
all_rec = []
for p in find_bags(args.root):
if p.name == HOLDOUT:
continue
t0 = time.time()
rec = run_bag(p, params, memory, args.limit, args.d_start, laterals,
args.seed, readout=readout)
all_rec.extend(rec)
n = sum(len(r["d"]) for r in rec)
print(f" {p.name:42s} сценариев {len(rec):3d}, наблюдений {n:6d}, "
f"{time.time()-t0:6.1f} с", flush=True)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(all_rec, f, ensure_ascii=False)
print(f"сохранено: {args.out} ({len(all_rec)} сценариев)")
if __name__ == "__main__":
main()

176
tools/make_training_set.py Normal file
View file

@ -0,0 +1,176 @@
"""Размеченная выборка кандидатов: предмет против тоннельной обстановки.
Разметки в датасете нет, и до сих пор это определяло архитектуру: грибовидное
тело учится **без меток**, запоминая частоту обстановки. Но у нас есть
физически обоснованный генератор предметов (`flyguard.synth`), сверенный с
единственным реальным объектом: настоящий 0.67 × 1.35 м на 55 м даёт 47–69
лучей, синтетический человек 0.44 × 1.71 м на 60 м — 50. Значит, метки можно
изготовить, и изготовить достоверно.
Каждый кандидат помечается по **пересечению лучей**, а не «по дальности
примерно»: `inject` возвращает индексы лучей, в которые предмет действительно
записан, и кандидат считается предметом, если его ядро состоит из этих лучей.
Так структура тоннеля, случайно оказавшаяся на той же дальности, в
положительные не попадает.
Сценарии намеренно ставят предмет в РАЗНЫЕ точки тоннеля (`--d-starts`): замер
показал, что одна и та же дальность в разных местах перегона ведёт себя
совершенно по-разному — где-то предмет виден целиком, где-то за поворотом
(EXPERIMENTS п. 9.6). Обучаться на одной точке постановки значит выучить эту
точку.
`doubleT_obstacle` исключён целиком: там настоящий объект, и он остаётся
независимой проверкой того, что модель выучила предмет, а не «синтетику».
python tools/make_training_set.py --out data/cache/training_set.npz
"""
from __future__ import annotations
import argparse
import time
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import FEATURES, describe
from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import Placement, catalogue, inject
HOLDOUT = "doubleT_obstacle" # там реальный объект — только для проверки
MIN_OVERLAP = 0.5 # доля лучей ядра, пришедших от предмета
def ego_track(bag: Bag, params: Params, limit: int):
"""Первый проход: пройденный путь на каждом кадре и общая решётка."""
fg = FlyGuard(params, memory=None)
s, total = [], 0.0
for _, pc in bag.frames(stop=limit):
res = fg.process(pc)
if res is None:
s.append(None)
continue
total += res.ego.ds if res.ego else 0.0
s.append(total)
return s, fg
def collect_bag(path, params: Params, limit: int, d_starts, laterals, seed: int):
bag = Bag(path)
s_track, ego_fg = ego_track(bag, params, limit)
have = [x for x in s_track if x is not None]
if len(have) < 30:
return None
s0 = have[0]
layout = ego_fg.layout_full
col0 = ego_fg.cols.start
cat = catalogue()
scen = [(n, lat, d0) for n in cat for lat in laterals for d0 in d_starts]
guide = FlyGuard(params, memory=None, layout=layout)
pipes = [FlyGuard(params, memory=None, layout=layout) for _ in scen]
rng = np.random.default_rng(seed)
X, y, dd, obj, lat_out = [], [], [], [], []
for k, (_, pc) in enumerate(bag.frames(stop=limit)):
g = guide.process(pc)
if g is None or s_track[k] is None:
continue
s_now = s_track[k] - s0
for i, (name, lat, d0) in enumerate(scen):
d_true = d0 - s_now
if d_true < 6.0:
continue
u = float(g.corridor.centre(np.array([d_true], np.float32))[0]) + lat
pc2, lab = inject(pc, layout, g.plane, cat[name],
Placement(d=d_true, u=u), rng=rng)
res = pipes[i].process(pc2)
if res is None or not res.candidates:
continue
rr = lab.get("rays")
if rr is None or lab["hit_rays"] == 0:
truth = None
else:
# лучи предмета в координатах полной решётки → плоский индекс;
# отсортированный массив, а не множество: проверка идёт
# сотни тысяч раз, и `in` по множеству тут заметно дороже
truth = np.sort((rr[0].astype(np.int64) << 20)
| rr[1].astype(np.int64))
for c in res.candidates:
ii, jj = c.extra.get("rays", (None, None))
if ii is None:
continue
lbl = 0
if truth is not None and truth.size:
key = ((ii.astype(np.int64) << 20)
| (jj.astype(np.int64) + col0))
pos = np.searchsorted(truth, key)
np.clip(pos, 0, truth.size - 1, out=pos)
frac = float((truth[pos] == key).mean())
lbl = int(frac >= MIN_OVERLAP)
v = describe(c)
acc = float(c.extra.get("acc_support", 0.0)) if c.extra else 0.0
X.append(np.append(v, acc).astype(np.float32))
y.append(lbl)
dd.append(c.d)
obj.append(name if lbl else "")
lat_out.append(lat)
return (np.asarray(X, np.float32), np.asarray(y, np.int8),
np.asarray(dd, np.float32), np.asarray(obj),
np.asarray(lat_out, np.float32))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--out", default=str(B.CACHE / "training_set.npz"))
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--d-starts", default="80,140,200")
# Поперечные положения намеренно кроют ВЕСЬ габарит, а не только ось.
# Иначе выборка вырождается: предметы у оси, обстановка у стен, и модель
# выучивает «всё, что у оси — предмет» вместо признаков предмета. Проверено
# на пробном прогоне с одной постановкой: AUC 1.000 по всем бэгам — цифра
# красивая и бессмысленная, а в тоннеле у оси полно штатных конструкций.
ap.add_argument("--laterals", default="0,-0.6,0.6,-1.2,1.2")
ap.add_argument("--seed", type=int, default=20260921)
args = ap.parse_args()
d_starts = tuple(float(x) for x in args.d_starts.split(","))
laterals = tuple(float(x) for x in args.laterals.split(","))
params = Params()
B.CACHE.mkdir(parents=True, exist_ok=True)
parts = {}
for p in find_bags(args.root):
if p.name == HOLDOUT:
continue
t0 = time.time()
got = collect_bag(p, params, args.limit, d_starts, laterals, args.seed)
if got is None:
print(f" {p.name:42s} пропущен")
continue
parts[p.name] = got
Xb, yb = got[0], got[1]
print(f" {p.name:42s} {Xb.shape[0]:7d} кандидатов, "
f"предметов {int(yb.sum()):6d} ({yb.mean():5.1%}), "
f"{time.time() - t0:6.0f} с", flush=True)
if not parts:
raise SystemExit("ничего не собрано")
out = {}
for name, (Xb, yb, db, ob, lb) in parts.items():
out[f"X_{name}"] = Xb
out[f"y_{name}"] = yb
out[f"d_{name}"] = db
out[f"obj_{name}"] = ob
out[f"lat_{name}"] = lb
np.savez_compressed(args.out, names=np.array(list(parts)),
features=np.array(FEATURES + ("acc_support",)), **out)
tot = sum(v[0].shape[0] for v in parts.values())
pos = sum(int(v[1].sum()) for v in parts.values())
print(f"\nвсего {tot} кандидатов, предметов {pos} ({pos / tot:.1%})")
print("сохранено:", args.out)
if __name__ == "__main__":
main()

131
tools/plot_benchmark.py Normal file
View file

@ -0,0 +1,131 @@
"""Кривые дальности обнаружения по размеру предмета.
Читает результат `tools/make_benchmark.py` и строит то, что требует ТЗ, п. 5:
с какой дистанции предмет уверенно виден и как это зависит от его размера.
Считаются две величины:
* **вероятность обнаружения в поясе дальности** — доля кадров, в которых
подтверждённый трек совпал с истинным положением предмета;
* **рабочая дальность** — самая дальняя точка, начиная с которой вероятность
устойчиво держится выше порога при сближении. Именно она отвечает на вопрос
«за сколько метров поезд увидел препятствие».
python tools/plot_benchmark.py --in artifacts/benchmark.json
"""
from __future__ import annotations
import argparse
import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402
EDGES = np.array([0, 15, 25, 40, 55, 70, 90, 110, 135, 160, 190, 230], float)
MIN_VISIBLE_RAYS = 2 # меньше — предмет физически не освещён лучами
def curve(d: np.ndarray, hit: np.ndarray):
"""Вероятность обнаружения по поясам дальности."""
idx = np.digitize(d, EDGES) - 1
p, n, centres = [], [], []
for b in range(len(EDGES) - 1):
m = idx == b
if m.sum() < 5:
continue
p.append(hit[m].mean())
n.append(int(m.sum()))
centres.append(0.5 * (EDGES[b] + EDGES[b + 1]))
return np.array(centres), np.array(p), np.array(n)
def working_range(centres: np.ndarray, p: np.ndarray, thr: float = 0.5) -> float:
"""Дальняя граница устойчивого обнаружения.
Идём от ближнего пояса к дальнему и останавливаемся там, где вероятность
впервые опускается ниже порога: дальше объект уже теряется.
"""
best = 0.0
for c, v in sorted(zip(centres, p)):
if v < thr:
break
best = c
return best
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--in", dest="path", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--thr", type=float, default=0.5)
args = ap.parse_args()
rec = json.load(open(args.path, encoding="utf-8"))
by_obj: dict[str, list] = {}
for r in rec:
by_obj.setdefault(r["obj"], []).append(r)
print(f"{'предмет':16s} {'площадь':>11s} | {'алгоритм':>8s} {'как есть':>9s} | "
f"{'P@50м':>6s} {'P@100м':>7s} {'P@150м':>7s} | {'видим':>7s} | {'набл.':>6s}")
print(f"{'':16s} {'':11s} | {'рабочая дальность, м':>18s} | "
f"{'при условии видимости':>22s} |")
print("-" * 104)
fig, axes = plt.subplots(1, 3, figsize=(19, 5.2), dpi=110)
for name, rows in sorted(by_obj.items(), key=lambda kv: -_size(kv[0])):
d = np.concatenate([np.array(r["d"]) for r in rows])
hit = np.concatenate([np.array(r["hit"]) for r in rows])
rays = np.concatenate([np.array(r["rays"]) for r in rows])
# видимость: в кривом тоннеле предмет за поворотом просто не освещён
# лучами, и «непопадание» там ничего не говорит об алгоритме
vis = rays >= MIN_VISIBLE_RAYS
cv, pv, _ = curve(d, vis.astype(float))
c, p, _ = curve(d, hit)
cc, pc_, _ = curve(d[vis], hit[vis]) if vis.any() else (np.array([]),) * 3
if c.size == 0:
continue
wr_alg = working_range(cc, pc_, args.thr) if cc.size else 0.0
wr_op = working_range(c, p, args.thr)
at = {x: float(np.interp(x, cc, pc_)) if cc.size else 0.0 for x in (50, 100, 150)}
print(f"{name:16s} {_size(name):8.2f} м² | {wr_alg:8.0f} {wr_op:9.0f} | "
f"{at[50]:6.2f} {at[100]:7.2f} {at[150]:7.2f} | {vis.mean():7.1%} | {d.size:6d}")
axes[0].plot(cv, pv, marker=".", lw=1.2, label=name)
if cc.size:
axes[1].plot(cc, pc_, marker="o", ms=3.5, lw=1.4, label=name)
axes[2].plot(c, p, marker="o", ms=3.5, lw=1.4, label=name)
axes[0].set_title(f"1. Виден ли предмет вообще\n(≥{MIN_VISIBLE_RAYS} лучей попало)")
axes[0].set_ylabel("доля кадров")
axes[1].set_title("2. Обнаружение, когда предмет виден\n(качество алгоритма)")
axes[1].set_ylabel("вероятность обнаружения")
axes[2].set_title("3. Обнаружение как есть\n(эксплуатационная величина)")
axes[2].set_ylabel("вероятность обнаружения")
for ax in axes:
ax.axhline(args.thr, color="k", ls="--", lw=0.8)
ax.set_xlabel("истинная дистанция до предмета, м")
ax.set_ylim(-0.03, 1.03); ax.grid(alpha=0.25)
ax.legend(fontsize=7, ncol=2)
B.FIGURES.mkdir(parents=True, exist_ok=True)
out = B.FIGURES / "detection_range.png"
fig.tight_layout(); fig.savefig(out)
print("\nсохранено:", out)
fp = np.concatenate([np.array(r["fp"]) for r in rec])
print(f"посторонних тревог: {fp.sum():.0f} на {fp.size} наблюдений "
f"({fp.mean():.3f} на кадр)")
def _size(name: str) -> float:
from flyguard.synth import catalogue
w, h = catalogue()[name].size
return w * h
if __name__ == "__main__":
main()

128
tools/probe_bag.py Normal file
View file

@ -0,0 +1,128 @@
"""Minimal, dependency-free reader for ROS 2 sqlite3 bags with sensor_msgs/PointCloud2.
Used for offline data exploration on Windows (no ROS installed).
Parses CDR (little-endian) encapsulated PointCloud2 messages.
"""
from __future__ import annotations
import sqlite3
import struct
import sys
import numpy as np
_DTYPES = {
1: ("i1", 1), 2: ("u1", 1), 3: ("i2", 2), 4: ("u2", 2),
5: ("i4", 4), 6: ("u4", 4), 7: ("f4", 4), 8: ("f8", 8),
}
class _Cdr:
"""Little-endian CDR reader with proper primitive alignment."""
def __init__(self, buf: bytes):
self.buf = buf
self.origin = 4 # skip encapsulation header
self.pos = 4
def _align(self, size: int) -> None:
rel = self.pos - self.origin
pad = (-rel) % size
self.pos += pad
def u8(self) -> int:
v = self.buf[self.pos]
self.pos += 1
return v
def u32(self) -> int:
self._align(4)
v = struct.unpack_from("<I", self.buf, self.pos)[0]
self.pos += 4
return v
def i32(self) -> int:
self._align(4)
v = struct.unpack_from("<i", self.buf, self.pos)[0]
self.pos += 4
return v
def string(self) -> str:
n = self.u32()
s = self.buf[self.pos:self.pos + n - 1].decode("utf-8", "replace")
self.pos += n
return s
def bytes(self, n: int) -> bytes:
v = self.buf[self.pos:self.pos + n]
self.pos += n
return v
def parse_pointcloud2(blob: bytes) -> dict:
c = _Cdr(blob)
sec = c.i32()
nsec = c.u32()
frame_id = c.string()
height = c.u32()
width = c.u32()
nfields = c.u32()
fields = []
for _ in range(nfields):
name = c.string()
offset = c.u32()
datatype = c.u8()
count = c.u32()
fields.append((name, offset, datatype, count))
is_bigendian = c.u8()
point_step = c.u32()
row_step = c.u32()
n_bytes = c.u32()
data = c.bytes(n_bytes)
is_dense = c.u8()
dt_fields = []
used = 0
for name, offset, datatype, count in fields:
kind, size = _DTYPES[datatype]
if offset > used:
dt_fields.append((f"_pad{used}", f"V{offset - used}"))
dt_fields.append((name, kind if count == 1 else f"{count}{kind}"))
used = offset + size * count
if point_step > used:
dt_fields.append((f"_pad{used}", f"V{point_step - used}"))
dtype = np.dtype([(n, t) for n, t in dt_fields])
assert dtype.itemsize == point_step, (dtype.itemsize, point_step)
arr = np.frombuffer(data, dtype=dtype, count=height * width)
return dict(stamp=sec + nsec * 1e-9, frame_id=frame_id, height=height, width=width,
fields=fields, point_step=point_step, row_step=row_step,
is_dense=is_dense, is_bigendian=is_bigendian, points=arr)
def frames(db_path: str, limit: int | None = None, start: int = 0):
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
q = "SELECT timestamp, data FROM messages ORDER BY timestamp"
if limit is not None:
q += f" LIMIT {limit} OFFSET {start}"
for ts, blob in con.execute(q):
yield ts, parse_pointcloud2(blob)
con.close()
if __name__ == "__main__":
path = sys.argv[1]
idx = int(sys.argv[2]) if len(sys.argv) > 2 else 0
for ts, m in frames(path, limit=1, start=idx):
print("stamp", m["stamp"], "frame_id", m["frame_id"])
print("height", m["height"], "width", m["width"], "point_step", m["point_step"],
"dense", m["is_dense"], "bigendian", m["is_bigendian"])
print("fields:")
for f in m["fields"]:
print(" ", f)
p = m["points"]
print("npoints", p.shape)
for name in p.dtype.names:
if name.startswith("_pad"):
continue
v = p[name]
print(f" {name:12s} dtype={v.dtype} min={np.min(v)} max={np.max(v)} mean={np.mean(v.astype(np.float64)):.4f}")

80
tools/run_pipeline.py Normal file
View file

@ -0,0 +1,80 @@
"""Сквозной прогон конвейера по бэгу: решения, треки, тайминги.
python tools/run_pipeline.py --bag data/for_hackathon/doubleT_obstacle --verbose
python tools/run_pipeline.py --all --memory artifacts/mushroom_body.npz
"""
from __future__ import annotations
import argparse
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
def run(bag_path, params: Params, memory, limit: int | None, verbose: bool) -> dict:
bag = Bag(bag_path)
fg = FlyGuard(params, memory=memory)
stages: dict[str, list[float]] = {}
n_det = n_frames = 0
dists, speeds, ncand = [], [], []
for _, pc in bag.frames(stop=limit):
res = fg.process(pc)
if res is None:
continue
n_frames += 1
for k, v in res.timings.items():
stages.setdefault(k, []).append(v)
ncand.append(len(res.candidates))
speeds.append(res.ego.speed if res.ego else 0.0)
d = res.decision
if d.detected:
n_det += 1
dists.append(d.distance)
if verbose and n_frames % 10 == 0:
obj = (f"{d.distance:6.1f} м conf={d.confidence:.2f} "
f"{'ЭКСТРЕННО' if d.emergency else 'предупр.'}"
if d.detected else "путь свободен")
print(f" кадр {n_frames:4d} v={res.ego.speed*3.6:5.1f} км/ч "
f"канд.={len(res.candidates):3d} треков={len(fg.cx.tracks):3d} | {obj} "
f"| {res.total_ms:5.1f} мс")
tot = np.array([sum(v[i] for v in stages.values()) for i in range(n_frames)]) \
if n_frames else np.zeros(1)
res = dict(name=bag.path.name, frames=n_frames, det_rate=n_det / max(n_frames, 1),
p50=float(np.median(tot)), p95=float(np.percentile(tot, 95)),
cands=float(np.mean(ncand)) if ncand else 0.0,
v=float(np.median(speeds) * 3.6) if speeds else 0.0,
d_med=float(np.median(dists)) if dists else float("nan"))
print(f"{res['name']:40s} кадров {res['frames']:4d} | тревога в {res['det_rate']:6.1%} "
f"кадров (медиана {res['d_med']:6.1f} м) | канд./кадр {res['cands']:5.1f} | "
f"v={res['v']:5.1f} км/ч | {res['p50']:5.1f}/{res['p95']:5.1f} мс (p50/p95)")
if verbose:
for k in sorted(stages, key=lambda k: -np.median(stages[k])):
print(f" {k:12s} {np.median(stages[k]):6.2f} мс")
return res
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag")
ap.add_argument("--all", action="store_true")
ap.add_argument("--limit", type=int, default=150)
ap.add_argument("--memory", default=None)
ap.add_argument("--fov", type=float, default=30.0)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
memory = MushroomBody.load(args.memory) if args.memory else None
params = Params(fov_deg=args.fov)
bags = find_bags(B.DATA / "for_hackathon") if args.all else [args.bag]
for b in bags:
run(b, params, memory, args.limit, args.verbose)
if __name__ == "__main__":
main()

202
tools/train_mbon.py Normal file
View file

@ -0,0 +1,202 @@
"""Обучение считывания MBON с учителем и честная проверка по бэгам.
Метки изготовлены физикой (`tools/make_training_set.py`), поэтому впервые можно
обучать не «частоту обстановки», а прямо различение «предмет / тоннель». Слои
при этом те же: разрежённый код клеток Кеньона, торможение APL, один выход
MBON — меняется только учитель (см. `flyguard/mbon_readout.py`).
Проверка — leave-one-bag-out: модель обучается на всех бэгах, кроме
проверяемого. Рядом считаются три вещи, без которых цифрам верить нельзя:
* **развёртка по ёмкости** — сколько клеток Кеньона реально нужно. Отбор
«победитель забирает всё» стоит дороже матмула, и в худшем кадре (64
кандидата) 20 000 клеток это 7.3 мс из бюджета в 100 мс, а 8 000 — 2.8 мс.
Платить за ёмкость имеет смысл, только если она что-то даёт;
* **важность признаков перестановкой** — если наверх вылезли `lat`/`abs_lat`,
модель выучила «что у оси, то предмет». В тоннеле у оси полно штатных
конструкций, и именно они дают нам ложные тревоги, так что такая модель
сделала бы хуже, показывая красивые цифры;
* **контрольный градиентный бустинг** по сырым признакам — сколько качества
стоит сам разрежённый код.
python tools/train_mbon.py --device cuda --baseline
"""
from __future__ import annotations
import argparse
import time
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.mbon_readout import MbonConfig, MbonReadout
BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230))
def auc(score: np.ndarray, y: np.ndarray) -> float:
pos, neg = score[y == 1], score[y == 0]
if pos.size == 0 or neg.size == 0:
return float("nan")
order = np.argsort(np.concatenate([pos, neg]))
ranks = np.empty(order.size, np.float64)
ranks[order] = np.arange(1, order.size + 1)
return float((ranks[:pos.size].sum() - pos.size * (pos.size + 1) / 2)
/ (pos.size * neg.size))
def fpr_at_tpr(score: np.ndarray, y: np.ndarray, tpr: float = 0.95) -> float:
"""Доля обстановки, проходящей порог, при котором ловится `tpr` предметов."""
pos, neg = score[y == 1], score[y == 0]
if pos.size == 0 or neg.size == 0:
return float("nan")
thr = np.quantile(pos, 1.0 - tpr)
return float((neg >= thr).mean())
def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов."""
out = []
for held in names:
tr = [n for n in names if n != held]
Xtr = np.concatenate([X[n] for n in tr])
ytr = np.concatenate([Y[n] for n in tr])
m = MbonReadout(cfg, n_pn=n_pn)
m.fit_normalizer(Xtr)
m.learn(Xtr, ytr, epochs=args.epochs, lr=args.lr, l2=args.l2,
device=args.device)
s = m.score(X[held])
out.append((held, s, m if keep_models else None, Xtr, ytr))
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data", default=str(B.CACHE / "training_set.npz"))
ap.add_argument("--out", default=str(B.ARTIFACTS / "mbon_readout.npz"))
ap.add_argument("--n-kc", type=int, default=0,
help="ёмкость итоговой модели; 0 — взять лучшую из развёртки")
ap.add_argument("--sweep-kc", default="4000,8000,20000",
help="ёмкости для развёртки; пусто — не разворачивать")
ap.add_argument("--active", type=int, default=100,
help="активных клеток после торможения APL")
ap.add_argument("--epochs", type=int, default=60)
ap.add_argument("--lr", type=float, default=4.0)
ap.add_argument("--l2", type=float, default=1e-5)
ap.add_argument("--device", default="cpu")
ap.add_argument("--baseline", action="store_true",
help="сравнить с градиентным бустингом по сырым признакам")
ap.add_argument("--save-folds", default=str(B.ARTIFACTS / "mbon_folds"),
help="куда сложить модели по складкам: без них сквозная "
"оценка нечестна — считывание увидит проверяемый бэг")
args = ap.parse_args()
d = np.load(args.data, allow_pickle=True)
names = [str(n) for n in d["names"]]
X = {n: d[f"X_{n}"].astype(np.float32) for n in names}
Y = {n: d[f"y_{n}"].astype(np.int8) for n in names}
D = {n: d[f"d_{n}"].astype(np.float32) for n in names}
feats = [str(f) for f in d["features"]]
n_pn = next(iter(X.values())).shape[1]
tot = sum(v.shape[0] for v in X.values())
pos = sum(int(v.sum()) for v in Y.values())
print(f"выборка: {tot} кандидатов, предметов {pos} ({pos / tot:.1%}), "
f"признаков {n_pn}")
for n in names:
print(f" {n:<42}{X[n].shape[0]:7d} предметов {int(Y[n].sum()):6d} "
f"({Y[n].mean():5.1%})")
# ------------------------------------------------------ развёртка по ёмкости
best_kc = args.n_kc
if args.sweep_kc.strip():
print("\nразвёртка по ёмкости (leave-one-bag-out):")
print(f"{'клеток':>8}{'активных':>10}{'AUC':>9}"
f"{'обстановки при 95% предметов':>31}")
scores = {}
for kc in (int(x) for x in args.sweep_kc.split(",")):
cfg = MbonConfig(n_kc=kc, sparsity=min(args.active / kc, 1.0))
res = loo(names, X, Y, cfg, n_pn, args)
a = float(np.nanmean([auc(s, Y[h]) for h, s, *_ in res]))
f = float(np.nanmean([fpr_at_tpr(s, Y[h], 0.95) for h, s, *_ in res]))
scores[kc] = a
print(f"{kc:8d}{min(args.active, kc):10d}{a:9.4f}{f:30.2%}", flush=True)
if not best_kc:
# берём наименьшую ёмкость, отстающую от лучшей не более чем на
# 0.002 AUC: дальше платим временем кадра за шум
top = max(scores.values())
best_kc = min(k for k, v in scores.items() if v >= top - 0.002)
print(f"выбрана ёмкость {best_kc} (лучшая AUC {top:.4f})")
if not best_kc:
best_kc = MbonConfig().n_kc
# ------------------------------------------------------------- подробно
cfg = MbonConfig(n_kc=best_kc, sparsity=min(args.active / best_kc, 1.0))
print(f"\nподробно при {best_kc} клетках:")
res = loo(names, X, Y, cfg, n_pn, args, keep_models=True)
for held, s, m, Xtr, ytr in res:
line = (f"{held:<42} AUC {auc(s, Y[held]):.4f} "
f"обстановки при 95% предметов {fpr_at_tpr(s, Y[held], 0.95):6.2%}")
if args.baseline:
import lightgbm as lgb
g = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05,
num_leaves=63, verbose=-1)
g.fit(Xtr, ytr)
line += f" | бустинг AUC {auc(g.predict_proba(X[held])[:, 1], Y[held]):.4f}"
print(line, flush=True)
if args.save_folds:
# По одной модели на складку. `evaluate.py --mbon-dir` берёт ту, что
# НЕ видела проверяемый бэг: иначе сквозные цифры ложные, ровно как
# было бы с памятью тоннеля, обученной на всём подряд.
from pathlib import Path
out_dir = Path(args.save_folds)
out_dir.mkdir(parents=True, exist_ok=True)
for held, _, mm, *_ in res:
mm.save(out_dir / f"mbon_{held}.npz")
print(f"модели по складкам сохранены: {out_dir} ({len(res)} шт.)")
print(f"\nсредний AUC: {np.nanmean([auc(s, Y[h]) for h, s, *_ in res]):.4f}")
print("AUC по полосам дальности:")
print(" " + "".join(f"{a}-{b} м".rjust(12) for a, b in BANDS))
line = " "
for lo, hi in BANDS:
ss = np.concatenate([s[(D[h] >= lo) & (D[h] < hi)] for h, s, *_ in res])
yy = np.concatenate([Y[h][(D[h] >= lo) & (D[h] < hi)] for h, s, *_ in res])
line += f"{auc(ss, yy):12.3f}" if (yy == 1).sum() >= 20 else " -"
print(line)
print("\nважность признаков (падение AUC при перестановке):")
rng = np.random.default_rng(0)
drops = []
for held, s, m, *_ in res:
base = auc(s, Y[held])
row = []
for j in range(n_pn):
Xp = X[held].copy()
Xp[:, j] = rng.permutation(Xp[:, j])
row.append(base - auc(m.score(Xp), Y[held]))
drops.append(row)
imp = np.mean(drops, axis=0)
for j in np.argsort(-imp)[:10]:
print(f" {feats[j]:<14}{imp[j]:+.4f}")
lat_j = [j for j, f in enumerate(feats) if f in ("lat", "abs_lat")]
if lat_j and max(imp[j] for j in lat_j) >= sorted(imp)[-3]:
print(" ВНИМАНИЕ: положение в сечении среди главных признаков — "
"модель могла выучить «что у оси, то предмет»")
# -------------------------------------------------------------- итоговая
Xall = np.concatenate([X[n] for n in names])
yall = np.concatenate([Y[n] for n in names])
m = MbonReadout(cfg, n_pn=n_pn)
m.fit_normalizer(Xall)
t0 = time.time()
m.learn(Xall, yall, epochs=args.epochs, lr=args.lr, l2=args.l2,
device=args.device, verbose=True)
m.save(args.out)
print(f"\nитоговая модель: {best_kc} клеток, {m.n_active} активных, "
f"обучена на {Xall.shape[0]} примерах за {time.time() - t0:.1f} с")
print("сохранено:", args.out)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,129 @@
"""Обучение памяти тоннеля — грибовидного тела.
Учится **без единой метки**: конвейер прогоняется по проездам пустого тоннеля,
все выданные геометрией кандидаты объявляются «знакомой обстановкой», и синапсы
KC→MBON на них депрессируются. После этого лотки, ниши, гермозатворы, кромки
платформ и стрелочные приводы перестают быть новостью, а незнакомая форма — нет.
python tools/train_mushroom_body.py --out artifacts/mushroom_body.npz
python tools/train_mushroom_body.py --exclude roundT_doubleT --device cuda
Датасет кандидатов кэшируется, поэтому подбор параметров памяти не требует
повторного прогона конвейера по 100 ГБ данных.
"""
from __future__ import annotations
import argparse
import time
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody, MushroomBodyConfig, describe
from flyguard.pipeline import FlyGuard, Params
# бэг с реальным препятствием в обучение не идёт: память обязана считать его новым
HOLDOUT = {"doubleT_obstacle"}
def collect(bag_path, params: Params, limit: int | None, stride: int):
bag = Bag(bag_path)
fg = FlyGuard(params, memory=None)
rows, meta = [], []
for k, (_, pc) in enumerate(bag.frames(stop=limit, stride=stride)):
res = fg.process(pc)
if res is None:
continue
for c in res.candidates:
rows.append(describe(c))
meta.append((c.d, c.u, c.h, c.n_rays))
return rows, meta
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--extra", action="append", default=[],
help="дополнительные бэги (например, data/new_data)")
ap.add_argument("--out", default=str(B.ARTIFACTS / "mushroom_body.npz"))
ap.add_argument("--cache", default=str(B.CACHE / "candidates.npz"))
ap.add_argument("--exclude", action="append", default=[])
ap.add_argument("--extra-cache", action="append", default=[],
help="готовые наборы дескрипторов (npz с массивом X), "
"например data/cache/new_data_candidates.npz")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--stride", type=int, default=1)
_d = MushroomBodyConfig() # умолчания берутся из самой модели
ap.add_argument("--rate", type=float, default=0.0,
help="темп депрессии; 0 — подобрать по размеру выборки")
ap.add_argument("--target", type=float, default=0.4,
help="во сколько e-раз ослабляется типичная клетка Кеньона; "
"0.4 даёт лучшее разделение (tools/tune_memory.py)")
ap.add_argument("--n-kc", type=int, default=_d.n_kc)
ap.add_argument("--claws", type=int, default=_d.claws)
ap.add_argument("--sparsity", type=float, default=_d.sparsity)
ap.add_argument("--device", default="cpu")
ap.add_argument("--reuse-cache", action="store_true")
args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
B.CACHE.mkdir(parents=True, exist_ok=True)
params = Params()
skip = HOLDOUT | set(args.exclude)
bags = [b for b in find_bags(args.root) if b.name not in skip]
bags += [__import__("pathlib").Path(p) for p in args.extra]
if args.reuse_cache and __import__("pathlib").Path(args.cache).exists():
d = np.load(args.cache, allow_pickle=True)
X = d["X"]
names = list(d["names"])
print(f"кэш: {X.shape[0]} кандидатов из {len(names)} бэгов")
else:
all_rows, names, per_bag = [], [], []
for b in bags:
t0 = time.time()
rows, _ = collect(b, params, args.limit, args.stride)
all_rows.extend(rows)
names.append(b.name)
per_bag.append(len(rows))
print(f" {b.name:42s} кандидатов {len(rows):7d} за {time.time()-t0:6.1f} с")
if not all_rows:
raise SystemExit("кандидатов не собрано — нечему учиться")
X = np.stack(all_rows).astype(np.float32)
np.savez_compressed(args.cache, X=X, names=np.array(names),
per_bag=np.array(per_bag))
print(f"кэш сохранён: {args.cache}")
for path in args.extra_cache:
d = np.load(path, allow_pickle=True)
extra = d["X"].astype(np.float32)
if extra.shape[1] != X.shape[1]:
raise SystemExit(f"{path}: {extra.shape[1]} признаков вместо {X.shape[1]} — "
"набор собран другой версией дескриптора, пересоберите")
print(f" + {path}: {extra.shape[0]} кандидатов")
X = np.concatenate([X, extra])
print(f"обучающая выборка: {X.shape[0]} кандидатов, {X.shape[1]} признаков")
cfg = MushroomBodyConfig(n_kc=args.n_kc, claws=args.claws, sparsity=args.sparsity)
mb = MushroomBody(cfg)
mb.fit_normalizer(X)
rate = args.rate if args.rate > 0 else mb.auto_rate(X.shape[0], args.target)
t0 = time.time()
mb.learn(X, rate=rate, device=args.device)
print(f"обучено на {X.shape[0]} примерах за {time.time()-t0:.2f} с "
f"(устройство {args.device}, темп депрессии {rate:.4f})")
nov = mb.novelty(X)
frac = (mb.w_mbon < 0.5).mean()
print(f"клеток Кеньона с подавленным синапсом: {frac:.1%}")
print("новизна обучающей выборки: "
+ " ".join(f"p{q}={np.percentile(nov, q):.3f}" for q in (5, 25, 50, 75, 95)))
mb.save(args.out)
print("сохранено:", args.out)
if __name__ == "__main__":
main()

125
tools/tune_memory.py Normal file
View file

@ -0,0 +1,125 @@
"""Подбор ёмкости грибовидного тела по разделяющей способности.
Память с малым числом клеток Кеньона насыщается: после нескольких тысяч примеров
подавлены все синапсы, и новым не выглядит уже ничто — включая настоящее
препятствие. Скрипт меряет, при каких параметрах память **различает**:
* отрицательные примеры — кандидаты отложенного пустого бэга (должны стать знакомы);
* положительные — реальный объект на ~55 м из `doubleT_obstacle` (должен остаться новым).
Считается ROC AUC по новизне. Заодно печатается доля подавленных синапсов —
прямой индикатор насыщения.
python tools/tune_memory.py --device cuda
"""
from __future__ import annotations
import argparse
import itertools
import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import FEATURES, MushroomBody, MushroomBodyConfig, describe
from flyguard.pipeline import FlyGuard, Params
OBSTACLE_BAG = "doubleT_obstacle"
TRUE_D = (50.0, 62.0)
def collect_bag(path, limit=None, stride=1, params=None):
fg = FlyGuard(params or Params(), memory=None)
rows, dists = [], []
for _, pc in Bag(path).frames(stop=limit, stride=stride):
res = fg.process(pc)
if res is None:
continue
for c in res.candidates:
rows.append(describe(c))
dists.append(c.d)
X = np.stack(rows).astype(np.float32) if rows else np.zeros((0, len(FEATURES)), np.float32)
return X, np.asarray(dists, np.float32)
def auc(pos: np.ndarray, neg: np.ndarray) -> float:
if pos.size == 0 or neg.size == 0:
return float("nan")
order = np.argsort(np.concatenate([pos, neg]))
ranks = np.empty(order.size, np.float64)
ranks[order] = np.arange(1, order.size + 1)
r_pos = ranks[:pos.size].sum()
return float((r_pos - pos.size * (pos.size + 1) / 2) / (pos.size * neg.size))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--cache", default=str(B.CACHE / "tune_candidates.npz"))
ap.add_argument("--device", default="cpu")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--stride", type=int, default=1)
ap.add_argument("--split-near", type=float, default=None,
help="переопределить порог ближней зоны разреза")
ap.add_argument("--collect-only", action="store_true",
help="только пересобрать кэш дескрипторов и выйти")
args = ap.parse_args()
B.CACHE.mkdir(parents=True, exist_ok=True)
from pathlib import Path
cache = Path(args.cache)
if cache.exists():
d = np.load(cache, allow_pickle=True)
bags = {str(k): d[f"X_{k}"] for k in d["names"]}
obj_d = d["obj_d"]
else:
bags, obj_d = {}, None
for p in find_bags(args.root):
over = ({} if args.split_near is None
else {'split_near': args.split_near})
X, dd = collect_bag(p, args.limit, args.stride, Params(**over))
bags[p.name] = X
if p.name == OBSTACLE_BAG:
obj_d = dd
print(f" {p.name:42s} {X.shape[0]:7d} кандидатов")
np.savez_compressed(cache, names=np.array(list(bags)), obj_d=obj_d,
**{f"X_{k}": v for k, v in bags.items()})
print("кэш сохранён:", cache)
if args.collect_only:
return
X_obs = bags[OBSTACLE_BAG]
in_band = (obj_d > TRUE_D[0]) & (obj_d < TRUE_D[1])
X_pos = X_obs[in_band]
empty_names = [k for k in bags if k != OBSTACLE_BAG]
print(f"\nположительных (объект ~55 м): {X_pos.shape[0]}, "
f"пустых бэгов: {len(empty_names)}")
grid = itertools.product([2_000, 20_000, 100_000], [0.05, 0.01, 0.002], [0.1, 0.3, 0.7])
print(f"\n{'n_kc':>8} {'разреж.':>8} {'rate':>6} | {'AUC':>6} | "
f"{'нов.объект':>10} {'нов.фон':>8} | {'подавл.':>8}")
print("-" * 72)
best = None
for n_kc, sp, rate in grid:
aucs, novp, novn, sat = [], [], [], []
for held in empty_names:
train = np.concatenate([bags[k] for k in empty_names if k != held])
mb = MushroomBody(MushroomBodyConfig(n_kc=n_kc, sparsity=sp))
mb.fit_normalizer(train)
mb.learn(train, rate=rate, device=args.device)
p = mb.novelty(X_pos)
n = mb.novelty(bags[held])
aucs.append(auc(p, n)); novp.append(np.median(p)); novn.append(np.median(n))
sat.append(float((mb.w_mbon < 0.5).mean()))
a = float(np.mean(aucs))
print(f"{n_kc:8d} {sp:8.3f} {rate:6.2f} | {a:6.3f} | "
f"{np.mean(novp):10.3f} {np.mean(novn):8.3f} | {np.mean(sat):8.1%}")
if best is None or a > best[0]:
best = (a, n_kc, sp, rate)
print(f"\nлучшее: AUC={best[0]:.3f} при n_kc={best[1]}, разрежённость={best[2]}, rate={best[3]}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,93 @@
"""Сверка калибровки решётки по данным с паспортной таблицей каналов.
Решение нигде не использует паспортные углы: решётка восстанавливается из самих
облаков точек. Но раз таблица из руководства есть, ею стоит воспользоваться как
**независимой проверкой** — совпадение показывает, что выпрямление образа и
оценка углов сделаны правильно, а не подогнаны.
python tools/validate_calibration.py --bag data/for_hackathon/roundT_doubleT
"""
from __future__ import annotations
import argparse
import csv
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402
from flyguard.bag import Bag, find_bags # noqa: E402
from flyguard.retina import ScanLayout # noqa: E402
TABLE = B.PKG / "flyguard" / "data" / "pandar128_channels.csv"
def load_table() -> dict[str, np.ndarray]:
rows = list(csv.DictReader(open(TABLE, encoding="utf-8")))
rows.sort(key=lambda r: int(r["channel"]))
return {
"elevation": np.array([float(r["elevation_deg"]) for r in rows]),
"az_offset": np.array([float(r["az_offset_deg"]) for r in rows]),
"max_range": np.array([float(r["max_range_10pct_m"]) for r in rows]),
"far_field": np.array([int(r["far_field"]) for r in rows], bool),
"high_res": np.array([int(r["high_res"]) for r in rows], bool),
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag", action="append")
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
args = ap.parse_args()
tab = load_table()
from pathlib import Path
bags = [Path(b) for b in args.bag] if args.bag else find_bags(args.root)
fig, axes = plt.subplots(1, 3, figsize=(16, 4.6), dpi=110)
print(f"{'бэг':42s} {'ошибка элевации, °':>22s} {'ошибка сдвига, °':>20s}")
print("-" * 88)
for p in bags:
bag = Bag(p)
layout = ScanLayout.calibrate([pc for _, pc in bag.frames(start=2, stop=14)])
# сдвиг канала в градусах = целочисленное выпрямление плюс остаток
meas_off = layout.col_shift * layout.az_step_deg + layout.az_resid_deg
meas_off = meas_off - np.median(meas_off) + np.median(tab["az_offset"])
de = layout.el_deg - tab["elevation"]
da = meas_off - tab["az_offset"]
print(f"{p.name:42s} медиана {np.median(np.abs(de)):7.4f} макс {np.abs(de).max():7.4f}"
f" медиана {np.median(np.abs(da)):6.4f} макс {np.abs(da).max():6.4f}")
axes[0].plot(np.arange(128), de, lw=0.9, alpha=0.8, label=p.name[:18])
axes[1].plot(np.arange(128), da, lw=0.9, alpha=0.8)
axes[0].set_title("элевация: измерено − паспорт")
axes[0].set_xlabel("канал"); axes[0].set_ylabel("°")
axes[0].legend(fontsize=6)
axes[1].set_title("азимутальный сдвиг канала: измерено − паспорт")
axes[1].set_xlabel("канал"); axes[1].set_ylabel("°")
for ax in axes[:2]:
ax.axhline(0, color="k", lw=0.6)
ax.grid(alpha=0.2)
ax = axes[2]
ax.plot(np.arange(128), tab["max_range"], lw=1.2, color="tab:blue")
ff = np.flatnonzero(tab["far_field"])
ax.fill_between(ff, 0, 210, color="tab:orange", alpha=0.18,
label=f"дальнобойные каналы {ff.min()+1}–{ff.max()+1}")
hr = np.flatnonzero(tab["high_res"])
ax.fill_between(hr, 0, 210, color="tab:green", alpha=0.10,
label=f"высокое разрешение {hr.min()+1}–{hr.max()+1}")
ax.set_title("паспортная дальность канала при 10 % отражения")
ax.set_xlabel("канал"); ax.set_ylabel("м")
ax.legend(fontsize=7); ax.grid(alpha=0.2)
B.FIGURES.mkdir(parents=True, exist_ok=True)
out = B.FIGURES / "calibration_vs_datasheet.png"
fig.tight_layout(); fig.savefig(out)
print("\nсохранено:", out)
if __name__ == "__main__":
main()