forked from Dan4ick/Lidar_Muxa
Compare commits
8 commits
22eaa97bef
...
200cb78348
| Author | SHA1 | Date | |
|---|---|---|---|
| 200cb78348 | |||
|
|
2051da2399 | ||
|
|
f5f433b96c | ||
|
|
28fd716a82 | ||
|
|
6964e4cc4e | ||
|
|
b4030df72b | ||
|
|
c895a72d9c | ||
|
|
6685673bdb |
31 changed files with 1875 additions and 335 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,4 +10,3 @@ docs/figures/*.mp4
|
|||
# Project memory & chat transfer
|
||||
PROJECT_MEMORY.md
|
||||
CHAT_MEMORY.md
|
||||
|
||||
|
|
|
|||
107
README.md
107
README.md
|
|
@ -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
|
|
@ -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.
|
|
@ -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 | выше → сильнее подавление знакомого, но риск заглушить и настоящее |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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) не выполним здесь; логика узла
|
||||
проверена только на заглушках.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
13
pyproject.toml
Normal 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 — так во всех тестах
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
72
tools/_new_data.py
Normal 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
47
tools/_params.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
95
tools/compare_benchmark.py
Normal file
95
tools/compare_benchmark.py
Normal 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
104
tools/eval_new_data.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,8 +204,8 @@ def main(args=None):
|
|||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
rclpy.try_shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue