Compare commits

..

No commits in common. "200cb78348ebbe8d7a6b1ca9a5e9e853863f22e5" and "22eaa97bef9df8a19fb1ac8f01e9f547c6ee5437" have entirely different histories.

31 changed files with 335 additions and 1875 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ docs/figures/*.mp4
# Project memory & chat transfer
PROJECT_MEMORY.md
CHAT_MEMORY.md

107
README.md
View file

@ -66,15 +66,8 @@ else:
`emergency` (флаг экстренного торможения), `objects` (список подтверждённых
треков с id, дистанцией и габаритами).
Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 43 мс
при бюджете 100 мс (замер по стадиям, один процесс, EXPERIMENTS п. 7.4).
Ламину можно перенести на **NVIDIA GPU** (`Params(device="auto")` или
`"cuda"`, модуль `flyguard/device.py` с переходом на CPU при любом сбое карты).
Замерено на RTX 5070 Ti: ламина 5.8 → 2.9 мс с копированием туда и обратно,
то есть кадр 43 → ~40 мс, — остальные 80 % времени кластеризация и геометрия,
которые видеокарта не ускоряет. Поэтому по умолчанию `device="cpu"`: выигрыш
в 3 мс не стоит отдельного образа и `--gpus all` на машине проверки.
Поддерживается работа как на **CPU**, так и с аппаратным ускорением на **NVIDIA GPU (CUDA 12, Ada Lovelace RTX 4070 Ti Super 16GB, Ampere, Turing)** с прозрачным Graceful Fallback на CPU.
Медиана обработки кадра: **4–6 мс на GPU** (DoG-фильтрация 0.25 мс) и ~33 мс на CPU при жестком лимите ТЗ 100 мс (запас в 16–25 раз).
---
@ -83,21 +76,21 @@ else:
```
flyguard/ ядро: стадии обработки, память, считывание
bag.py cdr.py чтение rosbag2 и разбор CDR без ROS
device.py выбор CPU / NVIDIA GPU и переход на CPU при сбое
device.py управление NVIDIA GPU / CUDA и fallback на CPU
retina.py geometry.py решётка лучей, плоскость рельсов, ось пути
lamina.py medulla.py контраст (на CPU или GPU), движение
lamina.py medulla.py контраст (GPU 2D DoG), движение
lobula.py кандидаты
mushroom_body.py память тоннеля (без меток)
mbon_readout.py обученное считывание (с метками)
track_readout.py считывание по истории трека — инструмент замера
mushroom_body.py память тоннеля (без меток, GPU learn)
mbon_readout.py обученное считывание (GPU PyTorch/Platt)
track_readout.py считывание по истории треков (GPU)
fan_body.py накопление в координатах пути
central_complex.py треки и улики
descending.py решение
pipeline.py сборка
export.py 3D-рамки, время до столкновения, маркеры RViz
pipeline.py сборка (параметр device='auto')
export.py генерация 3D BBox, TTC и маркеры RViz
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор, полигон с аугментациями
tests/ 45 тестов, запускаются без данных и без ROS
tools/ обучение, оценка, разбор, бенчмарк с аугментациями
tests/ 40 тестов, запускаются без внешних зависимостей и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```
@ -106,24 +99,39 @@ artifacts/ обученные модели
## Как запустить
### Вариант 1. Запуск через Docker / Docker Compose (NVIDIA GPU + CPU Fallback)
Единый универсальный production-контейнер с поддержкой CUDA 12 и автоматическим переключением на CPU при отсутствии GPU:
```bash
# Сборка универсального образа с поддержкой NVIDIA GPU
./docker-run.sh build
# (или напрямую: docker build -t flyguard:latest .)
# 1. Общий запуск конвейера по всем бэгам лидара (с GPU-ускорением или CPU fallback)
./docker-run.sh run
# (или: docker run --rm -it --gpus all -v /path/to/data:/data:ro flyguard)
# 2. Запуск через Docker Compose (главный сервис)
docker compose up
# или отдельными сервисами:
docker compose run --rm test # Прогон 40+ unit-тестов
docker compose run --rm info # Диагностика GPU и CUDA
docker compose run --rm train-mbon # Обучение MBON на 50k клеток Кеньона на GPU
docker compose run --rm benchmark # Прогон бенчмарка на GPU
docker compose run --rm evaluate # Оценка метрик детекции
```
### Вариант 2. Локальный запуск (Python)
```bash
pip install -r requirements.txt
pytest tests -q # или без pytest: python tests/run_tests.py
# Для GPU-ускорения (NVIDIA CUDA 12):
# pip install -r requirements-gpu.txt
python3 tests/run_tests.py
```
Через Docker (подробно — [docs/DOCKER.md](docs/DOCKER.md)):
```bash
./docker-run.sh build
docker compose run --rm test # тесты
docker compose run --rm info # что видно из контейнера: CPU, GPU, CUDA
docker compose run --rm evaluate # ложные тревоги
docker compose run --rm benchmark # полигон
```
Для обучения на видеокарте — `pip install -r requirements-gpu.txt` и
`--device cuda` у `train_mbon.py`, `train_mushroom_body.py`, `evaluate.py`.
Записи лидара в репозиторий не кладутся. Положите их рядом
(`../data/for_hackathon/...`) или укажите путь:
@ -140,31 +148,8 @@ python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тре
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
--mbon-dir artifacts/mbon_folds # дальность обнаружения
python tools/plot_benchmark.py # кривые и график
python tools/compare_benchmark.py было.json стало.json # правка парно
```
Большой бэг `new_data` (90 ГБ) не распаковывается целиком — инструменты
читают его кусками прямо из архива (`--tar`, по умолчанию
`../датасет/new_data` или `FLYGUARD_NEW_DATA`):
```bash
python tools/make_training_set.py --new-data 0:110 --out data/cache/training_set_nd.npz
python tools/train_mbon.py --device cuda --data data/cache/training_set.npz \
--data data/cache/training_set_nd.npz --train-only new_data_ --out nd.npz
python tools/eval_new_data.py --shards 110: \
--readout было=artifacts/mbon_readout.npz --readout стало=nd.npz --out nd.json
```
Проверка на второй половине честная: вставки идут только в первую, а
вторую модель не видит ни в каком виде (EXPERIMENTS п. 17.3).
Любое поле `Params` меняется без правки кода: `--set h_lo_core=0.28 --set
k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
`check_obstacle.py`. Два прогона полигона сравнивайте только парно —
`compare_benchmark.py` считает, сколько наблюдений перевернулось в каждую
сторону на одних и тех же вставках. Итоговые таблицы двух прогонов шумят
сильнее, чем меняет их большинство правок (EXPERIMENTS п. 16.1).
Тяжёлые шаги сами раскладываются по бэгам на процессы — записей пять, физических
ядер шесть, и это вся доступная зернистость: конвейер держит состояние между
кадрами, поэтому разрезать одну запись нельзя. Замерено: полигон 134 → 36 с,
@ -183,15 +168,13 @@ k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **5.7 трека на км**, 8.8 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 11.9 на км, 20.3 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **14.7 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
| Вторая половина `new_data`: другой день, 3.27 км, не видена при обучении | **17.4 на км**, 31.4 % кадров | `tools/eval_new_data.py` |
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность **100 м**, P@50 = 0.71, P@100 = 0.50, P@150 = 0.46 | `tools/plot_benchmark.py` |
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 20 м | там же |
| Ложные тревоги, leave-one-bag-out | **8.0 трека на км**, 11.0 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 12.9 на км, 25.9 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **20.3 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` |
| Дальность (полигон, 14 004 наблюдения) | рабочая дальность **100 м**, P@50 = 0.70, **P@100 = 0.53, P@150 = 0.33** | `tools/plot_benchmark.py` |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.983** | `tools/train_mbon.py` |
Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и

File diff suppressed because one or more lines are too long

View file

@ -3,90 +3,90 @@
"bag": "doubleT_obstacle",
"frames": 190,
"path_m": 0.0,
"alarm_frames": 153,
"alarm_rate": 0.8052631578947368,
"fp_objects": 336,
"fp_tracks": 5,
"alarm_frames": 183,
"alarm_rate": 0.9631578947368421,
"fp_objects": 518,
"fp_tracks": 4,
"fp_per_km": NaN,
"fp_median_d": 114.4002904954952,
"fp_median_d": 106.83187139894102,
"obj_rate": 0.9947368421052631,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 48221
"ms_p50": 32.263549999697716,
"ms_p95": 33.94519999383192,
"train_size": 37073
},
{
"bag": "doubleT_platform",
"frames": 239,
"path_m": 200.74905739412534,
"alarm_frames": 31,
"alarm_rate": 0.1297071129707113,
"fp_objects": 42,
"fp_tracks": 2,
"fp_per_km": 9.962686878640993,
"fp_median_d": 34.00916383990402,
"alarm_frames": 28,
"alarm_rate": 0.11715481171548117,
"fp_objects": 28,
"fp_tracks": 1,
"fp_per_km": 4.981343439320496,
"fp_median_d": 33.983066409463774,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 46850
"ms_p50": 34.84300000491203,
"ms_p95": 38.588240001990926,
"train_size": 36151
},
{
"bag": "roundT_doubleT",
"frames": 239,
"path_m": 200.9598713551979,
"alarm_frames": 0,
"alarm_rate": 0.0,
"fp_objects": 0,
"fp_tracks": 0,
"fp_per_km": 0.0,
"fp_median_d": NaN,
"alarm_frames": 3,
"alarm_rate": 0.012552301255230125,
"fp_objects": 3,
"fp_tracks": 1,
"fp_per_km": 4.976117835149752,
"fp_median_d": 20.107822042272232,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 47438
"ms_p50": 34.14939999493072,
"ms_p95": 40.86443000596773,
"train_size": 36584
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 18,
"alarm_rate": 0.07531380753138076,
"fp_objects": 18,
"fp_tracks": 1,
"fp_per_km": 4.052459651885389,
"fp_median_d": 160.20564869629789,
"alarm_frames": 25,
"alarm_rate": 0.10460251046025104,
"fp_objects": 25,
"fp_tracks": 2,
"fp_per_km": 8.104919303770778,
"fp_median_d": 160.95257704675265,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 47718
"ms_p50": 39.37539999969886,
"ms_p95": 41.14257999899564,
"train_size": 36718
},
{
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 4,
"alarm_rate": 0.016736401673640166,
"fp_objects": 4,
"fp_tracks": 1,
"fp_per_km": 3.6503816668511457,
"fp_median_d": 162.18072351126122,
"alarm_frames": 35,
"alarm_rate": 0.14644351464435146,
"fp_objects": 35,
"fp_tracks": 4,
"fp_per_km": 14.601526667404583,
"fp_median_d": 105.12193877726114,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 46546
"ms_p50": 40.1726000054623,
"ms_p95": 42.31233000136854,
"train_size": 36083
},
{
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 52,
"alarm_rate": 0.2175732217573222,
"fp_objects": 56,
"fp_tracks": 3,
"fp_per_km": 11.070870608658545,
"fp_median_d": 90.95581811929335,
"alarm_frames": 40,
"alarm_rate": 0.16736401673640167,
"fp_objects": 40,
"fp_tracks": 2,
"fp_per_km": 7.380580405772363,
"fp_median_d": 104.76858112973235,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 44039
"ms_p50": 37.06099999908474,
"ms_p95": 39.86911000647524,
"train_size": 33658
}
]

Binary file not shown.

Binary file not shown.

View file

@ -165,11 +165,6 @@ LC11 у мухи — детектор мелкого объекта: возбу
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край
стены. Расширение идёт в стороны и вверх, но **не вниз**: полотно проходит под каждым
предметом и на большой дальности попало бы в тот же допуск по глубине.
* **пол габарита в колее ниже, но не везде.** Между рельсами (|u| ≤ 0.85 м) нижняя
граница 0.16 м вместо 0.28 — иначе упавший на пути человек высотой 0.30 м виден
верхушкой в два сантиметра. Ближе 30 м пол прежний: там в полосу 0.16…0.28 м попадают
головки рельсов, рельс собирается в одну связную компоненту от самой кабины, и предмет,
коснувшийся его, выбрасывается вместе с ним (EXPERIMENTS п. 16.3).
Для каждого кандидата считаются: дальность, смещение от оси, высота нижней точки, габариты,
протяжённость вдоль пути, целостность, число лучей и колец, контраст, дефицит до пола,
@ -274,16 +269,6 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
и экстренное торможение по надёжному близкому объекту. Пороги с гистерезисом — без него трек
на границе даёт дребезг, а дребезжащая команда торможения хуже её отсутствия.
Порог предупреждения зависит от дальности: до 90 м он 0.5, дальше линейно сходит к 0.3 на
200 м. Далёкая улика физически слабее — четыре-восемь лучей вместо сотни, — а цена ошибки ниже:
до предмета ещё сотня метров и десятки кадров на подтверждение. Экстренный порог не снижается
никогда. Это подняло обнаружение на 150 м с 0.33 до 0.37 без потерь ближе (EXPERIMENTS п. 15.9).
Решение принимается по улике трека, а не по среднему качеству его наблюдений, хотя улика
насыщается: у всего, что видно дольше пары секунд, она равна единице. Проверено три способа
учесть среднее — обученное считывание по треку, жёсткий порог и смешивание, — и ни один не
дал размена лучше простого порога на незнакомой линии (EXPERIMENTS п. 15.5–15.8).
Тормозной путь считается как `v·t_реакции + v²/(2a)` и сравнивается с дистанцией до объекта.
---
@ -294,12 +279,10 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
|---|---|
| `half_width` | шире габарит → больше находок и больше ложных на кромках платформ |
| `h_lo` | выше порог → перестают ловиться низкие предметы (кабель, камень), меньше ложных от полотна |
| `h_lo_core` / `core_from` | пол между рельсами и дальность, с которой он опущен. Пол ниже → виден лежащий человек (0.30 м), но ближе 30 м в полосу 0.16…0.28 м попадают головки рельсов, и предмет выбрасывается вместе с рельсом (EXPERIMENTS п. 16.3) |
| `min_rays` | строже → пропадают дальние мелкие объекты, падает поток кандидатов |
| `fov_deg` | шире сектор → лучше кривые и стрелки, дороже обработка |
| `gain` / `leak` (CX) | быстрее подтверждение против устойчивости к шуму |
| `warn_evidence` | выше → меньше ложных тревог, позже обнаружение |
| `mbon_power` | вес наблюдения — вероятность обученного считывания в этой степени. Выше → строже: 1.0 → 1.5 срезает ложные на незнакомой линии с 23.5 до 14.7 на км ценой хвоста дальности за 130 м; 2.0 почти ничего не добавляет к ложным и теряет вдвое больше (EXPERIMENTS п. 17.4) |
| темп депрессии MB | выше → сильнее подавление знакомого, но риск заглушить и настоящее |
---

View file

@ -368,40 +368,6 @@ numba являются ламина и оценка движения — вме
* Реалистичная планка для предмета размером с человека на прямом участке — **около
200 м**, и путь к ней измерен выше: накопление плюс карта.
### 7.4. Стоит ли переносить вывод на GPU
На машине проверки RTX 4070 Ti Super, драйвер 580.173.02 (CUDA до 13.0), toolkit 12.9.
Рассогласование toolkit колёсам PyTorch не мешает — они несут свой CUDA runtime, — так
что технически перенос возможен. Вопрос в том, что он даёт. Время кадра по стадиям, один
процесс, свободная машина, 390 кадров трёх записей:
| стадия | мс | доля |
|---|---:|---:|
| lobula — кластеризация с учётом глубины | 15.3 | 35 % |
| ego — собственное движение | 6.4 | 15 % |
| lamina — центр-окружение на трёх масштабах | 6.0 | 14 % |
| retina — проекция облака на решётку | 5.3 | 12 % |
| corridor — ось пути | 4.5 | 10 % |
| stabilize | 4.0 | 9 % |
| память тоннеля, веерное тело, считывание MBON, треки, решение | 1.9 | 4 % |
| **всего, медиана / p95** | **43.1 / 49.0** | из 100 |
Под видеокарту годится только ламина — это фильтры по сетке 128 × 600. Остальные 80 %
времени — связные компоненты и подгонка геометрии к сотням точек: нерегулярная работа с
ветвлениями, которую GPU не ускоряет, а копирование туда-обратно только замедлит.
Ламину на CUDA проверили на 49 реальных кадрах (реализация сокомандника,
`F.avg_pool2d` вместо `uniform_filter`): 5.8 мс на процессоре, **2.9 мс** на RTX 5070 Ti
с копированием образа на карту и результата обратно. Совпадение не побитовое: ON-канал
расходится на 1·10⁻⁵, а в 60 пикселях из 3.76 млн (0.0016 %) выбирается другой масштаб —
при равных откликах решает порядок суммирования.
Итог: перенос сэкономил бы около 3 мс из 43 при бюджете 100, а взамен потребовал бы
другого контейнера (наш собран на `ros:humble-ros-base-jammy`, без CUDA), установленного
у жюри nvidia-container-toolkit и запуска с `--gpus all`. Отказ любого из трёх звеньев —
это не медленный кадр, а не запустившееся решение. **Вывод остаётся на CPU**, видеокарта
— для обучения.
---
## 8. Что не сработало
@ -1138,23 +1104,6 @@ reflectivity. Ламбертова модель описывала физику
20 000 клеток дают ту же AUC (0.9859 и 0.9850 против 0.9860), а в худшем кадре
стоят 2.8 и 7.3 мс вместо 1.5 — платить не за что.
Позднее развёртку продолжили до 100 000 клеток на полной выборке (214 958
кандидатов), leave-one-bag-out:
| клеток | AUC | обстановки при 95 % предметов |
|---|---:|---:|
| **4 000** | **0.9829** | **9.89 %** |
| 20 000 | 0.9815 | 10.85 % |
| 50 000 | 0.9794 | 11.73 % |
| 100 000 | 0.9791 | 11.79 % |
Больше клеток — **хуже**, и монотонно. Активных после торможения APL всегда сто,
поэтому с ростом популяции каждая клетка становится специфичнее, и код превращается
в справочник по обучающим записям, который на невиданной записи обобщает хуже. Узкое
место не в ёмкости, а во входе: бустинг по тем же 23 признакам даёт 0.9927, то есть
запас есть, но лежит в признаках. Для сравнения, у мухи около двух тысяч клеток
Кеньона на полушарие.
Проверка — leave-one-bag-out, отдельная модель на каждую складку. Без этого
сквозные цифры были бы ложными: считывание увидело бы проверяемый бэг ровно
так же, как память тоннеля, обученная на всём подряд.
@ -1449,10 +1398,6 @@ h +0.0055 lat +0.0044
могут пригодиться на линии с другой статистикой. Значения по умолчанию
выключают их полностью.
*Позднее:* `warn_far` перемерен на конфигурации после п. 12.3 и 12.5 и
**принят** — P@150 0.33 → 0.37 без потерь ближе. Почему отказ устарел —
п. 15.9.
Итоговая конфигурация и её цена:
| | без гашения | **принято** |
@ -1845,33 +1790,10 @@ P@150 0.32), а на всём, что меньше, — заметно хуже:
потолок. **Тот самый вес, из которого улика складывается, различает их
идеально.** Сумма потеряла то, что было в слагаемых.
**Где именно это бьёт — оговорка.** `doubleT_obstacle` записан со **стоящего**
поезда, и там наблюдений у каждого трека сотни, так что потолка достигают и
предмет, и обстановка. На полигоне поезд едет, ложный трек столько не живёт, и
картина другая: улика на потолке у 79 % наблюдений предмета в полосе 0…30 м и
лишь у 1.5 % ложных, на 80…110 м — 31 % против 3.4 %.
То есть насыщение само по себе ложных тревог не создаёт. Оно **обесценивает
улику как меру**: у предмета она перестаёт расти, и весь запас, который он
заработал качеством наблюдений, пропадает — вместо «этот трек вчетверо
убедительнее» остаётся «оба по единице». Хуже всего это там, где поезд стоит
или ползёт, то есть ровно в ситуации, когда препятствие опаснее всего.
Отсюда и решение: сравнивать с порогом не улику, а её геометрическую смесь со
средним весом наблюдения, `w_mean^b · улика^(1−b)`. При `b = 0` всё ровно как
раньше, так что размен меряется, а не объявляется.
Что смешивание делает с отсчётом, посчитано прямо по выборке. При `b = 0.3`
через порог 0.5 уходят вниз 180 предметных наблюдений и 668 ложных — на одно
потерянное обнаружение три и семь десятых убранных ложных. При `b = 0.2`
соотношение лучше: 73 против 348, то есть 4.8. При `b = 0.5` оно рушится до
1.3, и это то же колено, что видно в таблице п. 15.6.
Смешивание при этом **не только давит**: у 21 % наблюдений отсчёт растёт — это
молодые треки с хорошими наблюдениями, у которых улика ещё мала. Среди ложных
таких даже больше (25.5 % против 14.6 % среди предметных), и на незнакомой
линии, где память ничего не подавляет, это выходит боком (п. 15.8).
### 15.2. Выборка по трекам
Полигон научили попутно выкладывать описание каждого живого трека на каждом
@ -2019,757 +1941,3 @@ leave-one-bag-out.
(п. 12.5), для новизны (п. 12.3) и для утечки улики. Это возможно, но
смешивание даёт тот же эффект мягко и без ещё одной калибровочной кривой,
поэтому порог оставлен выключенным (`track_gate = 0`).
### 15.8. Смешивание меняет не в ту сторону
Ложные треки на километр, обе сцены, считывание по складкам:
| настройка | знакомая линия | незнакомая линия | объект на 55 м |
|---|---:|---:|---:|
| база | 8.0 | **20.3** | 99.5 % |
| один дальний порог 0.30 | **8.0** | 21.3 | 99.5 % |
| смешивание 0.3 + дальний порог с 60 м | 6.3 | **24.8** | 99.5 % |
| смешивание 0.2 + дальний порог с 60 м | 8.0 | 24.0 | 99.5 % |
Смешивание улучшает знакомую линию и **портит незнакомую**. Это не случайность,
и механизм виден из разбора п. 15.1: смешивание не только давит — у 21 %
наблюдений оно отсчёт поднимает, и среди ложных таких больше, чем среди
предметных (25.5 % против 14.6 %). Поднимаются молодые треки с хорошими
наблюдениями и малой уликой. На знакомой линии память тоннеля такие треки уже
погасила через новизну, а на незнакомой гасить нечем — и смешивание их
вытаскивает.
Приватный тест — это незнакомая линия. Значит, смешивание отвергается:
`track_blend = 0` по умолчанию.
И это ещё не всё. Смешивание надо сравнивать не с базой, а с **тем же разменом,
полученным простым поднятием порога** — иначе мы сравниваем две разные рабочие
точки и выдаём сдвиг по кривой за улучшение кривой. Контроль:
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---:|
| смешивание 0.2 + дальний порог с 60 м | 0.71 | 0.47 | 0.37 | 817 |
| **порог 0.60 + дальний порог, без смешивания** | 0.70 | **0.49** | 0.37 | **816** |
Одинаковое число посторонних тревог, и при нём простой порог даёт обнаружение
на 100 м **лучше**. Никакой новой кривой смешивание не открывает — оно двигает
по той же, что и ключ, который у нас уже был.
Итог: `track_blend = 0`, `track_gate = 0`. Накопители на треке и считывание по
трекам остаются в коде как инструмент замера — они и дали понимание, что улика
насыщается, — но в решении не участвуют.
### 15.9. Отрицательные результаты стареют
Выигрыш в этой главе дал не считывание по трекам, ради которого всё
затевалось, а ключ, **отвергнутый тремя разделами раньше**. П. 12.2 говорит
дословно: «порог тревоги, линейно опущенный с 0.5 до 0.3 начиная с 90 м, не
изменил ни ложных (3.3 на км), ни обнаружения (P@100 = 0.31, решения за 120 м
по-прежнему 0)».
Тогда это было правдой, и причина там же, в п. 12.3: улика далёкого трека
колебалась около нуля, потому что знакомость была на той дальности перевёрнута
и входила в вес **множителем**. Опускать порог под сигналом, который равен
нулю, бессмысленно — что замер и показал.
Потом приняли гашение знакомости с 90 м (п. 12.3) и порог по лучам,
нормированный на дальность (п. 12.5). Улика далёкого трека перестала быть
нулём. Тот же самый ключ, тот же самый замер — и P@150 растёт с 0.33 до 0.37
ценой нуля ложных треков на знакомой линии и одного на незнакомой.
Отсюда правило, которое стоит дороже самой правки: **отрицательный результат
верен только для той конфигурации, в которой он получен.** Каждое принятое
изменение обесценивает часть прежних отказов, и дешёвые из них надо
перепроверять. Здесь это стоило одного прогона на четыре минуты.
Варианты той же правки, полигон, `человек_стоя`:
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---:|
| база | 0.70 | 0.53 | 0.33 | 999 |
| **порог 0.30 с 90 м** | 0.70 | 0.53 | **0.37** | **1091** |
| порог 0.30 с 60 м | 0.70 | 0.53 | 0.37 | 1197 |
| порог 0.20 с 90 м | 0.70 | 0.53 | 0.38 | 1181 |
| порог 0.30 + утечка 0.03 | 0.70 | **0.48** | 0.40 | 1172 |
Начинать раньше незачем: обнаружение то же, ложных больше. Опускать до 0.20 —
сотая на 150 м за восемь процентов посторонних тревог. Утечку, отвергнутую
в п. 12.4, на нынешней базе тоже перемерили: теперь она даёт P@150 0.40, но
забирает 100 м (0.53 → 0.48) и рабочую дальность 100 → 80 м — отвергнута
снова, уже по другой причине. Правило «перепроверять» не означает
«перепринимать».
Принято: `warn_far = 0.30`, `warn_far_from = 90`. Итог на всех сценах:
| | было | **стало** |
|---|---:|---:|
| P@150, человек стоя | 0.33 | **0.37** |
| P@150, человек сидя | 0.22 | **0.24** |
| P@100 / P@150, ящик | 0.38 / 0.03 | **0.40 / 0.06** |
| P@50 и P@100, человек | 0.70 / 0.53 | 0.70 / 0.53 |
| решений в полосе 90–120 м / 120–160 м | 0.49 / 0.38 | **0.51 / 0.41** |
| ложных треков на км, знакомая линия | 8.0 | **8.0** |
| то же, незнакомая линия | 20.3 | 21.3 |
| настоящий объект на 55 м | 99.5 % | **99.5 %** |
Экстренного торможения правка не касается: у него свой порог и условие
`d ≤ max(тормозной путь, 25 м)`, поэтому далёкий ложный трек физически не
может его вызвать. Мы снова платим предупреждениями за дальность — для
движущегося поезда это правильная сторона размена (как и в п. 12.3).
## 16. Работа Zhirik1337: что перенесено и что из этого принято
22.09 в командный репозиторий пришло семь коммитов Zhirik1337: вывод на
видеокарте, пять правок габарита и решения, «человек лёжа» в каталоге,
случайные сценарии полигона, выгрузка рамок, ROS-узел и Docker. Всё перенесено
в основной проект, код детектора — за флагами, выключенными по умолчанию.
Потом каждая правка мерилась отдельно и на тех же вставках. Флаги
выставляются из командной строки без правки кода:
`--set k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py`
и `check_obstacle.py`.
### 16.1. Полигон шумит сильнее, чем меняют правки
Две реализации случайности полигона при **одних и тех же** настройках (зерна
12345 и 777) расходятся на P@150 у человека стоя 0.39 против 0.32, на P@100 у
ящика 0.40 против 0.29. Большинство правок меняют цифры меньше, поэтому
сравнивать два прогона по итоговой таблице нельзя: разница окажется шумом.
Сравнение ведётся **парно**. Генератор у каждого сценария свой (п. 16.2), и от
решений конвейера он не зависит, поэтому при другой настройке на вход идут те же
кадры с теми же вставками, до луча. Разница двух прогонов — только от настройки,
и её видно по переворотам отдельных наблюдений: сколько было 0 и стало 1 и
наоборот (`tools/compare_benchmark.py`). Если перевороты идут в одну сторону,
эффект настоящий, даже когда он меньше межзернового шума.
Так перепроверен и дальний порог из п. 15.9, на обоих зёрнах: 0.50 → 0.30
переворачивает 8 и 2 наблюдения человека стоя на 130–170 м в плюс и **ни одного**
в минус. Эффект маленький, но настоящий, и правка остаётся.
### 16.2. Новый предмет сдвигал цифры всех остальных
Генератор был один на запись, и сценарии тянули из него по очереди, кадр за
кадром. Когда в каталог добавили человека лёжа, поток сдвинулся для **всех**
сценариев: цифры по ящику менялись оттого, что где-то появился новый предмет.
Это проверено прямо: со старым каталогом перенесённый код давал полигон
побайтово, с новым цифры прежних предметов разошлись.
Теперь у каждого сценария свой генератор, зерно — из имени предмета и бокового
смещения через crc32 (`scenario_rng`; встроенный `hash()` солится в каждом
процессе заново). Случайные сценарии Zhirik1337 (`--augment`) переведены на него
же. Тест `test_scenario_stream_does_not_depend_on_the_rest_of_the_catalogue`.
База с новым каталогом (порог 0.30 за 90 м, как в п. 15.9):
| предмет | рабочая дальность | P@50 | P@100 | P@150 |
|---|---:|---:|---:|---:|
| человек стоя | 80 м | 0.70 | 0.48 | 0.39 |
| человек сидя | 100 м | 0.67 | 0.52 | 0.20 |
| **человек лёжа** | 20 м | **0.27** | **0.00** | 0.00 |
| ящик | 20 м | 0.58 | 0.40 | 0.06 |
Человек лёжа виден хуже всех, кроме совсем мелочи. Воронка (п. 12) показывает
почему: на 30–50 м кандидат рождается только в 35 % наблюдений. Высота 0.30 м,
а пол габарита стоит на 0.28 м, так что в габарит попадает верхушка в два
сантиметра.
### 16.3. Пол в колее: вдали выигрыш, вблизи рельсы
Правка: между рельсами (|u| ≤ 0.85 м) нижняя граница габарита опускается с
0.28 до 0.16 м, снаружи колеи остаётся прежней. Вдали она даёт много, но
вблизи обнаружение **падает вдвое**:
| полоса | человек стоя: база → пол 0.16 | лёжа | ящик |
|---|---|---|---|
| 0–15 м | 0.93 → 0.92 | 0.70 → **0.58** | 0.75 → **0.62** |
| 15–25 м | 0.71 → **0.37** | 0.55 → **0.27** | 0.56 → **0.34** |
| 25–40 м | 0.58 → **0.42** | 0.37 → 0.35 | 0.48 → **0.35** |
| 55–70 м | 0.83 → 0.83 | 0.17 → **0.62** | 0.70 → 0.71 |
| 110–135 м | 0.43 → 0.46 | 0.00 → 0.00 | 0.30 → **0.60** |
| 160–190 м | 0.07 → **0.27** | 0.03 → 0.03 | 0.04 → **0.12** |
Рабочая дальность у всех предметов стала 8 м, то есть обнаружение
проваливается уже на втором поясе.
**Причина.** Один и тот же кадр со вставкой прогнан через оба конвейера, и там,
где обычный видит кандидата, а опущенный нет, кандидаты пересчитаны без запрета
на длину. Во всех 12 потерях на 12–45 м одной записи предмет входит в
компоненту длиной 50–140 м, которая начинается **с 4 м** — с ближней границы
габарита. У разобранных подробно её нижняя точка лежит ровно на опущенном
полу, 0.16–0.18 м: это головки рельсов. Вблизи соседние кольца ложатся на
полотно плотнее допуска по глубине, и рельс собирается в одну компоненту. Компоненту
длиннее 15 м конвейер отбрасывает как «полотно или стену под скользящим углом»
и выбрасывает вместе с ней предмет. Разделение фигуры и фона могло бы вырезать
предмет, но ближе `split_near = 55 м` оно выключено — то же слипание, что с
кромкой платформы в п. 12.7.
**Лечение** — не опускать пол ближе заданной дальности (`core_from`). Почему
дальше рельсы предмет не губят, отдельно не разбирали; замер показывает, что
с 30 м опущенный пол вреда не делает (старые складки, база → пол с 30 м):
| полоса | человек стоя | лёжа | ящик |
|---|---|---|---|
| 0–15 м | 0.93 → 0.93 | 0.70 → 0.70 | 0.75 → 0.75 |
| 15–25 м | 0.71 → 0.71 | 0.55 → 0.55 | 0.56 → 0.56 |
| 25–40 м | 0.58 → 0.58 | 0.37 → **0.50** | 0.48 → 0.49 |
| 55–70 м | 0.83 → 0.83 | 0.17 → **0.62** | 0.70 → 0.71 |
| 110–135 м | 0.43 → 0.46 | 0.00 → 0.00 | 0.30 → **0.60** |
| 160–190 м | 0.07 → **0.27** | 0.03 → 0.03 | 0.04 → **0.12** |
Где ставить порог, мерилось тремя прогонами. Ближе 25 м ни один из них ничего
не теряет: из 3 469 наблюдений всех предметов в минус не перевернулось ни одно
(в плюс 8, 6 и 0 — треки, начатые дальше порога, доживают до ближней зоны).
Различаются пороги на мелком и низком:
| порог | ведро 25–40 м | ведро 40–55 м | лёжа 25–40 м | посторонних на полигоне |
|---|---:|---:|---:|---:|
| база (пол не опущен) | 0.23 | 0.02 | 0.37 | 1196 |
| **с 30 м** | **0.49** | **0.52** | **0.50** | **1313** |
| с 40 м | 0.49 | 0.52 | 0.45 | 1333 |
| с 55 м | 0.23 | 0.29 | 0.45 | 1336 |
Порог 30 м не хуже остальных ни в одной полосе и даёт меньше посторонних.
Парно против 40 м он добавляет 7 наблюдений лежачего и 2 ящика и не теряет
ни одного.
Парно, на тех же вставках (пол 0.16 с 30 м против базы, старые складки):
| предмет | полоса | было | стало | 0→1 | 1→0 |
|---|---|---:|---:|---:|---:|
| человек стоя | 130–170 м | 0.36 | 0.50 | 37 | 0 |
| человек сидя | 130–170 м | 0.19 | 0.47 | 68 | 0 |
| человек лёжа | 50–90 м | 0.13 | 0.46 | 74 | 0 |
| ящик | 90–130 м | 0.36 | 0.56 | 46 | 0 |
| чемодан | 130–170 м | 0.01 | 0.26 | 51 | 0 |
| ведро | 50–90 м | 0.00 | 0.39 | 85 | 0 |
На всех предметах и полосах вместе +677 наблюдений и −7. Вблизи с порогом по
дальности ничего не теряется (человек стоя на 6–50 м: +1 и −0). Цена:
Со старыми моделями пол обходится в два с лишним трека на километр на
знакомой линии и в четыре на незнакомой (таблица ниже, средний столбец).
Лишние треки — не сбой, а то, что пол открыл: низкие конструкции в колее. На
записи со стрелочным переводом появились три новых трека, и один из них —
предмет высотой 0.42 м прямо между рельсами (u = −0.40 м) на 78–104 м.
Лидар его видел и раньше, но он стоял ниже пола габарита.
Считывание таких кандидатов не видело никогда: оно обучалось на выборке,
собранной при прежнем поле. Поэтому, как в п. 12.6, выборка пересобрана на
новом полу — **333 739 кандидатов против 214 958**. Прибавка почти целиком
фоновая, это и есть низкие конструкции в колее, плюс человек лёжа, которого
раньше в каталоге не было (предметов 24 651 против 20 150). Складки
переобучены с прежней ёмкостью 4 000 клеток. AUC leave-one-bag-out **0.9856**
против 0.9829 на прежней выборке (по складкам 0.980–0.992): новые фоновые
кандидаты отделяются от предметов легко.
| | база | пол 30 м, старые складки | **пол 30 м, новые складки** |
|---|---:|---:|---:|
| ложных треков/км, знакомая линия | 8.0 | 10.2 | **8.0** |
| кадров с тревогой | 13.3 % | 19.3 % | 13.8 % |
| то же, незнакомая линия | 21.3 | 25.4 | **23.5** |
| кадров с тревогой | 28.3 % | 37.4 % | 34.6 % |
| посторонних на полигоне | 1196 | 1313 | **1226** |
| настоящий объект на 55 м | 99.5 % | 99.5 % | **99.5 %** |
| задержка кадра p50 / p95, мс (один процесс) | 33–45 / 37–49 | — | 35–42 / 41–52 |
Переобученное считывание убрало всю цену на знакомой линии и половину на
незнакомой. Из выигрыша в обнаружении отдана малая часть — у лежачего P@100
0.32 → 0.22 против старых складок, остальное в пределах сотых. Против базы:
| предмет | рабочая дальность | P@50 | P@100 | P@150 |
|---|---|---|---|---|
| человек стоя | 80 → 80 м | 0.70 → 0.71 | 0.48 → 0.48 | 0.39 → **0.49** |
| человек сидя | 100 → **148 м** | 0.67 → 0.67 | 0.52 → 0.52 | 0.20 → **0.49** |
| **человек лёжа** | 20 → **32 м** | 0.27 → **0.50** | 0.00 → **0.22** | 0.00 → 0.02 |
| ящик | 20 → 20 м | 0.58 → 0.54 | 0.40 → **0.50** | 0.06 → **0.26** |
| чемодан | 62 → **80 м** | 0.56 → 0.61 | 0.25 → **0.45** | 0.00 → **0.24** |
Парно против базы, все предметы:
| полоса | наблюдений | 0→1 | 1→0 |
|---|---:|---:|---:|
| 0–15 м | 2 158 | 0 | **16** |
| 15–40 м | 2 583 | 93 | 1 |
| 40–90 м | 2 644 | 224 | 7 |
| 90–200 м | 2 746 | 368 | 9 |
Шестнадцать ближних потерь — цена именно переобучения: со старыми складками
ближе 25 м не терялось ничего. Тринадцать из них — человек стоя у края
габарита (смещение 0.9 м) на 9–15 м, на трёх записях, две из них с платформой;
ещё сидящий там же и две бутылки на оси. Кандидат и трек у человека есть,
решения нет. Отдельно причину не разбирали. По месту это тот же случай, что в
п. 12.7: предмет у края габарита вблизи, где разрез фигуры выключен.
**Принято**: `h_lo_core = 0.16`, `core_from = 30`, выборка и складки
пересобраны. Самый важный для метро случай — человек, упавший на пути, — был
виден хуже всех (P@50 = 0.27, дальше 50 м почти никогда), теперь P@50 = 0.50 и
рабочая дальность 32 м. Цена — два трека на километр только на незнакомой линии
и 16 ближних наблюдений из двух тысяч у края габарита.
### 16.4. Лежащий предмет без штрафа — при обученном считывании не действует
`lying_exempt` снимает штраф за вытянутость по пути с низкого предмета в колее
(высота до 0.40 м, длина до 2.2 м): человек лёжа вдоль пути тянется на 1.8 м и
по ручной формуле похож на кусок лотка. Замер: полигон совпал с базой до
последнего наблюдения, и с опущенным полом тоже.
Причина не в правке, а в том, куда она встроена. Множитель компактности
живёт в ручной формуле веса, а в рабочей конфигурации `mbon_blend = 1.0`: вес
наблюдения целиком берёт обученное считывание, и ручная формула входит в него
в нулевой степени (`central_complex._quality`). Правка работает только там,
где модели нет. Флаг оставлен выключенным. Лежащему человеку помогает пол
(п. 16.3). Считывание, переобученное на выборке, где лежачий уже есть, ему не
помогло: P@100 0.32 → 0.22 против старых складок (п. 16.3).
### 16.5. Габарит в кривых, вырез платформы, дальний канал
Все три мерились одинаково: полигон парно против базы (сумма переворотов по
всем полосам) и ложные треки на двух сценах со старыми моделями.
| правка | человек стоя | сидя | лёжа | ящик | чемодан | всего | ложных/км, знакомая | незнакомая |
|---|---|---|---|---|---|---|---:|---:|
| база | | | | | | | 8.0 | 21.3 |
| `k_sigma = 0.75` | +0/−1 | +19/−0 | +0/−10 | +37/−0 | +0/−5 | +56/−16 | 9.5 | 25.7 |
| `platform_filter` | +9/−5 | +17/−3 | +0/−11 | +2/−7 | +1/−0 | +35/−26 | 10.2 | 25.2 |
| `far_channel` | +24/−0 | +21/−0 | +10/−0 | +43/−0 | +15/−0 | +115/−0 | 11.0 | 28.4 |
| для сравнения: пол с 30 м | +51/−1 | +92/−0 | +135/−0 | +108/−5 | +131/−0 | **+677/−7** | 10.2 | 25.4 |
**Габарит по неопределённости оси** (`k_sigma`) расширяет коридор там, где ось
пути известна хуже, — вдали и в кривых. Ящику это помогает, лежачему и
чемодану мешает: в широком габарите к низкому предмету чаще прилипает
посторонняя компонента. Ложные треки добавляются ровно там, где габарит
расширился, — на кривых записях, с медианной дальностью около 100 м. Отвергнуто.
**Вырез платформы** (`platform_filter`) удаляет из габарита горизонтальную
полосу 1.05–1.25 м у края (|u| ≥ 1.30 м) — настил платформы. На записях с
платформой ложных треков ровно столько же (1 → 1 и 2 → 2), а весь рост
приходится на `roundT_doubleT`, где платформы нет вовсе: 1 → 4 на знакомой
линии, 8 → 12 на незнакомой. Полоса, вырезанная из стены или лотка, режет
конструкцию на верхний и нижний обрывки, и каждый выглядит компактным
предметом. На полигоне перевороты в обе стороны почти поровну, то есть это
шум. Отвергнуто.
**Дальний канал** (`far_channel`) — отдельный путь к тревоге для треков дальше
90 м с послабленными условиями. Выигрыш настоящий (+115 и ни одного минуса), но
он решает ту же задачу, что и принятый дальний порог (п. 15.9), и стоит втрое
больше: +3.0 трека на км на знакомой линии и +7.1 на незнакомой, посторонних на
полигоне 1196 → 1676. Для сравнения, пол с 30 м даёт вшестеро больше
переворотов за меньшую цену. Отвергнуто; включать его вместе с дальним
порогом — ослаблять дальний край дважды.
### 16.6. Всё вместе, как было отправлено
Все пять правок разом плюс свои пороги дальнего края (`warn_far = 0.35`,
`leak_far = 0.06`): дальние цифры самые высокие из всех прогонов (P@150 у
человека сидя 0.56, у ящика 0.37), но посторонних тревог на полигоне **2299
против 1196** — почти вдвое, — и рабочая дальность 8 м у всех предметов из-за
рельсов (п. 16.3). Парно против базы:
| полоса | наблюдений | было | стало | 0→1 | 1→0 |
|---|---:|---:|---:|---:|---:|
| 6–40 м | 2 926 | 0.63 | **0.50** | 107 | **496** |
| 40–90 м | 1 982 | 0.47 | 0.61 | 271 | 3 |
| 90–200 м | 2 684 | 0.19 | 0.38 | 531 | 0 |
Дальше 40 м всё в плюс, ближе — всё в минус. А ближняя зона — это экстренное
торможение (у него условие `d ≤ max(тормозной путь, 25 м)`, п. 15.9) и вся
работа на малой скорости, у платформы и на подходе к ней: там других
дальностей просто нет. По отдельности видно, откуда что: дальний выигрыш почти
целиком даёт пол, ближний провал — он же без порога по дальности, а из
посторонних тревог больше всего добавляет дальний канал: +480 в одиночку, пол
без порога +99, остальные — десятки.
### 16.7. Выгрузка рамок, устройство, ROS-узел
* **Рамка в кривой стояла не там.** Боковое смещение трека отсчитано от оси
пути, а шло прямо в `x` сенсора: на радиусе 1300 м рамка уезжала на 1.2 м на
55 м и на 8.6 м на 150 м — в стену. Теперь `x = u + corridor.centre(d)`.
* **Поворот рамки был зеркальным**: `yaw = −atan(наклон)` вместо `+atan`, на
150 м это 13°. Оба случая проверяет `test_export_box_follows_a_curved_track`.
* **Устройство по умолчанию — процессор.** У Zhirik1337 `device=None` означал
«видеокарта, если есть», и обучение с выводом тихо уезжали на GPU. Вывод на
видеокарте ускоряет только ламину (п. 7.4). `auto` и `cuda` работают, если их
передать явно; Docker так и делает. Ошибка по дороге: `encode(device="auto")`
отдавал строку `auto` прямо в torch, падал, и падение навсегда помечало CUDA
сломанной.
* **ROS-узел `tools/flyguard_ros2_node.py`** отдаёт конвейеру массив numpy
вместо разобранного `PointCloud2`, публикует все треки, а не решение, и у
трека нет полей рамки. Узел ведёт интеграция, он оставлен за ними
(`TEAM_OWNED` в `export_team.py`). Рабочий узел — `ros2_ws/.../node.py`.
### 16.8. Итог
| правка | решение | почему |
|---|---|---|
| пол в колее `h_lo_core = 0.16` | **принято**, дальше 30 м (`core_from`) | лежачий P@50 0.27 → 0.50, P@150 у стоя 0.39 → 0.49; без порога по дальности вблизи провал вдвое |
| выборка и складки на новом полу | **принято** | цена пола на знакомой линии 2.2 → 0 треков на км, на незнакомой 4.1 → 2.2 |
| `lying_exempt` | не действует | правит ручную формулу, а вес целиком от модели |
| `k_sigma` | отвергнуто | +56/−16 на полигоне за +1.5 и +4.4 трека на км |
| `platform_filter` | отвергнуто | на платформах ничего, в тоннеле без платформы режет стену: +2.2 и +3.9 |
| `far_channel` | отвергнуто | та же задача, что `warn_far`, втрое дороже: +3.0 и +7.1 |
| человек лёжа в каталоге | **принято** | самый важный для метро случай и самый трудный |
| генератор на сценарий | **принято** | новый предмет больше не сдвигает цифры остальных |
| выгрузка рамок `export.py` | **принято** с двумя исправлениями | рамка в кривой и знак поворота |
| вывод на видеокарте | в коде, по умолчанию процессор | ускоряет только ламину (п. 7.4) |
Всё отвергнутое осталось в коде за флагами, с ценой в этом разделе:
отрицательный результат верен только для той конфигурации, в которой получен
(п. 15.9), и перепроверка любой правки стоит одного ключа `--set`.
**Что не сделано.** Память тоннеля (грибовидное тело) обучена на кандидатах,
собранных 19.09 при прежнем полу, и низких конструкций в колее не знает: на
знакомой линии их гасит только считывание. Выборку памяти надо собирать
проходом по 90 ГБ `new_data` — это следующий шаг, если понадобится ещё
снизить ложные на знакомой линии. *Сделано — п. 17.1.*
## 17. После переноса: память на новом полу, край габарита, вторая половина `new_data`
Три хвоста п. 16, каждый мерился так же: полигон парно, ложные треки на двух
сценах, настоящий объект.
### 17.1. Память тоннеля на новом полу
Кэш кандидатов памяти был собран 19.09, при старом полу, и низких конструкций
между рельсами память не знала (п. 16.8). Пересобран целиком: шесть записей
(`tune_memory.py --collect-only`) и весь `new_data` (`stream_new_data.py`,
39 707 кандидатов против 30 902), память переобучена.
| | было | новая память |
|---|---:|---:|
| ложных треков/км, знакомая линия | 8.0 | **6.5** |
| кадров с тревогой | 13.8 % | **9.0 %** |
| посторонних на полигоне | 1226 | 1166 |
| полигон парно | | +78 / −95 |
| настоящий объект | 99.5 % | 99.5 % |
Незнакомой линии память не касается — там её нет по построению.
При резкости считывания 1.5, принятой в п. 17.4, разница между памятями
исчезает: на знакомой линии 5.7 и 5.7 ложных трека на км, полигон парно
+90 / −86, на записи с настоящим объектом кадров с посторонней тревогой
80.5 % против 96.3 %. Принята новая: она собрана на том же полу габарита,
что и детектор, и знает низкие конструкции между рельсами, которые детектор
теперь видит.
Все ближние потери — один сценарий: на `roundT_pressureGate_roundT` поезд
стоит в 14.1 м от сидящего человека или ящика у края габарита, рядом
гермозатвор, и предмет не подтверждается: 26 кадров подряд у сидящего, 10 у
ящика. Кандидат там —
слипшееся пятно (≈950 лучей, высота 0.40 м), считывание в нём не уверено
(p = 0.23–0.30), и держится он только на новизне: у старой памяти 0.27–0.28,
у новой 0.20–0.21. При нижнем пороге новизны 0.15 вес наблюдения падает в
2.4 раза, улика 0.36–0.39 против 0.04–0.05. Случай пограничный при любой
памяти, новая переводит его через край. У настоящего объекта новизна тоже
ниже (медиана 0.63 → 0.51), но выше 0.50, где множитель новизны уже равен
единице.
### 17.2. Край габарита у платформы
Шестнадцать ближних потерь после переобучения (п. 16.3) разобраны. У края
габарита (0.9 м) рядом с платформой нижняя часть человека слипается с
кромкой, и от кадра к кадру кандидат то слипшееся пятно (p ≈ 0.0–0.2 у любых
складок), то «верх»: обрывок 0.2 × 0.2 м на высоте 1.5–1.7 м, целиком в
габарите, с нулевым дефицитом до пола — по признакам навесное оборудование.
Такой верх старые складки оценивали в 0.74–0.99, новые — в 0.43–0.84. Улика
не добирает до порога 0.5, и решение приходит на 8.8–8.9 м вместо 10.7–17 м.
В среднем новые складки на ближних предметах почти так же уверены, как
старые (отложенные записи, 0–15 м: медиана p 0.993 против 0.996, доля
p ≥ 0.9 — 0.864 против 0.891), а уверенного фона на 60–120 м у них вшестеро
меньше (0.1 % против 0.7 %). Отсюда и падение ложных тревог: новое считывание
строже в целом и к нетипичному обрывку в частности.
Попытка лечения — положение 0.9 м в обучающей выборке: полигон его проверяет,
а в выборке были только 0, ±0.6 и ±1.2 м. Выборка 466 715 кандидатов, AUC
0.9849. **Край это не вылечило**: человек стоя на 0–15 м +5 / −5, сидя
+0 / −4. Зато считывание стало строже к фону:
| | было | с ±0.9 |
|---|---:|---:|
| ложных треков/км, незнакомая линия | 23.5 | **21.9** |
| то же, знакомая (новая память) | 6.5 | 6.5 |
| посторонних на полигоне | 1226 | 1175 |
| полигон парно | | +9 / −25 |
| настоящий объект | 99.5 % | 99.5 % |
На второй половине `new_data` (п. 17.3) — ничего: 23.9 и 23.9 ложных трека
на км. Не принято.
### 17.3. Считывание, обученное на `new_data`
Пять коротких записей сняты в один день (02.09) и вместе дают чуть больше
километра пути. `new_data` — двадцать минут непрерывной езды 17.09: 11 271
кадр в 221 шарде, 90 ГБ несжатого tar. Места на диске меньше архива, поэтому
он читается кусками прямо из tar: `tools/_new_data.py` распаковывает по пять
шардов во временный каталог и удаляет за собой.
Делить пришлось по времени, а не по кадрам: вставки в первую половину
(шарды 0–109) идут в обучение, ложные тревоги меряются на второй (шарды
110–220, 3.27 км пути), которую модель не видела ни в каком виде. Памяти
тоннеля в этой проверке нет, как на незнакомой линии. Выборка — прежние
333 739 кандидатов плюс 827 314 из 22 кусков первой половины, предметов
5.4 %, AUC складок 0.981.
| считывание | вторая половина `new_data`, ложных/км | кадров с тревогой | незнакомая линия | знакомая линия |
|---|---:|---:|---:|---:|
| прежнее | 23.9 | 38.5 % | 23.5 | 6.5 |
| с ±0.9 (п. 17.2) | 23.9 | 39.0 % | 21.9 | 6.5 |
| с `new_data` | **13.2** | **20.2 %** | **13.2** | 6.2 |
Ложных тревог почти вдвое меньше, и прокси незнакомой линии совпал с
честной проверкой. Но полигон парно — +39 / −259: у стоящего человека P@50
0.71 → 0.62, у лежачего P@100 0.22 → 0.11, у ящика 0.53 → 0.40.
Воспроизводится так:
```bash
python tools/make_training_set.py --new-data 0:110 --out data/cache/training_set_nd.npz
python tools/train_mbon.py --device cuda --data data/cache/training_set.npz \
--data data/cache/training_set_nd.npz --train-only new_data_ \
--save-folds artifacts/mbon_folds_nd --out artifacts/mbon_readout_nd.npz
python tools/eval_new_data.py --shards 110: --readout было=artifacts/mbon_readout.npz \
--readout nd=artifacts/mbon_readout_nd.npz --out nd_eval.json
```
`--train-only new_data_` — куски `new_data` только учат и складок не
получают: проверяются по-прежнему пять записей.
### 17.4. Строже или умнее: сравнение при равной строгости
Модель, которая вдвое реже тревожится и заметно реже видит предмет, может
быть не лучше, а просто строже. Это проверяется сравнением при одинаковой
строгости, и ручка для неё есть с п. 11: `mbon_power`, вес наблюдения
p^power. Все строки — с новой памятью (п. 17.1).
| считывание, резкость | вторая половина `new_data` | незнакомая | знакомая | посторонних на полигоне | полигон парно к первой строке |
|---|---:|---:|---:|---:|---:|
| прежнее, 1.0 | 23.9 | 23.5 | 6.5 | 1166 | |
| **прежнее, 1.5** | 17.4 | 14.7 | **5.7** | **793** | **+1 / −142** |
| прежнее, 2.0 | 15.0 | 14.3 | 5.7 | 628 | +0 / −268 |
| `new_data`, 1.0 | **13.2** | **13.2** | 6.2 | 822 | +39 / −259 |
| `new_data`, 0.7 | 24.8 | 16.4 | 7.7 | 927 | +72 / −184 |
* **Больше половины выигрыша — строгость.** Прежнее считывание при
резкости 1.5 срезает ложные на незнакомой линии с 23.5 до 14.7, а на
полигоне теряет 142 наблюдения из 7 600: 39 вблизи у края габарита (тот же
пограничный случай, что в п. 17.1–17.2) и 82 за 90 м.
* **Но не весь.** При сопоставимых ложных на второй половине `new_data`
(15.0 и 13.2) модель на `new_data` теряет на полигоне меньше, чем прежняя
при 2.0 (−220 против −268 в сумме). Другой день записи действительно
научил её тому, чего в пяти записях нет. Ослаблять её бесполезно: при 0.7
ложные возвращаются к 24.8.
* **Между «прежнее, 1.5» и «`new_data`, 1.0» — размен, а не доминирование.**
Вторая на четверть реже тревожится на данных своего дня, первая держит
дальность: P@50 у стоящего 0.71 против 0.62, P@100 у лежачего 0.22 против
0.11, у ящика 0.50 против 0.40. На пяти записях без памяти, которых при
проверке не видела ни одна из двух, они почти равны (14.7 и 13.2), на
знакомой линии прежняя лучше (5.7 и 6.2).
**Принято: прежнее считывание, резкость 1.5.** ТЗ просит баланс дальности и
ложных тревог, постановщик — прежде всего не видеть того, чего нет; резкость
1.5 даёт второе почти без цены в первом. Модель на `new_data` остаётся
рецептом из п. 17.3: если появятся ещё записи других дней, учить на них
выгоднее, чем ужесточать.
### 17.5. Итог
Новая память (п. 17.1), прежнее считывание, резкость 1.5 (п. 17.4) — против
опубликованного 23.09:
| | 23.09 | сейчас |
|---|---:|---:|
| ложных треков/км, знакомая линия | 8.0 (13.8 % кадров) | **5.7** (8.8 %) |
| то же, незнакомая линия | 23.5 (34.6 %) | **14.7** (25.9 %) |
| то же, вторая половина `new_data` | 23.9 (38.5 %) | **17.4** (31.4 %) |
| посторонних тревог на полигоне | 1226 | **793** |
| настоящий объект | 99.5 % | 99.5 % |
| человек стоя: рабочая дальность; P@50 / P@100 / P@150 | 80 м; 0.71 / 0.48 / 0.49 | **100 м**; 0.71 / 0.50 / 0.46 |
| человек сидя | 148 м; 0.67 / 0.52 / 0.49 | 122 м; 0.67 / 0.54 / 0.39 |
| человек лёжа | 32 м; 0.50 / 0.22 / 0.02 | 20 м; 0.50 / 0.22 / 0.00 |
| ящик | 20 м; 0.54 / 0.50 / 0.26 | 20 м; 0.57 / 0.50 / 0.22 |
| чемодан | 80 м; 0.61 / 0.45 / 0.24 | 80 м; 0.61 / 0.46 / 0.24 |
Ложных тревог примерно на треть меньше на всех трёх сценах, ближняя и
средняя дальность на месте. Цена — хвост за 130 м у сидящего человека
(P@150 0.49 → 0.39) и рабочая дальность лежачего 32 → 20 м: доля в полосе
20–32 м у него ходит у самого порога 0.5, и сдвинула её новая память, а не
резкость (P@50 тот же, 0.50). Без обученного считывания при тех же
настройках было бы 11.9 и 36.1 ложных трека на км.
Отдельно найдено при проверке сдачи: образ Docker **не загружал**
считывание — `mbon_path` был пустым, файла модели в образе не было, и узел
молча работал на ручной формуле. Все цифры выше замерены со считыванием,
образ теперь тоже с ним (`docker/Dockerfile`, `detect.launch.py`), а
дымовой тест образа проверяет, что модель на месте.
### 17.6. Раздельный запуск: узел в контейнере, проигрывание снаружи
ТЗ описывает приёмку как `docker build → docker run → ros2 bag play`. Все
прежние замеры шли внутри одного контейнера — узел и проигрыватель вместе.
Раздельный запуск, как у инженера на стенде, проверен впервые (запись
`roundT_doubleT`, 252 кадра):
| узел | проигрыватель | принято кадров |
|---|---|---:|
| `--network host` | другой контейнер, наш профиль DDS | **0** |
| `--network host`, умолчания Fast DDS | другой контейнер, умолчания Fast DDS | **0** |
| `--network host --ipc host` | другой контейнер `--ipc host`, умолчания Fast DDS — как ROS на хосте | 252 |
| `--network host --ipc host` | другой контейнер `--ipc host`, наш профиль | 252 |
Причина: с `--network host` Fast DDS видит у собеседника тот же хост и шлёт
кадры через `/dev/shm`, а без `--ipc host` у контейнера она своя — сегмент
собеседника не виден, и кадры пропадают молча, без единой ошибки в журнале.
Это общая ловушка ROS 2 в Docker, наш профиль ни при чём: на умолчаниях то же.
Что сделано:
* `--ipc host` — во всех командах README;
* точка входа видит собственную `/dev/shm` (источник монтирования `shm`) и
переводит транспорт на UDP (`docker/fastdds_udp.xml`): проигрывание внутри
контейнера и второй контейнер из этого же образа тогда работают и без флага
(252 из 252). Проигрывателю на хосте со своими умолчаниями это не помогает —
он всё равно идёт через память (0 кадров), поэтому
* узел следит за входом: если издатель на топике есть, а кадров нет пять
секунд, он пишет в журнал, что контейнер нужно запускать с `--ipc host`.
Попутно: запуск одной командой (`bag:=`) проигрывал запись без
`--read-ahead-queue-size 10`, и на кадрах по 24 МБ доходило 119 из 201. Флаг и
задержка старта проигрывания на 4 с добавлены в launch — 201 из 201, с RViz и
без. Сквозная проверка после всех правок, `doubleT_obstacle` в реальном
времени: 201 кадр из 201, объект в 189 кадрах из 190 на 55.8 м, обработка кадра
33.4 / 40.3 мс (медиана / p95).
### 17.7. RViz: почему тормозило и где пропадало облако
При просмотре в RViz на записи с препятствием облака не было вовсе, а со
схемой мозга всё заметно тормозило. Обе причины — в обвязке, не в конвейере.
* **Топик.** RViz слушал `/lidar_points`, а `doubleT_obstacle` пишет в
`/sensing/lidar/hesai128/pointcloud` (остальные пять записей — в
`/lidar_points`). Узел подписан на оба, поэтому рамка «55 м» была, а точек —
нет.
* **Цена сообщений в rclpy.** Присваивание `bytes` полю `data` rclpy
проверяет поэлементно на Python: картинка мозга в 2.8 МБ — 142 мс, против
2.4 мс у `array('B')`. Обработка идёт в колбэке, и узел со схемой мозга
успевал около трёх кадров в секунду.
| RViz | схема мозга | принято кадров из 201: до | после |
|---|---|---:|---:|
| есть | нет | 183 | 201 |
| нет | есть | 135 | 201 |
| есть | есть | 80 | 201 |
Сделано: облако обзора `/flyguard/view_cloud` — сектор обработки из самого
конвейера, до 77 тысяч точек вместо 900 тысяч, в координатах пути (пол на
сетке, рамки препятствий на полу); оно есть при любом входном топике и
публикуется, только когда на него подписаны. В обоих местах — `array('B')`.
Для схемы мозга — отдельный конфиг RViz, где она справа во всю высоту:
раскладку панелей Qt хранит сериализацией, и строка для конфига собирается
`tools/rviz_layout.py` по исходникам Qt 5.15, без самого Qt.
Раз уж топик у записей разный, у контрольной записи он может оказаться
третьим. Сторож входа теперь и это закрывает: если наши топики молчат, а в
системе есть другой топик с облаком точек, узел через секунду подписывается на
него сам и пишет об этом в журнал. Проверено переименованием топика при
проигрывании (`--remap /lidar_points:=/my/cloud`): 248 кадров из 252 — четыре
первых ушли, пока узел искал.
И ещё одна находка в самой проверке. `docker/check_all.sh` гоняет записи
подряд в одном контейнере через `demo_test.sh`, а тот гасил launch сигналом
SIGTERM — узел его переживал. Узлы копились: на шестой записи их работало пять,
«принятых» кадров выходило 2287 при 877 в записи, а время кадра росло от записи
к записи с 37 до 82 мс. Теперь SIGINT и ожидание, пока детектор не выйдет.
Все шесть записей в контейнере после правок (`docker/check_all.sh`; память и
считывание из образа, так что все записи им знакомы):
| запись | кадров | кадров с тревогой | кадр, медиана / p95, мс |
|---|---:|---:|---:|
| `doubleT_obstacle` | 201 | 189 (99.5 %) — настоящий объект, 55.7 м | 37.3 / 44.8 |
| `doubleT_platform` | 345 | 0 | 40.8 / 49.2 |
| `roundT_doubleT` | 252 | 0 | 43.4 / 58.8 |
| `roundT_pressureGate_roundT` | 268 | 0 | 51.2 / 62.1 |
| `roundT_squareT_pressureGate_squareT` | 545 | 28 (5.2 %) | 56.0 / 68.5 |
| `squareT_platform_squareT_switch` | 877 | 50 (5.8 %) | 47.9 / 65.8 |
Записи читались с диска Windows через WSL, медленнее реального времени, и узел
обработал все кадры до одного. Тревоги на двух последних записях — три трека,
неподвижных в координатах пути (точка держится с точностью до 1.2 м):
| запись | когда | дальность | что видит узел | уверенность |
|---|---|---|---|---:|
| `roundT_squareT_pressureGate_squareT` | 28–31 с | 65 → 30 м | плоская полоса 1.3 м × 3 см на 0.19 м над рельсом, по оси | ≤ 0.26 |
| `squareT_platform_squareT_switch` | 21–23 с | 103 → 92 м | 0.2 × 0.4 м, низ на 0.48 м над рельсом, по оси | ≤ 0.34 |
| `squareT_platform_squareT_switch` | 70–74 с | 147 → 136 м | 2.5 × 1.0 м на высоте 0.8–1.8 м, в 0.8 м левее оси | ≤ 0.36 |
Первая — почти наверняка порог гермозатвора: пол в колее опущен до 0.16 м
(п. 16.3), и полоса на 0.19 м над рельсом оказывается над ним. Две другие
нужно смотреть глазами: по словам организаторов, препятствий в данных «не
больше двух», и одно из них в `doubleT_obstacle` — второе может оказаться
здесь.
## 18. Узел `tools/flyguard_ros2_node.py` доведён до рабочего состояния
П. 16.7 фиксировал узел как нерабочий («отдаёт конвейеру массив numpy вместо
разобранного `PointCloud2`, публикует все треки, а не решение, и у трека нет
полей рамки») и оставлял его за интеграцией. Отдельного `ros2_ws/.../node.py`
или `export_team.py` в репозитории нет — это единственный узел, поэтому
переписан начисто:
* **Вход.** [`flyguard/cdr.py`](../flyguard/cdr.py) получил `from_ros_message`:
переводит уже разобранное rclpy-сообщение в тот же `PointCloud2`, что и
офлайн-парсер, по общему `point_dtype`. Порядок точек и NaN сохраняются —
`sensor_msgs_py.read_points(skip_nans=True)` их выбрасывал, а решётка
`retina.py` раскладывает облако по позиции в массиве (азимут × кольцо), и
выброшенная точка сдвигала бы всю решётку. Тест —
`test_ros_message_keeps_every_point_in_order`.
* **Решение, не треки.** Узел зовёт [`flyguard/export.py`](../flyguard/export.py)
(`export_frame`) — тот же модуль, что уже решал разворот рамки в кривой и
зеркальный `yaw` (п. 16.7) — и публикует `Decision`, а не сырые треки
`CentralComplex`. Рамки идут и в `vision_msgs/Detection3DArray`, и в
`visualization_msgs/MarkerArray` (кубы + подписи «ID | дальность | TTC»), из
одного и того же `ExportResult`, так что они не могут разойтись.
* **Не падает от битого кадра.** `pointcloud_callback` оборачивает
`fg.process()` в `try/except`: следующий кадр придёт через 100 мс, а не
превратит узел в мёртвый процесс на записи с реальным Pandar128E3X, где
структура сообщения может отличаться от тестовых бэгов.
* **Память и считывания подключаются по умолчанию.** Раньше узел собирал
`FlyGuard` без `memory`/`readout` вообще — конвейер работал в режиме «всё
ново», без обученной памяти тоннеля. Теперь по умолчанию берутся
`artifacts/mushroom_body.npz` и `artifacts/mbon_readout.npz`, если они есть
(параметры `memory_path`/`readout_path` позволяют переопределить путь или
явно отключить значением `-`).
* **Устройство по умолчанию — `cpu`**, как и решил п. 16.7: на GPU
ускоряется только `Lamina`, а `device=auto` в узле интеграции — источник
тихих ошибок, если жюри не даёт GPU.
Не проверено на самом Pandar128E3X и не прогнано в реальном `ros2_ws` — только
модульным тестом и прогоном логики колбэка на заглушках `rclpy`/`vision_msgs`
(тестовое окружение без ROS2). Перед демо нужен прогон `ros2 bag play` живой
записи с этим узлом.
## 19. `flyguard/bag.py`: битый CDR-пакет больше не обрывает чтение бэга
`Bag.frames()` звал `parse_pointcloud2(blob)` без защиты — одно повреждённое
сообщение (реальный сенсор, не тестовый бэг) роняло исключение наружу и
обрывало генератор, а с ним — весь прогон `tools/evaluate.py` /
`tools/run_pipeline.py` на этой записи, вместе со всеми уже накопленными
кадрами. Теперь разбор каждого сообщения обёрнут в `try/except`: битый кадр
логируется (`flyguard.bag`, номер кадра, файл шарда, тип исключения) и
пропускается, `start`/`stop` по-прежнему считают по позиции сообщения в
бэге, а не по числу успешно разобранных — окно `frames(start=..., stop=...)`
не съезжает. Число пропусков доступно как `Bag.n_frames_failed` после
прохода. Тест — `test_bag_skips_a_corrupted_message_and_keeps_reading`
(набор вырос до 46/46), с отдельным минимальным CDR-кодировщиком в
`tests/test_pipeline.py` для валидного `PointCloud2` без зависимости от
реальных бэгов.
Два других пункта из того же списка критики остаются открытыми — их нельзя
закрыть в этом окружении:
* **Три неопознанных ложных трека (п. 17.7)** требуют визуального разбора по
самим бэгам/RViz — самих файлов записей в `data/` в этой рабочей копии нет.
* **ROS2-узел на реальном стенде** — в этом окружении не установлен пакет
`rclpy` (`ModuleNotFoundError: No module named 'rclpy'`), так что живой
прогон `ros2 bag play` с узлом (п. 18) не выполним здесь; логика узла
проверена только на заглушках.

View file

@ -6,7 +6,6 @@
"""
from __future__ import annotations
import logging
import re
import sqlite3
from contextlib import closing
@ -16,8 +15,6 @@ from typing import Iterator
from .cdr import PointCloud2, parse_pointcloud2
logger = logging.getLogger("flyguard.bag")
_SHARD_RE = re.compile(r"_(\d+)\.db3$")
_POINTCLOUD_TYPE = "sensor_msgs/msg/PointCloud2"
@ -47,7 +44,6 @@ class Bag:
self.shards = shards
self.topics = self._scan_topics()
self.topic = topic or self._pick_topic()
self.n_frames_failed = 0 # обновляется по ходу последнего frames()
@staticmethod
def _shard_key(p: Path) -> tuple[int, str]:
@ -87,13 +83,9 @@ class Bag:
"""Выдать (timestamp_ns, облако) для сообщений выбранного топика.
Индексация сквозная по всему бэгу; шарды читаются по порядку, внутри
шарда — по возрастанию времени. Сообщение, которое не удаётся разобрать
(битый CDR у реального сенсора), пропускается с предупреждением в лог,
а не останавливает чтение бэга — `start`/`stop` считают по позиции
сообщения в бэге, а не по числу успешно разобранных.
шарда — по возрастанию времени.
"""
idx = 0
self.n_frames_failed = 0
for shard in self.shards:
# closing(), а не сам connection: у sqlite3 `with` управляет
# транзакцией и файл остаётся открытым — на Windows его потом
@ -119,15 +111,7 @@ class Bag:
if stop is not None and idx >= stop:
return
if idx >= start and (idx - start) % stride == 0:
try:
pc = parse_pointcloud2(blob)
except Exception as e: # noqa: BLE001
self.n_frames_failed += 1
logger.warning(
"кадр %d в %s не разобран (%s: %s) — пропущен",
idx, shard.name, type(e).__name__, e)
else:
yield ts, pc
yield ts, parse_pointcloud2(blob)
idx += 1
def describe(self) -> str:

View file

@ -4,8 +4,7 @@
* офлайн-эксперименты на машине без ROS (Windows);
* прямое чтение rosbag внутри контейнера, минуя `ros2 bag play`.
Внутри ROS-ноды сообщение приходит уже разобранным; его переводит в тот же
`PointCloud2` функция `from_ros_message`.
Внутри ROS-ноды сообщение приходит уже разобранным, и этот модуль не используется.
"""
from __future__ import annotations
@ -129,21 +128,3 @@ def parse_pointcloud2(blob: bytes | memoryview) -> PointCloud2:
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)
def from_ros_message(msg) -> PointCloud2:
"""Перевести разобранное rclpy-сообщение sensor_msgs/msg/PointCloud2.
Все height·width точек сохраняются, включая NaN: сетчатка раскладывает облако
в решётку азимут × кольцо по порядку точек, и выброшенная точка сдвинула бы
всю решётку (`sensor_msgs_py.read_points(skip_nans=True)` делает именно это).
"""
if msg.is_bigendian:
raise ValueError("big-endian PointCloud2 не поддерживается")
fields = [(f.name, f.offset, f.datatype, f.count) for f in msg.fields]
dt = point_dtype(fields, msg.point_step)
points = np.frombuffer(msg.data, dtype=dt, count=msg.height * msg.width)
stamp = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9
return PointCloud2(stamp=stamp, frame_id=msg.header.frame_id, height=msg.height,
width=msg.width, point_step=msg.point_step,
is_dense=bool(msg.is_dense), points=points)

View file

@ -147,10 +147,8 @@ class CentralComplex:
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,
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0,
lying_exempt: bool = False):
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0):
self.use_shape = use_shape
self.lying_exempt = lying_exempt
self.mbon_power = mbon_power
self.mbon_blend = mbon_blend
self.nov_fade_from = nov_fade_from
@ -226,8 +224,7 @@ class CentralComplex:
t.hits += 1
t.misses = 0
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to,
self.lying_exempt)
self.nov_fade_from, self.nov_fade_to)
t.observe(c, self.s_world + c.d, w)
t.evidence = min(1.0, t.evidence + self.gain * w)
if dt > 1e-3:
@ -254,8 +251,7 @@ class CentralComplex:
width=c.width, height=c.height, first_d=c.d, last_d=c.d,
last_n_rays=c.n_rays, novelty=c.novelty)
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to,
self.lying_exempt)
self.nov_fade_from, self.nov_fade_to)
t.observe(c, self.s_world + c.d, w)
t.evidence = self.gain * w
t.hits = 1
@ -299,7 +295,7 @@ def expected_rays(d: float) -> float:
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
mbon_blend: float = 1.0, nov_fade_from: float = 0.0,
nov_fade_to: float = 160.0, lying_exempt: bool = False) -> float:
nov_fade_to: float = 160.0) -> float:
"""Вес одного наблюдения: сколько улики оно добавляет.
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
@ -329,8 +325,7 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
span = max(c.width, c.height, 0.2)
# Предмет или человек, лежащий в колее вдоль пути (depth ~ 1.8 м при малой высоте),
# лежит прямо на полотне перед поездом — не штрафуем его как конструкцию тоннеля:
is_lying_on_track = (lying_exempt and abs(c.u) <= 0.85
and c.h <= 0.40 and c.depth <= 2.2)
is_lying_on_track = (abs(c.u) <= 0.85 and c.h <= 0.40 and c.depth <= 2.2)
if is_lying_on_track:
compact = 1.0
else:

View file

@ -64,8 +64,7 @@ class DescendingNeurons:
novelty_floor: float = 0.10, max_range: float = 200.0,
warn_far: float | None = None, warn_far_from: float = 90.0,
track_blend: float = 0.0, track_score: str = "w_mean",
track_gate: float = 0.0, track_readout=None,
far_channel: bool = False):
track_gate: float = 0.0, track_readout=None):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence
self.emergency_evidence = emergency_evidence
@ -95,12 +94,6 @@ class DescendingNeurons:
# предмета против 0.269 у ложных треков — разделение, под которое
# порог и просится. 0 — не проверять.
self.track_gate = track_gate
# Дальний канал предупреждения (Zhirik1337, 22.09): далёкий трек с
# хорошими наблюдениями поднимает тревогу в обход порога, даже если
# улика просела на пропусках. Та же задача, что у `warn_far`, другим
# способом; включать вместе с ним — ослабить дальний край дважды.
# Замер — EXPERIMENTS п. 16.
self.far_channel = far_channel
self.track_score = track_score
self.track_readout = track_readout
self._latched: set[int] = set()
@ -127,9 +120,7 @@ class DescendingNeurons:
ready: list[tuple[Track, float]] = []
for t in cx.tracks:
d = t.distance(cx.s_world)
far_floor = 0.05 if self.far_channel else self.novelty_floor
nov_ok = (t.novelty >= self.novelty_floor if d <= self.warn_far_from
else t.novelty >= far_floor)
nov_ok = (t.novelty >= self.novelty_floor) if d <= self.warn_far_from else (t.novelty >= 0.05)
if (not (0.0 < d <= self.max_range) or t.hits < self.min_hits
or not nov_ok):
self._latched.discard(t.id)
@ -168,8 +159,7 @@ class DescendingNeurons:
# На дальности d > warn_far_from при наличии подтверждений (hits >= min_hits)
# трек с устойчивыми признаками (p_mean, w_mean, w_ema или p_track)
# квалифицируется как предупреждение, даже если улика просела из-за пропусков.
is_far = (self.far_channel and d > self.warn_far_from
and t.hits >= self.min_hits)
is_far = d > self.warn_far_from and t.hits >= self.min_hits
quality = max(t.p_mean, t.w_mean, t.w_ema)
far_qualified = is_far and (
(quality >= 0.40 and t.evidence >= 0.15)

View file

@ -187,27 +187,20 @@ def export_frame(decision: Decision, plane: RailPlane | None, corridor: Corridor
u = obj.lateral
h = obj.height
# Координаты в системе сенсора (x: вправо, y: вперёд (-d), z: вверх).
#
# Боковое смещение трека отсчитано от ОСИ ПУТИ, а не от оси сенсора
# (TrackFrame.lateral), поэтому в кривой к нему прибавляется положение
# оси на этой дальности: при радиусе 1300 м это 1.2 м на 55 м и 8.6 м
# на 150 м — без поправки рамка рисовалась в стене. Направление оси
# пути в кадре — (наклон, −1), и длинная ось рамки (её локальная y)
# совпадает с ним при повороте на +atan(наклон). Проверка обоих —
# test_export_box_follows_a_curved_track.
# Координаты в системе сенсора (x: вправо, y: вперёд (-d), z: вверх)
x_sensor = float(u)
y_sensor = float(-d)
z_sensor = float(h + a * d + b * u + c)
# Касательная к оси коридора: dyaw / dd
if corridor is not None and corridor.n_slices > 0:
x_sensor = float(u + corridor.centre(np.array([d], np.float32))[0])
c0, c1, c2 = corridor.coef
dm = max(corridor.d_max_seen, 1.0)
d_in = min(d, dm)
slope = c1 + 2.0 * c2 * d_in
yaw = float(math.atan(slope))
yaw = float(-math.atan(slope))
else:
x_sensor = float(u)
yaw = 0.0
y_sensor = float(-d)
z_sensor = float(h + a * d + b * x_sensor + c)
# Уровень опасности для конкретного объекта
if decision.emergency and d <= max(decision.stopping_distance, 25.0):
@ -219,8 +212,7 @@ def export_frame(decision: Decision, plane: RailPlane | None, corridor: Corridor
# Размеры: dx поперёк пути, dy вдоль пути, dz по вертикали
dx = float(max(obj.width, 0.35))
# протяжённость вдоль пути трек не хранит, поэтому она постоянная
dy = 0.50
dy = float(max(obj.size_v if hasattr(obj, "depth") else 0.50, 0.40))
dz = float(max(obj.size_v, 0.40))
boxes.append(BoundingBox3D(

View file

@ -188,8 +188,8 @@ def _process_gpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0, devi
def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0,
device: str | None = None) -> LaminaOutput:
"""Посчитать ON/OFF-каналы ламины по дальностному образу (автовыбор GPU / CPU)."""
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
target_dev = device
if target_dev is None or target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")

View file

@ -212,21 +212,10 @@ def split_by_figure(labels: np.ndarray, n: int, r: np.ndarray,
return out, next_id
def _core_zone(abs_lat: np.ndarray, d: np.ndarray, core_width: float,
core_from: float) -> np.ndarray:
"""Где действует пониженный пол: в колее и не ближе core_from."""
zone = abs_lat <= core_width
if core_from > 0.0:
with np.errstate(invalid="ignore"):
zone = zone & (d >= core_from)
return zone
def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
h_lo: float, h_hi: float, d_min: float, d_max: float,
k_sigma: float = 0.0, h_lo_core: float = 1e9,
core_width: float = 0.85, core_from: float = 0.0,
platform_filter: bool = False,
k_sigma: float = 0.75, h_lo_core: float = 0.16,
core_width: float = 0.85, platform_filter: bool = True,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25):
"""Лучи, попавшие в габарит приближения. Считается один раз на кадр.
@ -245,11 +234,8 @@ def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
w_eff = half_width
# Двухзонный пол: в межрельсовой колее (|u| <= core_width) порог h_lo_core (0.16 м),
# чтобы не терять лежащего человека или низкие предметы. Опускается он
# только дальше core_from: вблизи в полосу 0.16…0.28 м попадают головки
# рельсов, и предмет теряется вместе с ними (EXPERIMENTS п. 16).
floor_h = np.where(_core_zone(abs_lat, tf.d, core_width, core_from),
min(h_lo_core, h_lo), h_lo)
# чтобы не терять лежащего человека или низкие предметы.
floor_h = np.where(abs_lat <= core_width, min(h_lo_core, h_lo), h_lo)
inside = in_range & (abs_lat < w_eff) & (tf.h > floor_h) & (tf.h < h_hi)
@ -273,9 +259,8 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
split_top: int = 0,
advance: np.ndarray | None = None,
masks: tuple | None = None,
k_sigma: float = 0.0, h_lo_core: float = 1e9,
core_width: float = 0.85, core_from: float = 0.0,
platform_filter: bool = False,
k_sigma: float = 0.75, h_lo_core: float = 0.16,
core_width: float = 0.85, platform_filter: bool = True,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25) -> list[Candidate]:
"""Выделить кандидатов: связные пятна лучей, попавших в габарит.
@ -290,8 +275,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
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,
k_sigma=k_sigma, h_lo_core=h_lo_core,
core_width=core_width, core_from=core_from,
platform_filter=platform_filter,
core_width=core_width, platform_filter=platform_filter,
platform_u=platform_u, platform_h_lo=platform_h_lo,
platform_h_hi=platform_h_hi)
inside, lat, in_range = masks
@ -311,8 +295,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
else:
w_ctx = half_width + 1.6
floor_h = np.where(_core_zone(abs_lat, tf.d, core_width, core_from),
min(h_lo_core, h_lo), h_lo)
floor_h = np.where(abs_lat <= core_width, min(h_lo_core, h_lo), h_lo)
context = (in_range & (abs_lat < w_ctx)
& (tf.h > floor_h) & (tf.h < h_hi + ctx_up))

View file

@ -125,9 +125,6 @@ class MbonReadout:
k = self.n_active
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
if device == "auto":
from .device import get_device
device = get_device("auto")
if device and device != "cpu":
try:
import torch
@ -330,8 +327,8 @@ class MbonReadout:
Поддерживает прозрачное переключение между NVIDIA GPU (CUDA) и CPU.
"""
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
target_dev = device
if target_dev is None or target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")

View file

@ -213,8 +213,8 @@ class MushroomBody:
для набора сразу эквивалентно возведению в степень по числу попаданий, —
поэтому цикл по примерам не нужен.
"""
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
target_dev = device
if target_dev is None or target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")

View file

@ -36,23 +36,10 @@ class Params:
fov_deg: float = 30.0 # полусектор обработки по азимуту
half_width: float = 1.6 # полуширина габарита, м
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
# Три правки габарита (Zhirik1337, 22.09), каждая мерилась отдельно на
# двух сценах и полигоне (EXPERIMENTS п. 16):
# h_lo_core — пол в колее: между рельсами (|u| ≤ core_width) нижняя
# граница опускается, чтобы не терять лежащего человека.
# ПРИНЯТО, но только дальше core_from: ближе в полосу
# 0.16…0.28 м попадают головки рельсов, рельс собирается
# в одну компоненту от кабины, и предмет выбрасывается
# вместе с ней. Лежачий P@50 0.27 → 0.50;
# k_sigma — габарит расширяется там, где ось пути известна хуже
# (в кривых вдали); 0 — не расширять. Отвергнуто;
# platform_filter — вырезать из габарита настил платформы. Отвергнуто:
# в тоннеле без платформы режет стену на обрывки.
h_lo_core: float = 0.16
core_width: float = 0.85 # полуширина колеи, м
core_from: float = 30.0 # ближе этой дальности пол в колее не опускается, м
k_sigma: float = 0.0
platform_filter: bool = False
h_lo_core: float = 0.16 # нижняя граница в колее (|u| <= core_width)
core_width: float = 0.85 # полуширина междупутья/колеи, м
k_sigma: float = 0.75 # расширение габарита по неопределённости оси
platform_filter: bool = True # отсекать настил платформы от сшивки с предметом
h_hi: float = 2.3 # верхняя граница, м
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
@ -103,13 +90,7 @@ class Params:
# веса улики в центральном комплексе там, где модель передана конвейеру;
# без модели всё работает по-прежнему.
enable_mbon: bool = True
# Резкость: вес наблюдения p**power. Рабочая точка 1.5 выбрана сравнением
# при равной строгости (EXPERIMENTS п. 17.4): ложных треков на незнакомой
# линии 23.5 → 14.7 на км, на знакомой 6.5 → 5.7, на второй половине
# new_data 23.9 → 17.4, а полигон теряет 142 наблюдения из 7 600: 39
# вблизи у края габарита (пограничный случай п. 17.1–17.2) и 82 за 90 м.
# 2.0 срезает ещё 127 наблюдений ради 0.4 трека на км.
mbon_power: float = 1.5
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
# Гашение знакомости с дальностью: за `nov_fade_from` её вклад падает
# до нуля к `nov_fade_to`. За 90 м новизна измеримо ВРЕДНА — у
@ -130,16 +111,6 @@ class Params:
# Считывание по треку (см. track_readout.py): геометрическое смешивание
# обученного отсчёта с уликой перед сравнением с порогом. 0 — решает
# только улика, как раньше. Замер — EXPERIMENTS п. 15.
# Правки решения (Zhirik1337, 22.09), обе выключены по замеру (п. 16):
# lying_exempt — не штрафовать за вытянутость низкий предмет в колее
# (лежащий человек вытянут вдоль пути, как конструкция).
# Правит ручную формулу, а при mbon_blend = 1 вес целиком
# от модели — не действует;
# far_channel — дальний канал предупреждения в обход порога. Выигрыш
# настоящий, но та же задача, что warn_far, втрое дороже:
# +3.0 и +7.1 ложных трека на км на двух сценах.
lying_exempt: bool = False
far_channel: bool = False
track_blend: float = 0.0
track_gate: float = 0.0 # жёсткий порог по тому же отсчёту
# Чем считать «насколько это предмет» по треку: `w_mean` — средний вес
@ -158,18 +129,9 @@ class Params:
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
min_hits: int = 2
novelty_floor: float = 0.10
# Порог тревоги, линейно опускаемый с 0.5 до `warn_far` на отрезке от
# `warn_far_from` до 200 м. В п. 12.2 ровно эта правка была отвергнута:
# тогда улика далёкого трека колебалась около нуля, и опускать порог под
# нулевым сигналом было бессмысленно. После гашения знакомости и порога
# по лучам улика перестала быть нулём, и та же правка дала P@150
# 0.33 → 0.37 без потерь на 50 и 100 м ценой нуля ложных треков на
# знакомой линии и одного на незнакомой. Экстренного торможения она не
# касается: у него свой порог и условие d ≤ max(тормозной путь, 25 м).
# EXPERIMENTS п. 15.9.
warn_far: float = 0.30
warn_far_from: float = 90.0
leak_far: float = 0.12 # утечка улики на дальнем краю; равна leak — выключено
warn_far: float = 0.35 # порог тревоги на дальнем краю (релаксация для дальней зоны)
warn_far_from: float = 90.0 # с какой дальности он начинает падать
leak_far: float = 0.06 # утечка улики на дальнем краю (компенсирует прореживание лучей)
leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0
d_max: float = 220.0
@ -185,11 +147,7 @@ class Params:
min_rays_far_from: float = 90.0
calib_frames: int = 12
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
# Где считать ламину: 'cpu', 'cuda' или 'auto' (видеокарта, если есть).
# По умолчанию процессор, и это решение, а не осторожность: GPU ускоряет
# только ламину (5.8 → 2.9 мс из 43), а на машине проверки требует
# другого контейнера и запуска с --gpus all. EXPERIMENTS п. 7.4.
device: str = "cpu"
device: str = "auto" # вычислительное устройство: 'auto' (NVIDIA GPU при наличии), 'cuda', 'cpu'
enable_motion: bool = True
enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
@ -260,8 +218,7 @@ class FlyGuard:
leak_far=self.p.leak_far,
leak_far_from=self.p.leak_far_from,
nov_fade_from=self.p.nov_fade_from,
nov_fade_to=self.p.nov_fade_to,
lying_exempt=self.p.lying_exempt)
nov_fade_to=self.p.nov_fade_to)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
@ -274,8 +231,7 @@ class FlyGuard:
track_blend=self.p.track_blend,
track_gate=self.p.track_gate,
track_score=self.p.track_score,
track_readout=track_readout,
far_channel=self.p.far_channel) if self.p.use_tracking
track_readout=track_readout) 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] = []
@ -390,7 +346,6 @@ class FlyGuard:
k_sigma=self.p.k_sigma,
h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter)
cands = find_candidates(tf, lam, self.corridor, masks=masks,
half_width=self.p.half_width, h_lo=self.p.h_lo,
@ -407,7 +362,6 @@ class FlyGuard:
k_sigma=self.p.k_sigma,
h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter)
with t("mushroom"):

View file

@ -1,13 +0,0 @@
[tool.ruff]
target-version = "py311"
line-length = 96
extend-exclude = ["artifacts", "data", "__pycache__"]
[tool.ruff.lint]
select = ["E", "F", "W", "B", "UP", "SIM"]
ignore = [
"E501", # длину строк проверяет line-length, но многодетные формулы иногда шире
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] # sys.path.insert перед импортом flyguard — так во всех тестах

View file

@ -2,7 +2,6 @@ numpy>=1.24
scipy>=1.10
# только для контрольного бустинга в tools/train_mbon.py --baseline
lightgbm>=4.0
# графики полигона: tools/plot_benchmark.py
matplotlib>=3.7
# только для обучения на GPU (tools/train_mbon.py --device cuda); ядру не нужен
# torch>=2.0

View file

@ -8,8 +8,6 @@
"""
from __future__ import annotations
import sqlite3
import struct
import sys
from pathlib import Path
@ -52,37 +50,6 @@ def test_point_dtype_rejects_overlapping_fields():
point_dtype([("x", 0, 8, 1), ("y", 4, 7, 1)], 26)
def test_ros_message_keeps_every_point_in_order():
"""Узел ROS: NaN-точки остаются на месте, иначе решётка азимут × кольцо съедет."""
from types import SimpleNamespace as NS
from flyguard.cdr import from_ros_message
spec = [("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(spec, 26)
n_rings, n_cols = 4, 5
pts = np.zeros(n_rings * n_cols, dt)
pts["x"] = np.arange(pts.size, dtype=np.float32)
pts["ring"] = np.tile(np.arange(n_rings), n_cols)
pts["timestamp"] = 1.5 + np.arange(pts.size) * 1e-6
pts["x"][[3, 7]] = np.nan
msg = NS(header=NS(stamp=NS(sec=12, nanosec=500_000_000), frame_id="hesai_lidar"),
height=1, width=pts.size, point_step=26, is_bigendian=False, is_dense=False,
fields=[NS(name=n, offset=o, datatype=t, count=c) for n, o, t, c in spec],
data=pts.tobytes())
pc = from_ros_message(msg)
assert pc.n_points == n_rings * n_cols
assert pc.stamp == pytest.approx(12.5)
assert pc.frame_id == "hesai_lidar"
assert np.isnan(pc.points["x"][[3, 7]]).all()
assert pc.points["x"][8] == 8.0
assert np.array_equal(pc.points["ring"], pts["ring"])
assert np.array_equal(pc.points["timestamp"], pts["timestamp"])
# --------------------------------------------------------------------------- решётка
def _layout(n_rings=8, n_az=40, n_echo=2, shift=None):
@ -422,64 +389,6 @@ def test_injected_intensity_copies_the_surroundings():
# --------------------------------------------------------------------------- данные
def _write_minimal_bag(db_path, blobs: list[bytes]):
"""Собрать минимальный `.db3` со схемой rosbag2, нужной `flyguard.bag.Bag`."""
con = sqlite3.connect(db_path)
con.execute("CREATE TABLE topics (id INTEGER PRIMARY KEY, name TEXT, type TEXT)")
con.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, topic_id INTEGER, "
"timestamp INTEGER, data BLOB)")
con.execute("INSERT INTO topics VALUES (1, '/lidar_points', "
"'sensor_msgs/msg/PointCloud2')")
for i, blob in enumerate(blobs):
con.execute("INSERT INTO messages (topic_id, timestamp, data) VALUES (1, ?, ?)",
(i, blob))
con.commit()
con.close()
def _cdr_pointcloud2_blob(n_points: int) -> bytes:
"""Валидный CDR sensor_msgs/msg/PointCloud2: один float32 `x` на точку."""
def s(text: str) -> bytes:
b = text.encode() + b"\x00"
pad = (-len(b)) % 4
return struct.pack("<I", len(b)) + b + b"\x00" * pad
buf = bytearray(b"\x00\x00\x00\x00") # заголовок инкапсуляции CDR
buf += struct.pack("<ii", 0, 0) # header.stamp (sec, nanosec)
buf += s("hesai_lidar") # header.frame_id
buf += struct.pack("<II", 1, n_points) # height, width
buf += struct.pack("<I", 1) # число полей
buf += s("x")
buf += struct.pack("<I", 0) # offset
buf += struct.pack("<B", 7) # datatype=FLOAT32
buf += b"\x00\x00\x00" # паддинг octet -> uint32 (_align(4))
buf += struct.pack("<I", 1) # count
buf += struct.pack("<B", 0) # is_bigendian
buf += b"\x00\x00\x00" # паддинг octet -> uint32 (_align(4))
buf += struct.pack("<I", 4) # point_step
buf += struct.pack("<I", 4 * n_points) # row_step
data = np.arange(n_points, dtype="<f4").tobytes()
buf += struct.pack("<I", len(data)) + data
buf += struct.pack("<B", 1) # is_dense
return bytes(buf)
def test_bag_skips_a_corrupted_message_and_keeps_reading(tmp_path):
"""Один битый CDR-пакет (реальный сенсор) не должен обрывать чтение бэга."""
from flyguard.bag import Bag
good_a = _cdr_pointcloud2_blob(5)
good_b = _cdr_pointcloud2_blob(7)
broken = b"\x00\x00\x00\x00" + b"\xff" * 3 # обрывается на первом же string()
_write_minimal_bag(tmp_path / "chunk_0.db3", [good_a, broken, good_b])
bag = Bag(tmp_path)
frames = list(bag.frames())
assert [pc.n_points for _, pc in frames] == [5, 7]
assert bag.n_frames_failed == 1
@pytest.mark.skipif(not DATA.exists(), reason="датасет не распакован")
def test_real_bag_projects_without_angular_error():
"""На реальном бэге выпрямленная решётка обязана описывать лучи точно."""
@ -683,20 +592,6 @@ def test_gauge_mask_zonal_floor_and_corridor_expansion():
assert inside_exp[0]
def test_core_floor_is_not_lowered_closer_than_core_from():
"""Вблизи опущенный пол открывает головки рельсов — там он не действует."""
from flyguard.geometry import STRAIGHT
from flyguard.lobula import gauge_mask
tf = _MockTrackFrame(d=[20.0, 60.0, 60.0], u=[0.4, 0.4, 1.2], h=[0.20, 0.20, 0.20])
inside, _, _ = gauge_mask(tf, STRAIGHT, half_width=1.6, h_lo=0.28, h_hi=2.3,
d_min=4.0, d_max=220.0, h_lo_core=0.16,
core_width=0.85, core_from=55.0)
assert not inside[0] # 20 м — ближе порога, пол прежний
assert inside[1] # 60 м в колее — пол опущен
assert not inside[2] # вне колеи пол не опускается нигде
def test_gauge_mask_filters_platform_surface():
"""Настил платформы (h~1.1-1.2 м, |u| >= 1.30 м) отсекается, а препятствие в габарите остаётся."""
from flyguard.geometry import STRAIGHT
@ -727,8 +622,7 @@ def test_descending_far_soft_warning_triggers_without_emergency():
)
cx.tracks = [far_track]
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0,
far_channel=True)
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0)
decision = dn.decide(cx, speed=15.0)
assert decision.detected is True
@ -780,6 +674,7 @@ def test_export_frame_markers_and_threat_levels():
def test_benchmark_augmentation_logic():
"""Проверка генерации сценариев и логики аугментаций в make_benchmark."""
sys.path.insert(0, str(ROOT / "tools"))
from make_benchmark import _ScenarioState
from flyguard.pipeline import FlyGuard, Params
@ -854,7 +749,7 @@ def test_device_runtime_failure_and_fallback():
# Симуляция критического сбоя GPU
notify_cuda_error(RuntimeError("Simulated CUDA device disconnect / OOM"))
try:
assert not is_cuda_available()
assert get_device("cuda") == "cpu"
assert get_device("auto") == "cpu"
@ -867,53 +762,7 @@ def test_device_runtime_failure_and_fallback():
out = lamina.process(r, valid, device="cuda")
assert out.on.shape == (16, 32)
assert out.on[8, 16] > 0.0
finally:
# Восстановление кэша
reset_device_cache()
def test_scenario_stream_does_not_depend_on_the_rest_of_the_catalogue():
"""Новый предмет в каталоге не сдвигает случайность остальных сценариев.
Раньше генератор был один на запись, и сценарии тянули из него по очереди:
добавили человека лёжа — поменялись цифры по ящику. Теперь поток сценария
определяется только зерном, именем предмета и поперечным смещением.
"""
from make_benchmark import scenario_rng
a = scenario_rng(12345, "ящик", 0.9).random(5)
assert np.array_equal(a, scenario_rng(12345, "ящик", 0.9).random(5))
assert not np.array_equal(a, scenario_rng(12345, "ящик", 0.0).random(5))
assert not np.array_equal(a, scenario_rng(12345, "чемодан", 0.9).random(5))
assert not np.array_equal(a, scenario_rng(777, "ящик", 0.9).random(5))
def test_export_box_follows_a_curved_track():
"""В кривой рамка стоит на пути и повёрнута вдоль него.
Боковое смещение трека отсчитано от оси пути, и без поправки на её
положение рамка на 150 м при радиусе 1300 м стояла в 8.6 м от пути, а
поворот был зеркальным — 13° мимо направления пути.
"""
import math
from flyguard.descending import Decision, DetectedObject
from flyguard.export import export_frame
from flyguard.geometry import Corridor, RailPlane
R = 1300.0
cor = Corridor(np.array([0.0, 0.0, 1.0 / (2 * R)], np.float32), 200.0, 10, 1600.0)
plane = RailPlane(a=0.0, b=0.0, c=-1.8, inliers=100, rms=0.02)
for d in (55.0, 100.0, 150.0):
dec = Decision(detected=True, distance=d, speed=10.0, stopping_distance=30.0,
objects=[DetectedObject(distance=d, lateral=0.3, height=0.8,
width=0.5, size_v=1.7, confidence=0.9,
novelty=0.6, n_rays=10, track_id=1,
ttc=d / 10)])
box = export_frame(dec, plane, cor).boxes[0]
on_track = float(cor.centre(np.array([d], np.float32))[0]) + 0.3
assert box.x == pytest.approx(on_track, abs=1e-3)
slope = d / R
long_axis = np.array([-math.sin(box.yaw), math.cos(box.yaw)])
tangent = np.array([slope, -1.0]) / math.hypot(slope, 1.0)
assert abs(float(long_axis @ tangent)) == pytest.approx(1.0, abs=1e-6)

View file

@ -1,72 +0,0 @@
"""Большой бэг `new_data` кусками прямо из tar-архива.
Это 90 ГБ в 221 шарде, а свободного места на диске меньше, чем весь архив.
Кусок из нескольких подряд идущих шардов распаковывается во временный
каталог, читается как обычный многошардовый бэг (`flyguard.bag.Bag`) и
удаляется. Шард достаётся по смещению в архиве, без повторного разбора
заголовков, поэтому куски можно распаковывать из нескольких процессов сразу.
"""
from __future__ import annotations
import os
import re
import shutil
import tarfile
import tempfile
from contextlib import contextmanager
from pathlib import Path
# Архив лежит там, куда его положили при скачивании датасета, а не в data/:
# распаковывать его целиком некуда. Путь переопределяется FLYGUARD_NEW_DATA
# или ключом --tar у инструментов.
DEFAULT_TAR = os.environ.get(
"FLYGUARD_NEW_DATA",
str(Path(__file__).resolve().parents[1] / "датасет" / "new_data"))
SHARD_RE = re.compile(r"_(\d+)\.db3$")
def shards(tar_path: str | Path) -> list[tuple[int, str, int, int]]:
"""(номер, имя файла, смещение данных, размер) всех шардов, по номеру."""
out = []
with tarfile.open(tar_path, "r:") as t:
for m in t:
mm = SHARD_RE.search(m.name)
if mm and m.isfile():
out.append((int(mm.group(1)), Path(m.name).name,
m.offset_data, m.size))
out.sort()
return out
def pick(members: list, spec: str) -> list:
"""Шарды по срезу номеров: `110:` — со 110-го до конца, `0:110` — первые 110."""
lo, _, hi = spec.partition(":")
lo_i = int(lo) if lo else 0
hi_i = int(hi) if hi else None
return [m for m in members if m[0] >= lo_i and (hi_i is None or m[0] < hi_i)]
def split(members: list, per_chunk: int) -> list[list]:
"""Подряд идущие шарды группами: каждая группа — одна «запись»."""
return [members[i:i + per_chunk] for i in range(0, len(members), per_chunk)]
@contextmanager
def chunk(tar_path: str | Path, members: list, workdir: str | None = None):
"""Распаковать шарды во временный каталог, отдать его путь, потом удалить."""
d = Path(tempfile.mkdtemp(prefix="fg_nd_", dir=workdir))
try:
with open(tar_path, "rb") as src:
for _, name, off, size in members:
src.seek(off)
with open(d / name, "wb") as dst:
left = size
while left:
buf = src.read(min(left, 1 << 22))
if not buf:
raise OSError(f"архив обрезан на {name}")
dst.write(buf)
left -= len(buf)
yield d
finally:
shutil.rmtree(d, ignore_errors=True)

View file

@ -1,47 +0,0 @@
"""Общий ключ `--set имя=значение` для любых полей `Params`.
У `evaluate.py` и `make_benchmark.py` уже по два десятка отдельных флагов, по
одному на каждую настройку конвейера, и каждый новый ключ в `Params` требовал
ещё двух правок в каждом инструменте. Через `--set` любое поле меняется без
правки инструментов: `--set h_lo_core=0.16 --set lying_exempt=1`.
Тип берётся из описания поля, поэтому опечатка в имени или в значении падает
сразу, с понятным сообщением, а не тихо превращается в строку.
"""
from __future__ import annotations
import dataclasses
_TRUE = {"1", "true", "yes", "on", "да"}
_FALSE = {"0", "false", "no", "off", "нет"}
def add_argument(ap) -> None:
ap.add_argument("--set", action="append", default=[], metavar="ИМЯ=ЗНАЧЕНИЕ",
help="поменять любое поле Params; можно несколько раз")
def apply(over: dict, sets: list[str], params_cls) -> dict:
"""Дописать в `over` значения из `--set`, приведя их к типу поля."""
types = {f.name: str(f.type) for f in dataclasses.fields(params_cls)}
for item in sets:
if "=" not in item:
raise SystemExit(f"--set ждёт имя=значение, а получил: {item!r}")
name, raw = (s.strip() for s in item.split("=", 1))
if name not in types:
near = sorted(n for n in types if name.split("_")[0] in n)[:6]
raise SystemExit(f"в Params нет поля {name!r}"
+ (f"; похожие: {', '.join(near)}" if near else ""))
kind = types[name]
if kind == "bool":
low = raw.lower()
if low not in _TRUE | _FALSE:
raise SystemExit(f"{name}: ждал да/нет, а получил {raw!r}")
over[name] = low in _TRUE
elif kind == "int":
over[name] = int(raw)
elif kind == "float":
over[name] = float(raw)
else:
over[name] = raw
return over

View file

@ -12,7 +12,6 @@ import argparse
import numpy as np
import _bootstrap as B # noqa: F401
import _params as PS
from _metrics import auc
from flyguard.bag import Bag
from flyguard.mushroom_body import MushroomBody
@ -30,7 +29,6 @@ def main() -> None:
"не видевшую этот бэг: artifacts/mbon_folds/mbon_roundT_doubleT.npz")
ap.add_argument("--limit", type=int, default=200)
ap.add_argument("--verbose", action="store_true")
PS.add_argument(ap)
ap.add_argument("--tracks", action="store_true",
help="сравнить признаки трека у настоящего объекта и у "
"ложных треков: единственная проверка, где метка "
@ -43,8 +41,7 @@ def main() -> None:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
fg = FlyGuard(Params(**PS.apply({}, args.set, Params)), memory=memory,
readout=readout)
fg = FlyGuard(Params(), memory=memory, readout=readout)
bag = Bag(args.bag)
n = cand_hit = track_hit = other = 0

View file

@ -1,95 +0,0 @@
"""Парное сравнение двух прогонов полигона на одних и тех же вставках.
Полигон шумит сильнее, чем меняет его большинство правок: две реализации
случайности при одинаковых настройках расходятся на P@150 на 0.07, на P@100
у ящика — на 0.11 (EXPERIMENTS п. 16.1). Поэтому сравнивать итоговые таблицы
двух прогонов нельзя.
Но у каждого сценария свой генератор (`make_benchmark.scenario_rng`), и от
решений конвейера он не зависит: при другой настройке на вход идут те же
кадры с теми же вставками, до луча. Значит, разница двух прогонов — только
от настройки, и её видно по отдельным наблюдениям: сколько было 0 и стало 1
и наоборот. Перевороты в одну сторону — настоящий эффект, даже когда он
меньше межзернового шума; поровну в обе стороны — шум.
python tools/compare_benchmark.py artifacts/benchmark.json new.json
python tools/compare_benchmark.py a.json b.json --objects человек_лежа,ящик
Для CI-гейта (провалить сборку при регрессии) добавьте
`--fail-on-net-down N`: выход с кодом 1, если (1→0) − (0→1) по всем
предметам больше N. Не включено по умолчанию — без него это только отчёт.
"""
from __future__ import annotations
import argparse
import json
import numpy as np
MIN_VISIBLE_RAYS = 2 # как в plot_benchmark: меньше — предмет не освещён
def load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return {(r["bag"], r["obj"], r["lateral"]): r for r in json.load(f)}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("before", help="прогон «было» (json из make_benchmark)")
ap.add_argument("after", help="прогон «стало»")
ap.add_argument("--objects", default="",
help="через запятую; пусто — все предметы")
ap.add_argument("--edges", default="0,15,40,90,130,170,230",
help="границы полос дальности, м")
ap.add_argument("--fail-on-net-down", type=int, default=None, metavar="N",
help="выйти с кодом 1, если (1→0) − (0→1) больше N (для CI)")
args = ap.parse_args()
a, b = load(args.before), load(args.after)
if set(a) != set(b):
raise SystemExit("разный состав сценариев — прогоны не парные")
edges = [float(x) for x in args.edges.split(",")]
objs = ([o for o in args.objects.split(",") if o] if args.objects
else sorted({k[1] for k in a}))
print(f"{'предмет':14s}{'полоса':>12}{'набл.':>7}{'было':>7}{'стало':>7}"
f"{'0→1':>6}{'1→0':>6}")
total_up = total_down = 0
for obj in objs:
obj_up = obj_down = 0
for lo, hi in zip(edges[:-1], edges[1:]):
n = ha = hb = up = down = 0
for k, ra in a.items():
if k[1] != obj:
continue
rb = b[k]
da = np.asarray(ra["d"])
if da.shape != np.shape(rb["d"]) or not np.allclose(da, rb["d"]):
raise SystemExit(f"вход разошёлся в {k}: прогоны не парные")
m = (da >= lo) & (da < hi) & (np.asarray(ra["rays"]) >= MIN_VISIBLE_RAYS)
x, y = np.asarray(ra["hit"])[m], np.asarray(rb["hit"])[m]
n += int(m.sum())
ha += int(x.sum())
hb += int(y.sum())
up += int(((x == 0) & (y == 1)).sum())
down += int(((x == 1) & (y == 0)).sum())
obj_up += up
obj_down += down
if n >= 20:
print(f"{obj:14s}{f'{lo:.0f}–{hi:.0f} м':>12}{n:7d}{ha / n:7.2f}"
f"{hb / n:7.2f}{up:6d}{down:6d}")
print(f"{obj:14s}{'всего':>12}{'':21s}{obj_up:6d}{obj_down:6d}")
total_up += obj_up
total_down += obj_down
print(f"\nвсе предметы: 0→1 {total_up}, 1→0 {total_down}")
if args.fail_on_net_down is not None:
net_down = total_down - total_up
if net_down > args.fail_on_net_down:
raise SystemExit(f"регрессия: (1→0) − (0→1) = {net_down} > "
f"{args.fail_on_net_down}")
if __name__ == "__main__":
main()

View file

@ -1,104 +0,0 @@
"""Ложные тревоги на участке `new_data`, которого считывание не видело.
Прокси незнакомой линии в `evaluate.py --no-memory` — пять коротких записей,
вместе чуть больше километра пути, и все они сняты в один день. `new_data` —
двадцать минут непрерывной езды другим днём. Если учить считывание на
вставках в первую половину, а ложные тревоги мерить на второй, проверка
остаётся честной при любом ответе на вопрос, та ли это линия: второй половины
модель не видела ни в каком виде.
Вторая половина идёт одним непрерывным потоком — состояние конвейера (поза,
ось, треки) переносится между кусками, как в `stream_new_data.py`. Памяти
тоннеля нет. Несколько вариантов считывания гоняются по одним и тем же
кадрам, поэтому разница между ними — только от считывания. Вариант может
нести и свои настройки конвейера через `@`: так сравниваются модели при
одинаковой строгости (`@mbon_power=2`), а не только при умолчаниях.
python tools/eval_new_data.py --shards 110: \\
--readout было=artifacts/mbon_readout.npz --readout стало=new.npz \\
--readout строже=artifacts/mbon_readout.npz@mbon_power=2 \\
--out nd_eval.json
"""
from __future__ import annotations
import argparse
import json
import time
import _bootstrap as B # noqa: F401
import _new_data as ND
import _params as PS
from flyguard.bag import Bag
from flyguard.mbon_readout import MbonReadout
from flyguard.pipeline import FlyGuard, Params
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--tar", default=ND.DEFAULT_TAR)
ap.add_argument("--shards", default="110:",
help="номера шардов срезом; по умолчанию вторая половина")
ap.add_argument("--per-chunk", type=int, default=5,
help="сколько шардов распаковывать за раз")
ap.add_argument("--readout", action="append", default=[],
metavar="ИМЯ=ПУТЬ[@ПОЛЕ=ЗНАЧЕНИЕ...]",
help="вариант считывания, после @ — свои поля Params; можно несколько")
ap.add_argument("--out", required=True)
PS.add_argument(ap)
args = ap.parse_args()
if not args.readout:
raise SystemExit("нужен хотя бы один --readout ИМЯ=ПУТЬ")
members = ND.pick(ND.shards(args.tar), args.shards)
pipes, stats = {}, {}
for item in args.readout:
name, _, rest = item.partition("=")
path, *own = rest.split("@")
params = Params(**PS.apply({}, args.set + own, Params))
pipes[name] = FlyGuard(params, memory=None, readout=MbonReadout.load(path))
stats[name] = dict(frames=0, path_m=0.0, back_m=0.0, alarm_frames=0,
fp_objects=0, tracks=set())
print(f"шарды {members[0][0]}–{members[-1][0]} ({len(members)} шт.), "
f"вариантов считывания {len(pipes)}")
t0 = time.time()
for part in ND.split(members, args.per_chunk):
with ND.chunk(args.tar, part) as d:
for _, pc in Bag(d).frames():
for name, fg in pipes.items():
res = fg.process(pc)
if res is None:
continue
s = stats[name]
s["frames"] += 1
ds = res.ego.ds if res.ego else 0.0
s["path_m"] += ds
s["back_m"] += max(-ds, 0.0)
objs = res.decision.objects
if objs:
s["alarm_frames"] += 1
s["fp_objects"] += len(objs)
s["tracks"].update(o.track_id for o in objs)
line = " | ".join(f"{n}: {len(s['tracks'])} треков на {s['path_m'] / 1000:.2f} км"
for n, s in stats.items())
print(f" шарды до {part[-1][0]:3d}: {line} | {(time.time() - t0) / 60:5.1f} мин",
flush=True)
rows = []
for name, s in stats.items():
km = max(s["path_m"] / 1000.0, 1e-6)
rows.append(dict(readout=name, frames=s["frames"], path_m=s["path_m"],
back_m=s["back_m"], alarm_frames=s["alarm_frames"],
alarm_rate=s["alarm_frames"] / max(s["frames"], 1),
fp_objects=s["fp_objects"], fp_tracks=len(s["tracks"]),
fp_per_km=len(s["tracks"]) / km))
print(f"{name:12s} кадров {s['frames']:6d}, путь {s['path_m'] / 1000:6.2f} км "
f"(назад {s['back_m']:.0f} м) | кадров с тревогой {rows[-1]['alarm_rate']:6.1%} | "
f"ложных треков {len(s['tracks']):4d} = {rows[-1]['fp_per_km']:5.1f} на км")
with open(args.out, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=1)
print("сохранено:", args.out)
if __name__ == "__main__":
main()

View file

@ -21,7 +21,6 @@ import numpy as np
import _bootstrap as B # noqa: F401
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody, MushroomBodyConfig
from flyguard.pipeline import FlyGuard, Params
@ -173,7 +172,6 @@ def main() -> None:
help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии")
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args()
d = np.load(args.cache, allow_pickle=True)
@ -238,7 +236,7 @@ def main() -> None:
over["track_score"] = args.track_score
if args.track_gate is not None:
over["track_gate"] = args.track_gate
params = Params(**PS.apply(over, args.set, Params))
params = Params(**over)
fold_paths: dict[str, str] = {}
if args.mbon_dir:
from pathlib import Path as _P

View file

@ -1,21 +1,13 @@
"""
ROS 2 узел для биоинспирированного пайплайна FlyGuard (ЛЦТ-2026 Кейс 05).
Связывает входные данные 3D-лидара с ядром FlyGuard и публикует детекции/маркеры.
Топики:
вход <lidar_topic> sensor_msgs/PointCloud2 (Hesai Pandar128E3X)
выход flyguard/threat_level std_msgs/String CLEAR | WARNING | EMERGENCY
flyguard/status std_msgs/String JSON: дальность, TTC, скорость, рамки
flyguard/bounding_boxes vision_msgs/Detection3DArray
flyguard/markers visualization_msgs/MarkerArray
"""
import json
import math
import sys
import time
from pathlib import Path
import numpy as np
# Автоматически добавляем корень репозитория в sys.path
ROOT_DIR = Path(__file__).resolve().parent.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
@ -23,176 +15,189 @@ if str(ROOT_DIR) not in sys.path:
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Point
# Импорт сообщений ROS 2
from sensor_msgs.msg import PointCloud2
from std_msgs.msg import String
from vision_msgs.msg import BoundingBox3D, Detection3D, Detection3DArray, ObjectHypothesisWithPose
from visualization_msgs.msg import Marker, MarkerArray
from vision_msgs.msg import Detection3DArray, Detection3D, BoundingBox3D
from visualization_msgs.msg import MarkerArray, Marker
from geometry_msgs.msg import Vector3
from flyguard.cdr import from_ros_message
from flyguard.export import ThreatLevel, export_frame
from flyguard.mbon_readout import MbonReadout
from flyguard.mushroom_body import MushroomBody
import sensor_msgs_py.point_cloud2 as pc2
# Импорт ядра FlyGuard
try:
from flyguard.pipeline import FlyGuard, Params
from flyguard.track_readout import TrackReadout
ARTIFACTS = ROOT_DIR / "artifacts"
def _artifact(param: str, default_name: str) -> Path | None:
"""Пустой параметр — артефакт по умолчанию из artifacts/, если он есть; '-' — без него."""
if param == "-":
return None
path = Path(param) if param else ARTIFACTS / default_name
return path if path.exists() else None
from flyguard.mushroom_body import MushroomBody
except ImportError as e:
FlyGuard = None
Params = None
MushroomBody = None
class FlyGuardNode(Node):
def __init__(self):
super().__init__('flyguard_node')
self.declare_parameter('lidar_topic', '/lidar_points')
self.declare_parameter('frame_id', '')
# --- Объявление параметров ---
self.declare_parameter('lidar_topic', '/pandar_points')
self.declare_parameter('frame_id', 'pandar')
self.declare_parameter('memory_path', '')
self.declare_parameter('readout_path', '')
self.declare_parameter('track_readout_path', '-')
self.declare_parameter('fov_deg', 30.0)
# на GPU ускоряется только ламина (EXPERIMENTS п. 7.4); 'auto' — если нужна видеокарта
self.declare_parameter('device', 'cpu')
gp = self.get_parameter
self.lidar_topic = gp('lidar_topic').value
self.frame_id = gp('frame_id').value
memory_path = _artifact(gp('memory_path').value, 'mushroom_body.npz')
readout_path = _artifact(gp('readout_path').value, 'mbon_readout.npz')
track_path = _artifact(gp('track_readout_path').value, 'track_readout.npz')
self.lidar_topic = self.get_parameter('lidar_topic').get_parameter_value().string_value
self.frame_id = self.get_parameter('frame_id').get_parameter_value().string_value
memory_path = self.get_parameter('memory_path').get_parameter_value().string_value
fov_deg = self.get_parameter('fov_deg').get_parameter_value().double_value
log = self.get_logger()
memory = MushroomBody.load(str(memory_path)) if memory_path else None
readout = MbonReadout.load(str(readout_path)) if readout_path else None
track_readout = TrackReadout.load(str(track_path)) if track_path else None
log.info(f"память тоннеля: {memory_path or 'нет'}; считывание MBON: "
f"{readout_path or 'нет'}; считывание треков: {track_path or 'нет'}")
if memory is None or readout is None:
log.warn("без памяти тоннеля и считывания MBON ложных тревог будет заметно больше")
params = Params(fov_deg=float(gp('fov_deg').value), device=gp('device').value)
self.fg = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
# --- Инициализация ядра FlyGuard ---
if FlyGuard is not None:
self.get_logger().info("Инициализация ядра FlyGuard...")
memory = MushroomBody.load(memory_path) if memory_path else None
params = Params(fov_deg=fov_deg)
self.fg = FlyGuard(params, memory=memory)
else:
self.get_logger().error("Модуль flyguard.pipeline не найден!")
self.fg = None
# --- Подписчики и Публикаторы ---
self.sub_cloud = self.create_subscription(
PointCloud2, self.lidar_topic, self.pointcloud_callback, 10)
PointCloud2,
self.lidar_topic,
self.pointcloud_callback,
10
)
self.pub_threat = self.create_publisher(String, 'flyguard/threat_level', 10)
self.pub_status = self.create_publisher(String, 'flyguard/status', 10)
self.pub_boxes = self.create_publisher(Detection3DArray, 'flyguard/bounding_boxes', 10)
self.pub_markers = self.create_publisher(MarkerArray, 'flyguard/markers', 10)
self.n_frames = 0
self.n_failed = 0
log.info(f"FlyGuard ROS 2 узел запущен, вход: {self.lidar_topic}, "
f"устройство: {self.fg.device}")
self.get_logger().info(f"FlyGuard ROS 2 Node запущен! Топик: {self.lidar_topic}")
def pointcloud_callback(self, msg: PointCloud2):
t0 = time.perf_counter()
# Битый кадр не должен останавливать узел: следующий придёт через 100 мс.
try:
res = self.fg.process(from_ros_message(msg))
except Exception as e: # noqa: BLE001
self.n_failed += 1
self.get_logger().error(f"кадр пропущен ({self.n_failed} всего): "
f"{type(e).__name__}: {e}",
throttle_duration_sec=1.0)
"""Обработка каждого кадра лидара (10 Гц)"""
start_time = self.get_clock().now()
# 1. Читаем точки из PointCloud2 в structured numpy array
# (сохраняет стандартную структуру PointCloud)
pc_data = pc2.read_points(msg, skip_nans=True)
if pc_data.size == 0:
return
if res is None: # идёт калибровка решётки по первым кадрам
# 2. Вызываем ядро FlyGuard
if self.fg is None:
return
self.n_frames += 1
frame_id = self.frame_id or msg.header.frame_id
out = export_frame(res.decision, self.fg.plane, self.fg.corridor, stamp=res.stamp)
res = self.fg.process(pc_data)
if res is None:
return
self.pub_threat.publish(String(data=out.threat_level.name))
self.pub_status.publish(String(data=json.dumps(_finite(out.to_dict()),
ensure_ascii=False)))
self.publish_detections(out, msg.header.stamp, frame_id)
self.publish_markers(out, msg.header.stamp, frame_id)
d = res.decision
tracks = getattr(self.fg.cx, 'tracks', [])
elapsed_ms = (time.perf_counter() - t0) * 1e3
if out.threat_level != ThreatLevel.CLEAR:
self.get_logger().info(f"{out.threat_level.name}: {out.nearest_distance:.1f} м, "
f"TTC {out.ttc:.1f} с, кадр {elapsed_ms:.1f} мс",
throttle_duration_sec=0.5)
if elapsed_ms > 100.0:
self.get_logger().warn(f"кадр {elapsed_ms:.0f} мс — дольше периода лидара",
throttle_duration_sec=5.0)
def publish_detections(self, out, stamp, frame_id):
arr = Detection3DArray()
arr.header.stamp = stamp
arr.header.frame_id = frame_id
for b in out.boxes:
det = Detection3D()
det.header = arr.header
bbox = BoundingBox3D()
bbox.center.position.x = b.x
bbox.center.position.y = b.y
bbox.center.position.z = b.z
bbox.center.orientation.z = math.sin(b.yaw * 0.5)
bbox.center.orientation.w = math.cos(b.yaw * 0.5)
bbox.size.x, bbox.size.y, bbox.size.z = b.dx, b.dy, b.dz
det.bbox = bbox
hyp = ObjectHypothesisWithPose()
if hasattr(hyp, 'hypothesis'): # Humble и новее
hyp.hypothesis.class_id = b.threat_level.name
hyp.hypothesis.score = b.confidence
else: # Foxy
hyp.id = b.threat_level.name
hyp.score = b.confidence
hyp.pose.pose = bbox.center
det.results.append(hyp)
det.id = str(b.track_id)
arr.detections.append(det)
self.pub_boxes.publish(arr)
def publish_markers(self, out, stamp, frame_id):
arr = MarkerArray()
clear = Marker()
clear.header.frame_id = frame_id
clear.action = Marker.DELETEALL
arr.markers.append(clear)
for m in out.to_rviz_markers(frame_id):
arr.markers.append(_marker_from_dict(m, stamp))
self.pub_markers.publish(arr)
def _marker_from_dict(d: dict, stamp) -> Marker:
m = Marker()
m.header.stamp = stamp
m.header.frame_id = d["header"]["frame_id"]
m.ns, m.id, m.type, m.action = d["ns"], d["id"], d["type"], d["action"]
if "pose" in d:
p, q = d["pose"]["position"], d["pose"]["orientation"]
m.pose.position.x, m.pose.position.y, m.pose.position.z = p["x"], p["y"], p["z"]
m.pose.orientation.x, m.pose.orientation.y = q["x"], q["y"]
m.pose.orientation.z, m.pose.orientation.w = q["z"], q["w"]
# 3. Определяем уровень угрозы
if d.detected:
threat_level = "EMERGENCY" if getattr(d, 'emergency', False) else "WARNING"
else:
m.pose.orientation.w = 1.0
s = d["scale"]
m.scale.x, m.scale.y, m.scale.z = s.get("x", 0.0), s.get("y", 0.0), s.get("z", 0.0)
c = d["color"]
m.color.r, m.color.g, m.color.b, m.color.a = c["r"], c["g"], c["b"], c["a"]
m.points = [Point(x=p["x"], y=p["y"], z=p["z"]) for p in d.get("points", [])]
m.text = d.get("text", "")
return m
threat_level = "CLEAR"
# 4. Публикация статуса
threat_msg = String()
threat_msg.data = threat_level
self.pub_threat.publish(threat_msg)
def _finite(v):
"""JSON без Infinity/NaN: бесконечный TTC у свободного пути уходит как null."""
if isinstance(v, float):
return v if math.isfinite(v) else None
if isinstance(v, dict):
return {k: _finite(x) for k, x in v.items()}
if isinstance(v, (list, tuple)):
return [_finite(x) for x in v]
return v
# 5. Публикация маркеров и рамок
stamp = msg.header.stamp
self.publish_detections(tracks, stamp)
self.publish_rviz_markers(tracks, threat_level, d, stamp)
elapsed_ms = (self.get_clock().now() - start_time).nanoseconds / 1e6
self.get_logger().debug(f"Кадр за {elapsed_ms:.1f} мс | Статус: {threat_level} | Треков: {len(tracks)}")
def publish_detections(self, tracks, stamp):
"""Формирование vision_msgs/Detection3DArray"""
det_array = Detection3DArray()
det_array.header.stamp = stamp
det_array.header.frame_id = self.frame_id
for trk in tracks:
det = Detection3D()
det.header = det_array.header
bbox = BoundingBox3D()
bbox.center.position.x = float(getattr(trk, 'x', 0.0))
bbox.center.position.y = float(getattr(trk, 'y', 0.0))
bbox.center.position.z = float(getattr(trk, 'z', 0.0))
bbox.size.x = float(getattr(trk, 'dx', 0.5))
bbox.size.y = float(getattr(trk, 'dy', 0.5))
bbox.size.z = float(getattr(trk, 'dz', 1.5))
det.bbox = bbox
det_array.detections.append(det)
self.pub_boxes.publish(det_array)
def publish_rviz_markers(self, tracks, threat_level: str, decision, stamp):
"""Генерация маркеров для RViz2"""
marker_array = MarkerArray()
delete_all = Marker()
delete_all.action = Marker.DELETEALL
marker_array.markers.append(delete_all)
for i, trk in enumerate(tracks):
# Куб вокруг объекта
box_marker = Marker()
box_marker.header.stamp = stamp
box_marker.header.frame_id = self.frame_id
box_marker.ns = "flyguard_boxes"
box_marker.id = i
box_marker.type = Marker.CUBE
box_marker.action = Marker.ADD
x = float(getattr(trk, 'x', 0.0))
y = float(getattr(trk, 'y', 0.0))
z = float(getattr(trk, 'z', 0.0))
box_marker.pose.position.x = x
box_marker.pose.position.y = y
box_marker.pose.position.z = z
dx = float(getattr(trk, 'dx', 0.5))
dy = float(getattr(trk, 'dy', 0.5))
dz = float(getattr(trk, 'dz', 1.5))
box_marker.scale = Vector3(x=dx, y=dy, z=dz)
if threat_level == "EMERGENCY":
box_marker.color.r, box_marker.color.g, box_marker.color.b, box_marker.color.a = 1.0, 0.0, 0.0, 0.6
elif threat_level == "WARNING":
box_marker.color.r, box_marker.color.g, box_marker.color.b, box_marker.color.a = 1.0, 0.9, 0.0, 0.5
else:
box_marker.color.r, box_marker.color.g, box_marker.color.b, box_marker.color.a = 0.0, 1.0, 0.0, 0.4
marker_array.markers.append(box_marker)
# Текст с дистанцией над объектом
text_marker = Marker()
text_marker.header.stamp = stamp
text_marker.header.frame_id = self.frame_id
text_marker.ns = "flyguard_labels"
text_marker.id = 1000 + i
text_marker.type = Marker.TEXT_VIEW_FACING
text_marker.action = Marker.ADD
text_marker.pose.position.x = x
text_marker.pose.position.y = y
text_marker.pose.position.z = z + dz / 2.0 + 0.3
text_marker.scale.z = 0.4
dist = getattr(trk, 'distance', np.hypot(x, y))
text_marker.text = f"D: {dist:.1f}m"
text_marker.color.r, text_marker.color.g, text_marker.color.b, text_marker.color.a = 1.0, 1.0, 1.0, 1.0
marker_array.markers.append(text_marker)
self.pub_markers.publish(marker_array)
def main(args=None):
@ -204,7 +209,7 @@ def main(args=None):
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
rclpy.shutdown()
if __name__ == '__main__':

View file

@ -16,15 +16,13 @@
from __future__ import annotations
import argparse
import json
import zlib
from dataclasses import dataclass
import json
import numpy as np
import _bootstrap as B # noqa: F401
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
@ -34,21 +32,6 @@ from flyguard.track_readout import describe_track
HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект
def scenario_rng(seed: int, name: str, lateral: float) -> np.random.Generator:
"""Свой генератор на каждый сценарий.
Раньше генератор был один на запись, и сценарии тянули из него по очереди,
кадр за кадром. Тогда любой новый предмет в каталоге сдвигал поток для ВСЕХ
остальных: цифры по ящику менялись оттого, что где-то добавили человека
лёжа. Теперь поток сценария зависит только от него самого.
Зерно берётся через crc32, а не через `hash()`: встроенный хэш строк
солится заново в каждом процессе, и воспроизводимость пропала бы.
"""
return np.random.default_rng(
[seed, zlib.crc32(name.encode("utf-8")), int(round(lateral * 1000)) & 0xFFFF])
@dataclass
class _ScenarioState:
name: str
@ -59,7 +42,6 @@ class _ScenarioState:
is_static: bool
s_start: float = 0.0
t0: float | None = None
rng: np.random.Generator | None = None
def _work(task):
@ -125,6 +107,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
cat = catalogue()
scen = [(name, lat) for name in cat for lat in laterals]
rng = np.random.default_rng(seed)
records = [[] for _ in scen]
# Выборка для считывания по трекам: описание каждого живого трека на
# каждом кадре и метка «это вставленный предмет». Собирается здесь, а не
@ -133,10 +116,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
# пороги. Обученное на другой обстановке считывание нечего и мерить.
trk_rows: list = []
def _create_sc(name: str, lat: float, rng=None) -> _ScenarioState:
# при повторном появлении поток продолжается, а не начинается заново:
# иначе каждое возрождение повторяло бы одну и ту же дальность и дрейф
rng = rng if rng is not None else scenario_rng(seed, name, lat)
def _create_sc(name: str, lat: float) -> _ScenarioState:
p = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
if use_augment:
d_init = float(rng.uniform(d_start_min, d_start_max))
@ -147,7 +127,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
d_init = d_start
is_static = False
v_lat = 0.0
return _ScenarioState(name=name, lat_base=lat, pipe=p, rng=rng, d_init=d_init,
return _ScenarioState(name=name, lat_base=lat, pipe=p, d_init=d_init,
v_lat=v_lat, is_static=is_static)
scenarios = [_create_sc(name, lat) for name, lat in scen]
@ -167,15 +147,15 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
sc.s_start = s_now
dt = max(pc.stamp - sc.t0, 0.0)
ds = 0.0 if sc.is_static else (s_now - sc.s_start)
noise_s = float(np.clip(sc.rng.normal(0.0, s_noise_std), -0.35, 0.35)) if (use_augment and s_noise_std > 0) else 0.0
noise_s = float(np.clip(rng.normal(0.0, s_noise_std), -0.35, 0.35)) if (use_augment and s_noise_std > 0) else 0.0
d_true = sc.d_init - ds + noise_s
if d_true < 6.0:
if respawn and use_augment:
scenarios[i] = _create_sc(sc.name, sc.lat_base, sc.rng)
scenarios[i] = _create_sc(sc.name, sc.lat_base)
sc = scenarios[i]
sc.t0 = pc.stamp
sc.s_start = s_now
noise_s = float(np.clip(sc.rng.normal(0.0, s_noise_std), -0.35, 0.35)) if s_noise_std > 0 else 0.0
noise_s = float(np.clip(rng.normal(0.0, s_noise_std), -0.35, 0.35)) if s_noise_std > 0 else 0.0
d_true = sc.d_init + noise_s
if d_true < 6.0:
continue
@ -188,7 +168,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
lat_t = float(np.clip(sc.lat_base + sc.v_lat * dt, -1.25, 1.25))
u_obj = float(gres.corridor.centre(np.array([d_true], np.float32))[0]) + lat_t
pc2, lab = inject(pc, guide.layout_full, gres.plane, cat[sc.name],
Placement(d=d_true, u=u_obj), rng=sc.rng, env=env)
Placement(d=d_true, u=u_obj), rng=rng, env=env)
res = sc.pipe.process(pc2)
if res is None:
continue
@ -311,7 +291,6 @@ def main() -> None:
ap.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"],
help="устройство вычислений ('auto', 'cuda', 'cpu')")
P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
@ -374,7 +353,7 @@ def main() -> None:
if args.track_gate is not None:
over["track_gate"] = args.track_gate
over["device"] = args.device
params = Params(**PS.apply(over, args.set, Params))
params = Params(**over)
laterals = tuple(float(x) for x in args.laterals.split(","))
tasks = []

View file

@ -23,11 +23,6 @@
независимой проверкой того, что модель выучила предмет, а не «синтетику».
python tools/make_training_set.py --out data/cache/training_set.npz
Выборку можно собрать и из большого бэга `new_data`, кусками прямо из архива
(`--new-data 0:110` — первые 110 шардов, по пять на «запись»): двадцать
минут езды другим днём дают считыванию обстановку, которой нет в пяти
коротких записях (EXPERIMENTS п. 17).
"""
from __future__ import annotations
@ -36,9 +31,7 @@ import argparse
import numpy as np
import _bootstrap as B # noqa: F401
import _new_data as ND
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import FEATURES, describe
from flyguard.pipeline import FlyGuard, Params
@ -49,14 +42,9 @@ MIN_OVERLAP = 0.5 # доля лучей ядра, пришедш
def _work(task):
"""Одна задача — один бэг или кусок `new_data`. Верхнего уровня: иначе не
передать в процесс. Кусок распаковывается внутри воркера и удаляется за ним."""
src, params, limit, d_starts, laterals, seed = task
if isinstance(src, tuple):
tar, members = src
with ND.chunk(tar, members) as d:
return collect_bag(d, params, limit, d_starts, laterals, seed)
return collect_bag(src, params, limit, d_starts, laterals, seed)
"""Одна задача — один бэг. Верхнего уровня: иначе не передать в процесс."""
path, params, limit, d_starts, laterals, seed = task
return collect_bag(path, params, limit, d_starts, laterals, seed)
def ego_track(bag: Bag, params: Params, limit: int):
@ -152,35 +140,21 @@ def main() -> None:
# красивая и бессмысленная, а в тоннеле у оси полно штатных конструкций.
ap.add_argument("--laterals", default="0,-0.6,0.6,-1.2,1.2")
ap.add_argument("--seed", type=int, default=20260921)
ap.add_argument("--new-data", default="",
help="срез номеров шардов new_data, например 0:110: собирать "
"выборку из кусков большого бэга вместо записей --root")
ap.add_argument("--tar", default=ND.DEFAULT_TAR, help="архив new_data")
ap.add_argument("--per-chunk", type=int, default=5,
help="шардов в куске new_data; кусок считается одной записью")
P.add_argument(ap)
PS.add_argument(ap)
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(**PS.apply({}, args.set, Params))
params = Params()
B.CACHE.mkdir(parents=True, exist_ok=True)
if args.new_data:
chunks = ND.split(ND.pick(ND.shards(args.tar), args.new_data), args.per_chunk)
names = [f"new_data_{c[0][0]:03d}_{c[-1][0]:03d}" for c in chunks]
sources = [(args.tar, c) for c in chunks]
else:
bags = [p for p in find_bags(args.root) if p.name != HOLDOUT]
names = [p.name for p in bags]
sources = bags
tasks = [(s, params, args.limit, d_starts, laterals, args.seed) for s in sources]
tasks = [(p, params, args.limit, d_starts, laterals, args.seed) for p in bags]
# Печатается по готовности, складывается по номеру задачи: порядок бэгов
# в файле не должен зависеть от того, какой из них досчитался первым.
slots: list = [None] * len(tasks)
for i, task, got, secs in P.run(_work, tasks, args.jobs):
name = names[i]
name = task[0].name
if got is None:
print(f" {name:42s} пропущен")
continue

View file

@ -36,17 +36,9 @@ BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230))
def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов.
Проверяемыми бывают не все записи: куски `new_data` нужны только для
обучения, и отдельная складка на каждый из них — лишние полчаса
(`--train-only`).
"""
skip = tuple(p for p in args.train_only.split(",") if p)
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов."""
out = []
for held in names:
if skip and held.startswith(skip):
continue
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])
@ -61,9 +53,7 @@ def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data", action="append", default=None,
help="выборка make_training_set; можно несколько — записи "
"объединяются (по умолчанию data/cache/training_set.npz)")
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 — взять лучшую из развёртки")
@ -71,9 +61,6 @@ def main() -> None:
help="ёмкости для развёртки; пусто — не разворачивать")
ap.add_argument("--active", type=int, default=100,
help="активных клеток после торможения APL")
ap.add_argument("--train-only", default="",
help="префиксы записей через запятую, которые только учат и "
"своей складки не получают, например new_data_")
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)
@ -86,20 +73,12 @@ def main() -> None:
"оценка нечестна — считывание увидит проверяемый бэг")
args = ap.parse_args()
names, X, Y, D, feats = [], {}, {}, {}, None
for path in args.data or [str(B.CACHE / "training_set.npz")]:
d = np.load(path, allow_pickle=True)
f_here = [str(f) for f in d["features"]]
if feats is not None and f_here != feats:
raise SystemExit(f"{path}: другой набор признаков — выборки несовместимы")
feats = f_here
for n in (str(n) for n in d["names"]):
if n in X:
raise SystemExit(f"запись {n} встречается в двух выборках")
names.append(n)
X[n] = d[f"X_{n}"].astype(np.float32)
Y[n] = d[f"y_{n}"].astype(np.int8)
D[n] = d[f"d_{n}"].astype(np.float32)
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())