ML-ядро: конвейер обнаружения на схемах мозга дрозофилы
Ретина, ламина, медулла, лобула, грибовидное тело, веерное тело, центральный комплекс, нисходящие нейроны. Обучение памяти тоннеля и считывания MBON, оценка leave-one-bag-out, полигон дальности, 24 теста. Реальный объект на 55 м — 98.9 % кадров, ложных 7.5 трека на км, кадр обрабатывается за 33 мс на CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5ad311f78a
commit
623cbb3843
44 changed files with 7970 additions and 61 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
data/
|
||||
artifacts/mbon_folds/
|
||||
docs/figures/*.png
|
||||
docs/figures/*.mp4
|
||||
198
README.md
198
README.md
|
|
@ -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
1
artifacts/benchmark.json
Normal file
File diff suppressed because one or more lines are too long
92
artifacts/generalisation.json
Normal file
92
artifacts/generalisation.json
Normal 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
BIN
artifacts/mushroom_body.npz
Normal file
Binary file not shown.
303
docs/ALGORITHM.md
Normal file
303
docs/ALGORITHM.md
Normal 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
84
docs/CASE.md
Normal 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 октября | офлайн в Москве |
|
||||
205
docs/CONNECTOME.md
Normal file
205
docs/CONNECTOME.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Что именно взято из коннектома
|
||||
|
||||
Документ отвечает на два вопроса: какие параметры решения пришли из данных о мозге
|
||||
дрозофилы, и почему взяты **схемы**, а не полная симуляция.
|
||||
|
||||
---
|
||||
|
||||
## 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 м) — механизм
|
||||
выключен, но параметры остались.
|
||||
|
||||
---
|
||||
|
||||
## 3.5. Атлас: те же клетки, видимые глазом
|
||||
|
||||
Проверить, что архитектура действительно собрана из заявленных схем, можно не на
|
||||
словах. `tools/build_brain_atlas.py` берёт публичные выгрузки Codex (FAFB v783) и
|
||||
сводит их в атлас: положение сомы каждого нейрона и номер стадии FlyGuard, которой
|
||||
он соответствует. Привязка идёт по **имени типа клетки**, а не по догадке, и все
|
||||
типы находятся поимённо:
|
||||
|
||||
| Тип | В коннектоме | Где используется |
|
||||
|---|---|---|
|
||||
| LC11 | 127 | `lobula.py` — кандидаты как компактные группы лучей |
|
||||
| LPLC2 | 210 | `medulla.py` — надвигание как дивергенция потока |
|
||||
| HS (HSE/HSN/HSS) | 6 | `medulla.py` — широкопольный поток |
|
||||
| VS1…VS8 | 16 | там же, вертикальный канал |
|
||||
| T4a–d / T5a–d | 6243 / 6002 | `medulla.py` — коррелятор Хассенштайна–Райхардта |
|
||||
| L1 / L2 | 3503 | `lamina.py` — каналы ON и OFF |
|
||||
| Клетки Кеньона | 5177 | `mushroom_body.py` — разрежённый код |
|
||||
| MBON | 96 | там же, выход памяти |
|
||||
| APL | 2 | там же, глобальное торможение |
|
||||
| EPG | 47 | `central_complex.py` — кольцевой аттрактор |
|
||||
| DNp01 | 2 | `descending.py` — гигантское волокно |
|
||||
|
||||
Совпадение с литературой полное: LC11 около 110–130 клеток, HS по три на сторону,
|
||||
VS по восемь, APL и гигантское волокно — по одной на полушарие.
|
||||
|
||||
Вид `brain_style: cloud` рисует эти 139 255 нейронов на их анатомических местах и
|
||||
подсвечивает активностью соответствующей стадии. Симуляции по-прежнему нет:
|
||||
коннектом даёт анатомию, конвейер даёт активность, вид накладывает одно на другое
|
||||
за 8 мс на кадр.
|
||||
|
||||
## 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
970
docs/EXPERIMENTS.md
Normal 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
16
flyguard/__init__.py
Normal 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
133
flyguard/bag.py
Normal 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
130
flyguard/cdr.py
Normal 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
236
flyguard/central_complex.py
Normal 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
111
flyguard/descending.py
Normal 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
180
flyguard/fan_body.py
Normal 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
324
flyguard/geometry.py
Normal 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
118
flyguard/lamina.py
Normal 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
346
flyguard/lobula.py
Normal 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
236
flyguard/mbon_readout.py
Normal 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
398
flyguard/medulla.py
Normal 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
482
flyguard/mushroom_body.py
Normal 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
328
flyguard/pipeline.py
Normal 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
351
flyguard/retina.py
Normal 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
315
flyguard/synth.py
Normal 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
7
requirements.txt
Normal 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
326
tests/test_pipeline.py
Normal 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
27
tools/_bootstrap.py
Normal 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
108
tools/ablation.py
Normal 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
91
tools/analyze_corridor.py
Normal 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
111
tools/analyze_gauge.py
Normal 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
81
tools/analyze_geometry.py
Normal 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
65
tools/check_obstacle.py
Normal 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
108
tools/diagnose_fp.py
Normal 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
183
tools/evaluate.py
Normal 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()
|
||||
118
tools/extract_channel_table.py
Normal file
118
tools/extract_channel_table.py
Normal 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
114
tools/inspect_bags.py
Normal 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
143
tools/make_benchmark.py
Normal 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
176
tools/make_training_set.py
Normal 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
131
tools/plot_benchmark.py
Normal 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
128
tools/probe_bag.py
Normal 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}")
|
||||
202
tools/train_mbon.py
Normal file
202
tools/train_mbon.py
Normal 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()
|
||||
129
tools/train_mushroom_body.py
Normal file
129
tools/train_mushroom_body.py
Normal 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
125
tools/tune_memory.py
Normal 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()
|
||||
93
tools/validate_calibration.py
Normal file
93
tools/validate_calibration.py
Normal 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()
|
||||
Loading…
Reference in a new issue