Compare commits
No commits in common. "0a244fa20b02453edf1e255fc0885a7bd2fbf968" and "623cbb384392a3b2af28426bf9ce65562a668413" have entirely different histories.
0a244fa20b
...
623cbb3843
44 changed files with 7989 additions and 208 deletions
212
.gitignore
vendored
212
.gitignore
vendored
|
|
@ -1,210 +1,8 @@
|
|||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# ---> ROS2
|
||||
install/
|
||||
log/
|
||||
build/
|
||||
|
||||
# Ignore generated docs
|
||||
*.dox
|
||||
*.wikidoc
|
||||
|
||||
# eclipse stuff
|
||||
.project
|
||||
.cproject
|
||||
|
||||
# qcreator stuff
|
||||
CMakeLists.txt.user
|
||||
|
||||
srv/_*.py
|
||||
*.pcd
|
||||
*.pyc
|
||||
qtcreator-*
|
||||
*.user
|
||||
|
||||
*~
|
||||
|
||||
# Emacs
|
||||
.#*
|
||||
|
||||
# Colcon custom files
|
||||
COLCON_IGNORE
|
||||
AMENT_IGNORE
|
||||
|
||||
# ---> Linux
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
data/
|
||||
artifacts/mbon_folds/
|
||||
docs/figures/*.png
|
||||
docs/figures/*.mp4
|
||||
|
|
|
|||
160
README.md
160
README.md
|
|
@ -1,2 +1,160 @@
|
|||
# Brainrot_Muxa
|
||||
# FlyGuard · ML-ядро
|
||||
|
||||
Обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара
|
||||
Hesai Pandar128. Кейс 05, ЛЦТ-2026.
|
||||
|
||||
Это **ядро обработки**: облако точек на входе, решение о препятствии на выходе.
|
||||
Узел ROS 2, транспорт, контейнер и визуализация живут отдельно и сюда не
|
||||
входят — ядро от них не зависит и проверяется без ROS вообще.
|
||||
|
||||
---
|
||||
|
||||
## Чем это не является
|
||||
|
||||
Не нейросетевой детектор общего назначения. Конвейер собран по схемам
|
||||
зрительной системы дрозофилы, и каждая стадия — это конкретный нейропиль с
|
||||
конкретной функцией, а не слой, подобранный перебором:
|
||||
|
||||
```
|
||||
облако точек 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 м) — ядро работает с обеими без правок.
|
||||
|
||||
---
|
||||
|
||||
## Что нужно интеграции: один класс, один вызов
|
||||
|
||||
```python
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
from flyguard.mushroom_body import MushroomBody
|
||||
from flyguard.mbon_readout import MbonReadout
|
||||
|
||||
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 мс.
|
||||
|
||||
---
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
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/ обученные модели
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Как запустить
|
||||
|
||||
```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 # дальность обнаружения
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Где мы сейчас
|
||||
|
||||
| Метрика | Значение | Чем измерено |
|
||||
|---|---|---|
|
||||
| Реальный объект 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