Compare commits

...

8 commits

Author SHA1 Message Date
200cb78348 ROS-узел, устойчивость к битому CDR, гейт регрессии полигона
Узел tools/flyguard_ros2_node.py (п. 16.7: отдавал массив вместо PointCloud2,
публиковал треки без решения) переписан: разбор через новый
flyguard.cdr.from_ros_message, публикация через flyguard.export.export_frame,
не падает от битого кадра, по умолчанию подключает artifacts/*.npz.

flyguard/bag.py: битый CDR-пакет в бэге пропускается с логом вместо обрыва
всего чтения (Bag.n_frames_failed). tools/compare_benchmark.py: флаг
--fail-on-net-down для CI-гейта регрессии по методологии парных переворотов
(EXPERIMENTS п. 16.1). Добавлен pyproject.toml (ruff, не прогнан — в
окружении нет ruff/pip). Тесты 46/46 (tests/test_pipeline.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 21:18:14 +03:00
Данил Омелечко
2051da2399 EXPERIMENTS п. 17.7: почему тормозил просмотр, прогон всех записей в контейнере 2026-09-24 17:00:15 +03:00
Данил Омелечко
f5f433b96c починил переносы в командах, раздельный запуск узла и проигрывания в EXPERIMENTS п. 17.6 2026-09-24 15:15:26 +03:00
Данил Омелечко
28fd716a82 резкость считывания 1.5: ложных на новой линии 23.5 -> 14.7 на км, память пересобрана на новом полу, new_data читается кусками из tar 2026-09-24 14:31:47 +03:00
Данил Омелечко
6964e4cc4e слил ветку Zhirik1337: его Docker, GPU-зависимости и ROS-узел; код детектора взят из выгрузки 2026-09-23 23:25:07 +03:00
Данил Омелечко
b4030df72b пол в колее 0.16 дальше 30 м, переобучил считывание; правки Zhirik1337 замерены, остальные выключены 2026-09-23 23:24:34 +03:00
Данил Омелечко
c895a72d9c развёртка ёмкости до 100 тыс. клеток и замер вывода на GPU 2026-09-23 17:03:37 +03:00
Данил Омелечко
6685673bdb дальний порог тревоги 0.3 за 90 м: P@150 0.33 -> 0.37; разбор решения по треку 2026-09-23 16:53:00 +03:00
31 changed files with 1875 additions and 335 deletions

1
.gitignore vendored
View file

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

107
README.md
View file

@ -66,8 +66,15 @@ else:
`emergency` (флаг экстренного торможения), `objects` (список подтверждённых
треков с id, дистанцией и габаритами).
Поддерживается работа как на **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 раз).
Всё считается на **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` на машине проверки.
---
@ -76,21 +83,21 @@ else:
```
flyguard/ ядро: стадии обработки, память, считывание
bag.py cdr.py чтение rosbag2 и разбор CDR без ROS
device.py управление NVIDIA GPU / CUDA и fallback на CPU
device.py выбор CPU / NVIDIA GPU и переход на CPU при сбое
retina.py geometry.py решётка лучей, плоскость рельсов, ось пути
lamina.py medulla.py контраст (GPU 2D DoG), движение
lamina.py medulla.py контраст (на CPU или GPU), движение
lobula.py кандидаты
mushroom_body.py память тоннеля (без меток, GPU learn)
mbon_readout.py обученное считывание (GPU PyTorch/Platt)
track_readout.py считывание по истории треков (GPU)
mushroom_body.py память тоннеля (без меток)
mbon_readout.py обученное считывание (с метками)
track_readout.py считывание по истории трека — инструмент замера
fan_body.py накопление в координатах пути
central_complex.py треки и улики
descending.py решение
pipeline.py сборка (параметр device='auto')
export.py генерация 3D BBox, TTC и маркеры RViz
pipeline.py сборка
export.py 3D-рамки, время до столкновения, маркеры RViz
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор, бенчмарк с аугментациями
tests/ 40 тестов, запускаются без внешних зависимостей и без ROS
tools/ обучение, оценка, разбор, полигон с аугментациями
tests/ 45 тестов, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```
@ -99,39 +106,24 @@ 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
# Для GPU-ускорения (NVIDIA CUDA 12):
# pip install -r requirements-gpu.txt
python3 tests/run_tests.py
pytest tests -q # или без pytest: python 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/...`) или укажите путь:
@ -148,8 +140,31 @@ 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 с,
@ -168,13 +183,15 @@ python tools/plot_benchmark.py # кривые и граф
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, 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` |
| Ложные тревоги, 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 м | там же |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.983** | `tools/train_mbon.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `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": 183,
"alarm_rate": 0.9631578947368421,
"fp_objects": 518,
"fp_tracks": 4,
"alarm_frames": 153,
"alarm_rate": 0.8052631578947368,
"fp_objects": 336,
"fp_tracks": 5,
"fp_per_km": NaN,
"fp_median_d": 106.83187139894102,
"fp_median_d": 114.4002904954952,
"obj_rate": 0.9947368421052631,
"ms_p50": 32.263549999697716,
"ms_p95": 33.94519999383192,
"train_size": 37073
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 48221
},
{
"bag": "doubleT_platform",
"frames": 239,
"path_m": 200.74905739412534,
"alarm_frames": 28,
"alarm_rate": 0.11715481171548117,
"fp_objects": 28,
"fp_tracks": 1,
"fp_per_km": 4.981343439320496,
"fp_median_d": 33.983066409463774,
"alarm_frames": 31,
"alarm_rate": 0.1297071129707113,
"fp_objects": 42,
"fp_tracks": 2,
"fp_per_km": 9.962686878640993,
"fp_median_d": 34.00916383990402,
"obj_rate": null,
"ms_p50": 34.84300000491203,
"ms_p95": 38.588240001990926,
"train_size": 36151
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 46850
},
{
"bag": "roundT_doubleT",
"frames": 239,
"path_m": 200.9598713551979,
"alarm_frames": 3,
"alarm_rate": 0.012552301255230125,
"fp_objects": 3,
"fp_tracks": 1,
"fp_per_km": 4.976117835149752,
"fp_median_d": 20.107822042272232,
"alarm_frames": 0,
"alarm_rate": 0.0,
"fp_objects": 0,
"fp_tracks": 0,
"fp_per_km": 0.0,
"fp_median_d": NaN,
"obj_rate": null,
"ms_p50": 34.14939999493072,
"ms_p95": 40.86443000596773,
"train_size": 36584
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 47438
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 25,
"alarm_rate": 0.10460251046025104,
"fp_objects": 25,
"fp_tracks": 2,
"fp_per_km": 8.104919303770778,
"fp_median_d": 160.95257704675265,
"alarm_frames": 18,
"alarm_rate": 0.07531380753138076,
"fp_objects": 18,
"fp_tracks": 1,
"fp_per_km": 4.052459651885389,
"fp_median_d": 160.20564869629789,
"obj_rate": null,
"ms_p50": 39.37539999969886,
"ms_p95": 41.14257999899564,
"train_size": 36718
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 47718
},
{
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 35,
"alarm_rate": 0.14644351464435146,
"fp_objects": 35,
"fp_tracks": 4,
"fp_per_km": 14.601526667404583,
"fp_median_d": 105.12193877726114,
"alarm_frames": 4,
"alarm_rate": 0.016736401673640166,
"fp_objects": 4,
"fp_tracks": 1,
"fp_per_km": 3.6503816668511457,
"fp_median_d": 162.18072351126122,
"obj_rate": null,
"ms_p50": 40.1726000054623,
"ms_p95": 42.31233000136854,
"train_size": 36083
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 46546
},
{
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 40,
"alarm_rate": 0.16736401673640167,
"fp_objects": 40,
"fp_tracks": 2,
"fp_per_km": 7.380580405772363,
"fp_median_d": 104.76858112973235,
"alarm_frames": 52,
"alarm_rate": 0.2175732217573222,
"fp_objects": 56,
"fp_tracks": 3,
"fp_per_km": 11.070870608658545,
"fp_median_d": 90.95581811929335,
"obj_rate": null,
"ms_p50": 37.06099999908474,
"ms_p95": 39.86911000647524,
"train_size": 33658
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 44039
}
]

Binary file not shown.

Binary file not shown.

View file

@ -165,6 +165,11 @@ LC11 у мухи — детектор мелкого объекта: возбу
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край
стены. Расширение идёт в стороны и вверх, но **не вниз**: полотно проходит под каждым
предметом и на большой дальности попало бы в тот же допуск по глубине.
* **пол габарита в колее ниже, но не везде.** Между рельсами (|u| ≤ 0.85 м) нижняя
граница 0.16 м вместо 0.28 — иначе упавший на пути человек высотой 0.30 м виден
верхушкой в два сантиметра. Ближе 30 м пол прежний: там в полосу 0.16…0.28 м попадают
головки рельсов, рельс собирается в одну связную компоненту от самой кабины, и предмет,
коснувшийся его, выбрасывается вместе с ним (EXPERIMENTS п. 16.3).
Для каждого кандидата считаются: дальность, смещение от оси, высота нижней точки, габариты,
протяжённость вдоль пути, целостность, число лучей и колец, контраст, дефицит до пола,
@ -269,6 +274,16 @@ $$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)` и сравнивается с дистанцией до объекта.
---
@ -279,10 +294,12 @@ $$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,6 +368,40 @@ 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. Что не сработало
@ -1104,6 +1138,23 @@ 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, отдельная модель на каждую складку. Без этого
сквозные цифры были бы ложными: считывание увидело бы проверяемый бэг ровно
так же, как память тоннеля, обученная на всём подряд.
@ -1398,6 +1449,10 @@ h +0.0055 lat +0.0044
могут пригодиться на линии с другой статистикой. Значения по умолчанию
выключают их полностью.
*Позднее:* `warn_far` перемерен на конфигурации после п. 12.3 и 12.5 и
**принят** — P@150 0.33 → 0.37 без потерь ближе. Почему отказ устарел —
п. 15.9.
Итоговая конфигурация и её цена:
| | без гашения | **принято** |
@ -1790,10 +1845,33 @@ 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. Выборка по трекам
Полигон научили попутно выкладывать описание каждого живого трека на каждом
@ -1941,3 +2019,757 @@ 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,6 +6,7 @@
"""
from __future__ import annotations
import logging
import re
import sqlite3
from contextlib import closing
@ -15,6 +16,8 @@ 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"
@ -44,6 +47,7 @@ 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]:
@ -83,9 +87,13 @@ class Bag:
"""Выдать (timestamp_ns, облако) для сообщений выбранного топика.
Индексация сквозная по всему бэгу; шарды читаются по порядку, внутри
шарда — по возрастанию времени.
шарда — по возрастанию времени. Сообщение, которое не удаётся разобрать
(битый CDR у реального сенсора), пропускается с предупреждением в лог,
а не останавливает чтение бэга — `start`/`stop` считают по позиции
сообщения в бэге, а не по числу успешно разобранных.
"""
idx = 0
self.n_frames_failed = 0
for shard in self.shards:
# closing(), а не сам connection: у sqlite3 `with` управляет
# транзакцией и файл остаётся открытым — на Windows его потом
@ -111,7 +119,15 @@ class Bag:
if stop is not None and idx >= stop:
return
if idx >= start and (idx - start) % stride == 0:
yield ts, parse_pointcloud2(blob)
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
idx += 1
def describe(self) -> str:

View file

@ -4,7 +4,8 @@
* офлайн-эксперименты на машине без ROS (Windows);
* прямое чтение rosbag внутри контейнера, минуя `ros2 bag play`.
Внутри ROS-ноды сообщение приходит уже разобранным, и этот модуль не используется.
Внутри ROS-ноды сообщение приходит уже разобранным; его переводит в тот же
`PointCloud2` функция `from_ros_message`.
"""
from __future__ import annotations
@ -128,3 +129,21 @@ 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,8 +147,10 @@ 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):
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0,
lying_exempt: bool = False):
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
@ -224,7 +226,8 @@ 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.nov_fade_from, self.nov_fade_to,
self.lying_exempt)
t.observe(c, self.s_world + c.d, w)
t.evidence = min(1.0, t.evidence + self.gain * w)
if dt > 1e-3:
@ -251,7 +254,8 @@ 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.nov_fade_from, self.nov_fade_to,
self.lying_exempt)
t.observe(c, self.s_world + c.d, w)
t.evidence = self.gain * w
t.hits = 1
@ -295,7 +299,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) -> float:
nov_fade_to: float = 160.0, lying_exempt: bool = False) -> float:
"""Вес одного наблюдения: сколько улики оно добавляет.
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
@ -325,7 +329,8 @@ 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 = (abs(c.u) <= 0.85 and c.h <= 0.40 and c.depth <= 2.2)
is_lying_on_track = (lying_exempt and 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,7 +64,8 @@ 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):
track_gate: float = 0.0, track_readout=None,
far_channel: bool = False):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence
self.emergency_evidence = emergency_evidence
@ -94,6 +95,12 @@ 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()
@ -120,7 +127,9 @@ class DescendingNeurons:
ready: list[tuple[Track, float]] = []
for t in cx.tracks:
d = t.distance(cx.s_world)
nov_ok = (t.novelty >= self.novelty_floor) if d <= self.warn_far_from else (t.novelty >= 0.05)
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)
if (not (0.0 < d <= self.max_range) or t.hits < self.min_hits
or not nov_ok):
self._latched.discard(t.id)
@ -159,7 +168,8 @@ class DescendingNeurons:
# На дальности d > warn_far_from при наличии подтверждений (hits >= min_hits)
# трек с устойчивыми признаками (p_mean, w_mean, w_ema или p_track)
# квалифицируется как предупреждение, даже если улика просела из-за пропусков.
is_far = d > self.warn_far_from and t.hits >= self.min_hits
is_far = (self.far_channel and 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,20 +187,27 @@ def export_frame(decision: Decision, plane: RailPlane | None, corridor: Corridor
u = obj.lateral
h = obj.height
# Координаты в системе сенсора (x: вправо, y: вперёд (-d), z: вверх)
x_sensor = float(u)
y_sensor = float(-d)
z_sensor = float(h + a * d + b * u + c)
# Касательная к оси коридора: dyaw / dd
# Координаты в системе сенсора (x: вправо, y: вперёд (-d), z: вверх).
#
# Боковое смещение трека отсчитано от ОСИ ПУТИ, а не от оси сенсора
# (TrackFrame.lateral), поэтому в кривой к нему прибавляется положение
# оси на этой дальности: при радиусе 1300 м это 1.2 м на 55 м и 8.6 м
# на 150 м — без поправки рамка рисовалась в стене. Направление оси
# пути в кадре — (наклон, −1), и длинная ось рамки (её локальная y)
# совпадает с ним при повороте на +atan(наклон). Проверка обоих —
# test_export_box_follows_a_curved_track.
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):
@ -212,7 +219,8 @@ def export_frame(decision: Decision, plane: RailPlane | None, corridor: Corridor
# Размеры: dx поперёк пути, dy вдоль пути, dz по вертикали
dx = float(max(obj.width, 0.35))
dy = float(max(obj.size_v if hasattr(obj, "depth") else 0.50, 0.40))
# протяжённость вдоль пути трек не хранит, поэтому она постоянная
dy = 0.50
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
if target_dev is None or target_dev == "auto":
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")

View file

@ -212,10 +212,21 @@ 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.75, h_lo_core: float = 0.16,
core_width: float = 0.85, platform_filter: bool = True,
k_sigma: float = 0.0, h_lo_core: float = 1e9,
core_width: float = 0.85, core_from: float = 0.0,
platform_filter: bool = False,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25):
"""Лучи, попавшие в габарит приближения. Считается один раз на кадр.
@ -234,8 +245,11 @@ def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
w_eff = half_width
# Двухзонный пол: в межрельсовой колее (|u| <= core_width) порог h_lo_core (0.16 м),
# чтобы не терять лежащего человека или низкие предметы.
floor_h = np.where(abs_lat <= core_width, min(h_lo_core, h_lo), h_lo)
# чтобы не терять лежащего человека или низкие предметы. Опускается он
# только дальше 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)
inside = in_range & (abs_lat < w_eff) & (tf.h > floor_h) & (tf.h < h_hi)
@ -259,8 +273,9 @@ 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.75, h_lo_core: float = 0.16,
core_width: float = 0.85, platform_filter: bool = True,
k_sigma: float = 0.0, h_lo_core: float = 1e9,
core_width: float = 0.85, core_from: float = 0.0,
platform_filter: bool = False,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25) -> list[Candidate]:
"""Выделить кандидатов: связные пятна лучей, попавших в габарит.
@ -275,7 +290,8 @@ 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, platform_filter=platform_filter,
core_width=core_width, core_from=core_from,
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
@ -295,7 +311,8 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
else:
w_ctx = half_width + 1.6
floor_h = np.where(abs_lat <= core_width, min(h_lo_core, h_lo), h_lo)
floor_h = np.where(_core_zone(abs_lat, tf.d, core_width, core_from),
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,6 +125,9 @@ 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
@ -327,8 +330,8 @@ class MbonReadout:
Поддерживает прозрачное переключение между NVIDIA GPU (CUDA) и CPU.
"""
target_dev = device
if target_dev is None or target_dev == "auto":
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")

View file

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

View file

@ -36,10 +36,23 @@ class Params:
fov_deg: float = 30.0 # полусектор обработки по азимуту
half_width: float = 1.6 # полуширина габарита, м
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
h_lo_core: float = 0.16 # нижняя граница в колее (|u| <= core_width)
core_width: float = 0.85 # полуширина междупутья/колеи, м
k_sigma: float = 0.75 # расширение габарита по неопределённости оси
platform_filter: bool = True # отсекать настил платформы от сшивки с предметом
# Три правки габарита (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_hi: float = 2.3 # верхняя граница, м
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
@ -90,7 +103,13 @@ class Params:
# веса улики в центральном комплексе там, где модель передана конвейеру;
# без модели всё работает по-прежнему.
enable_mbon: bool = True
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
# Резкость: вес наблюдения 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_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
# Гашение знакомости с дальностью: за `nov_fade_from` её вклад падает
# до нуля к `nov_fade_to`. За 90 м новизна измеримо ВРЕДНА — у
@ -111,6 +130,16 @@ 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` — средний вес
@ -129,9 +158,18 @@ class Params:
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
min_hits: int = 2
novelty_floor: float = 0.10
warn_far: float = 0.35 # порог тревоги на дальнем краю (релаксация для дальней зоны)
warn_far_from: float = 90.0 # с какой дальности он начинает падать
leak_far: float = 0.06 # утечка улики на дальнем краю (компенсирует прореживание лучей)
# Порог тревоги, линейно опускаемый с 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 — выключено
leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0
d_max: float = 220.0
@ -147,7 +185,11 @@ class Params:
min_rays_far_from: float = 90.0
calib_frames: int = 12
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
device: str = "auto" # вычислительное устройство: 'auto' (NVIDIA GPU при наличии), 'cuda', 'cpu'
# Где считать ламину: 'cpu', 'cuda' или 'auto' (видеокарта, если есть).
# По умолчанию процессор, и это решение, а не осторожность: GPU ускоряет
# только ламину (5.8 → 2.9 мс из 43), а на машине проверки требует
# другого контейнера и запуска с --gpus all. EXPERIMENTS п. 7.4.
device: str = "cpu"
enable_motion: bool = True
enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
@ -218,7 +260,8 @@ 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)
nov_fade_to=self.p.nov_fade_to,
lying_exempt=self.p.lying_exempt)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
@ -231,7 +274,8 @@ class FlyGuard:
track_blend=self.p.track_blend,
track_gate=self.p.track_gate,
track_score=self.p.track_score,
track_readout=track_readout) if self.p.use_tracking
track_readout=track_readout,
far_channel=self.p.far_channel) 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] = []
@ -346,6 +390,7 @@ 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,
@ -362,6 +407,7 @@ 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"):

13
pyproject.toml Normal file
View file

@ -0,0 +1,13 @@
[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,6 +2,7 @@ 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,6 +8,8 @@
"""
from __future__ import annotations
import sqlite3
import struct
import sys
from pathlib import Path
@ -50,6 +52,37 @@ 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):
@ -389,6 +422,64 @@ 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():
"""На реальном бэге выпрямленная решётка обязана описывать лучи точно."""
@ -592,6 +683,20 @@ 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
@ -622,7 +727,8 @@ 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)
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0,
far_channel=True)
decision = dn.decide(cx, speed=15.0)
assert decision.detected is True
@ -674,7 +780,6 @@ 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
@ -749,20 +854,66 @@ 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"
assert not is_cuda_available()
assert get_device("cuda") == "cpu"
assert get_device("auto") == "cpu"
r = np.full((16, 32), 20.0, dtype=np.float32)
r[8, 16] = 4.0
valid = np.ones((16, 32), dtype=bool)
r = np.full((16, 32), 20.0, dtype=np.float32)
r[8, 16] = 4.0
valid = np.ones((16, 32), dtype=bool)
# Даже при явном указании device="cuda", Lamina должна успешно отработать на CPU
out = lamina.process(r, valid, device="cuda")
assert out.on.shape == (16, 32)
assert out.on[8, 16] > 0.0
finally:
reset_device_cache()
# Даже при явном указании device="cuda", Lamina должна успешно отработать на CPU
out = lamina.process(r, valid, device="cuda")
assert out.on.shape == (16, 32)
assert out.on[8, 16] > 0.0
# Восстановление кэша
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)

72
tools/_new_data.py Normal file
View file

@ -0,0 +1,72 @@
"""Большой бэг `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)

47
tools/_params.py Normal file
View file

@ -0,0 +1,47 @@
"""Общий ключ `--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,6 +12,7 @@ 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
@ -29,6 +30,7 @@ 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="сравнить признаки трека у настоящего объекта и у "
"ложных треков: единственная проверка, где метка "
@ -41,7 +43,8 @@ def main() -> None:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
fg = FlyGuard(Params(), memory=memory, readout=readout)
fg = FlyGuard(Params(**PS.apply({}, args.set, Params)), memory=memory,
readout=readout)
bag = Bag(args.bag)
n = cand_hit = track_hit = other = 0

View file

@ -0,0 +1,95 @@
"""Парное сравнение двух прогонов полигона на одних и тех же вставках.
Полигон шумит сильнее, чем меняет его большинство правок: две реализации
случайности при одинаковых настройках расходятся на 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()

104
tools/eval_new_data.py Normal file
View file

@ -0,0 +1,104 @@
"""Ложные тревоги на участке `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,6 +21,7 @@ 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
@ -172,6 +173,7 @@ 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)
@ -236,7 +238,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(**over)
params = Params(**PS.apply(over, args.set, Params))
fold_paths: dict[str, str] = {}
if args.mbon_dir:
from pathlib import Path as _P

View file

@ -1,13 +1,21 @@
"""
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))
@ -15,189 +23,176 @@ if str(ROOT_DIR) not in sys.path:
import rclpy
from rclpy.node import Node
# Импорт сообщений ROS 2
from geometry_msgs.msg import Point
from sensor_msgs.msg import PointCloud2
from std_msgs.msg import String
from vision_msgs.msg import Detection3DArray, Detection3D, BoundingBox3D
from visualization_msgs.msg import MarkerArray, Marker
from geometry_msgs.msg import Vector3
from vision_msgs.msg import BoundingBox3D, Detection3D, Detection3DArray, ObjectHypothesisWithPose
from visualization_msgs.msg import Marker, MarkerArray
import sensor_msgs_py.point_cloud2 as pc2
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
from flyguard.pipeline import FlyGuard, Params
from flyguard.track_readout import TrackReadout
# Импорт ядра FlyGuard
try:
from flyguard.pipeline import FlyGuard, Params
from flyguard.mushroom_body import MushroomBody
except ImportError as e:
FlyGuard = None
Params = None
MushroomBody = None
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
class FlyGuardNode(Node):
def __init__(self):
super().__init__('flyguard_node')
# --- Объявление параметров ---
self.declare_parameter('lidar_topic', '/pandar_points')
self.declare_parameter('frame_id', 'pandar')
self.declare_parameter('lidar_topic', '/lidar_points')
self.declare_parameter('frame_id', '')
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')
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
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')
# --- Инициализация ядра 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
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)
# --- Подписчики и Публикаторы ---
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.get_logger().info(f"FlyGuard ROS 2 Node запущен! Топик: {self.lidar_topic}")
self.n_frames = 0
self.n_failed = 0
log.info(f"FlyGuard ROS 2 узел запущен, вход: {self.lidar_topic}, "
f"устройство: {self.fg.device}")
def pointcloud_callback(self, msg: PointCloud2):
"""Обработка каждого кадра лидара (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:
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)
return
# 2. Вызываем ядро FlyGuard
if self.fg is None:
if res is None: # идёт калибровка решётки по первым кадрам
return
self.n_frames += 1
res = self.fg.process(pc_data)
if res is None:
return
frame_id = self.frame_id or msg.header.frame_id
out = export_frame(res.decision, self.fg.plane, self.fg.corridor, stamp=res.stamp)
d = res.decision
tracks = getattr(self.fg.cx, 'tracks', [])
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)
# 3. Определяем уровень угрозы
if d.detected:
threat_level = "EMERGENCY" if getattr(d, 'emergency', False) else "WARNING"
else:
threat_level = "CLEAR"
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)
# 4. Публикация статуса
threat_msg = String()
threat_msg.data = threat_level
self.pub_threat.publish(threat_msg)
# 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:
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 = det_array.header
det.header = arr.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))
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
det_array.detections.append(det)
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)
self.pub_boxes.publish(det_array)
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 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)
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"]
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
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 _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
def main(args=None):
@ -209,7 +204,7 @@ def main(args=None):
pass
finally:
node.destroy_node()
rclpy.shutdown()
rclpy.try_shutdown()
if __name__ == '__main__':

View file

@ -16,13 +16,15 @@
from __future__ import annotations
import argparse
from dataclasses import dataclass
import json
import zlib
from dataclasses import dataclass
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
@ -32,6 +34,21 @@ 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
@ -42,6 +59,7 @@ class _ScenarioState:
is_static: bool
s_start: float = 0.0
t0: float | None = None
rng: np.random.Generator | None = None
def _work(task):
@ -107,7 +125,6 @@ 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]
# Выборка для считывания по трекам: описание каждого живого трека на
# каждом кадре и метка «это вставленный предмет». Собирается здесь, а не
@ -116,7 +133,10 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
# пороги. Обученное на другой обстановке считывание нечего и мерить.
trk_rows: list = []
def _create_sc(name: str, lat: float) -> _ScenarioState:
def _create_sc(name: str, lat: float, rng=None) -> _ScenarioState:
# при повторном появлении поток продолжается, а не начинается заново:
# иначе каждое возрождение повторяло бы одну и ту же дальность и дрейф
rng = rng if rng is not None else scenario_rng(seed, name, lat)
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))
@ -127,7 +147,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, d_init=d_init,
return _ScenarioState(name=name, lat_base=lat, pipe=p, rng=rng, d_init=d_init,
v_lat=v_lat, is_static=is_static)
scenarios = [_create_sc(name, lat) for name, lat in scen]
@ -147,15 +167,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(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(sc.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)
scenarios[i] = _create_sc(sc.name, sc.lat_base, sc.rng)
sc = scenarios[i]
sc.t0 = pc.stamp
sc.s_start = s_now
noise_s = float(np.clip(rng.normal(0.0, s_noise_std), -0.35, 0.35)) if s_noise_std > 0 else 0.0
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
d_true = sc.d_init + noise_s
if d_true < 6.0:
continue
@ -168,7 +188,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=rng, env=env)
Placement(d=d_true, u=u_obj), rng=sc.rng, env=env)
res = sc.pipe.process(pc2)
if res is None:
continue
@ -291,6 +311,7 @@ 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)
@ -353,7 +374,7 @@ def main() -> None:
if args.track_gate is not None:
over["track_gate"] = args.track_gate
over["device"] = args.device
params = Params(**over)
params = Params(**PS.apply(over, args.set, Params))
laterals = tuple(float(x) for x in args.laterals.split(","))
tasks = []

View file

@ -23,6 +23,11 @@
независимой проверкой того, что модель выучила предмет, а не «синтетику».
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
@ -31,7 +36,9 @@ 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
@ -42,9 +49,14 @@ MIN_OVERLAP = 0.5 # доля лучей ядра, пришедш
def _work(task):
"""Одна задача — один бэг. Верхнего уровня: иначе не передать в процесс."""
path, params, limit, d_starts, laterals, seed = task
return collect_bag(path, params, limit, d_starts, laterals, seed)
"""Одна задача — один бэг или кусок `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)
def ego_track(bag: Bag, params: Params, limit: int):
@ -140,21 +152,35 @@ 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()
params = Params(**PS.apply({}, args.set, Params))
B.CACHE.mkdir(parents=True, exist_ok=True)
bags = [p for p in find_bags(args.root) if p.name != HOLDOUT]
tasks = [(p, params, args.limit, d_starts, laterals, args.seed) for p in bags]
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]
# Печатается по готовности, складывается по номеру задачи: порядок бэгов
# в файле не должен зависеть от того, какой из них досчитался первым.
slots: list = [None] * len(tasks)
for i, task, got, secs in P.run(_work, tasks, args.jobs):
name = task[0].name
name = names[i]
if got is None:
print(f" {name:42s} пропущен")
continue

View file

@ -36,9 +36,17 @@ 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])
@ -53,7 +61,9 @@ def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data", default=str(B.CACHE / "training_set.npz"))
ap.add_argument("--data", action="append", default=None,
help="выборка make_training_set; можно несколько — записи "
"объединяются (по умолчанию data/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 — взять лучшую из развёртки")
@ -61,6 +71,9 @@ 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)
@ -73,12 +86,20 @@ def main() -> None:
"оценка нечестна — считывание увидит проверяемый бэг")
args = ap.parse_args()
d = np.load(args.data, allow_pickle=True)
names = [str(n) for n in d["names"]]
X = {n: d[f"X_{n}"].astype(np.float32) for n in names}
Y = {n: d[f"y_{n}"].astype(np.int8) for n in names}
D = {n: d[f"d_{n}"].astype(np.float32) for n in names}
feats = [str(f) for f in d["features"]]
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)
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())