Compare commits

...

4 commits

23 changed files with 956 additions and 158 deletions

1
.gitignore vendored
View file

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

View file

@ -66,8 +66,15 @@ else:
`emergency` (флаг экстренного торможения), `objects` (список подтверждённых `emergency` (флаг экстренного торможения), `objects` (список подтверждённых
треков с id, дистанцией и габаритами). треков с id, дистанцией и габаритами).
Поддерживается работа как на **CPU**, так и с аппаратным ускорением на **NVIDIA GPU (CUDA 12, Ada Lovelace RTX 4070 Ti Super 16GB, Ampere, Turing)** с прозрачным Graceful Fallback на CPU. Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 43 мс
Медиана обработки кадра: **4–6 мс на GPU** (DoG-фильтрация 0.25 мс) и ~33 мс на CPU при жестком лимите ТЗ 100 мс (запас в 16–25 раз). при бюджете 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/ ядро: стадии обработки, память, считывание flyguard/ ядро: стадии обработки, память, считывание
bag.py cdr.py чтение rosbag2 и разбор CDR без ROS bag.py cdr.py чтение rosbag2 и разбор CDR без ROS
device.py управление NVIDIA GPU / CUDA и fallback на CPU device.py выбор CPU / NVIDIA GPU и переход на CPU при сбое
retina.py geometry.py решётка лучей, плоскость рельсов, ось пути retina.py geometry.py решётка лучей, плоскость рельсов, ось пути
lamina.py medulla.py контраст (GPU 2D DoG), движение lamina.py medulla.py контраст (на CPU или GPU), движение
lobula.py кандидаты lobula.py кандидаты
mushroom_body.py память тоннеля (без меток, GPU learn) mushroom_body.py память тоннеля (без меток)
mbon_readout.py обученное считывание (GPU PyTorch/Platt) mbon_readout.py обученное считывание (с метками)
track_readout.py считывание по истории треков (GPU) track_readout.py считывание по истории трека — инструмент замера
fan_body.py накопление в координатах пути fan_body.py накопление в координатах пути
central_complex.py треки и улики central_complex.py треки и улики
descending.py решение descending.py решение
pipeline.py сборка (параметр device='auto') pipeline.py сборка
export.py генерация 3D BBox, TTC и маркеры RViz export.py 3D-рамки, время до столкновения, маркеры RViz
synth.py вставка предметов трассировкой лучей synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор, бенчмарк с аугментациями tools/ обучение, оценка, разбор, полигон с аугментациями
tests/ 40 тестов, запускаются без внешних зависимостей и без ROS tests/ 45 тестов, запускаются без данных и без ROS
docs/ методика и результаты docs/ методика и результаты
artifacts/ обученные модели 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 ```bash
pip install -r requirements.txt pip install -r requirements.txt
# Для GPU-ускорения (NVIDIA CUDA 12): pytest tests -q # или без pytest: python tests/run_tests.py
# pip install -r requirements-gpu.txt
python3 tests/run_tests.py
``` ```
Через Docker (подробно — [docs/DOCKER.md](docs/DOCKER.md)):
```bash
./docker-run.sh build
docker compose run --rm test # тесты
docker compose run --rm info # что видно из контейнера: CPU, GPU, CUDA
docker compose run --rm evaluate # ложные тревоги
docker compose run --rm benchmark # полигон
```
Для обучения на видеокарте — `pip install -r requirements-gpu.txt` и
`--device cuda` у `train_mbon.py`, `train_mushroom_body.py`, `evaluate.py`.
Записи лидара в репозиторий не кладутся. Положите их рядом Записи лидара в репозиторий не кладутся. Положите их рядом
(`../data/for_hackathon/...`) или укажите путь: (`../data/for_hackathon/...`) или укажите путь:
@ -148,8 +140,16 @@ python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тре
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \ python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
--mbon-dir artifacts/mbon_folds # дальность обнаружения --mbon-dir artifacts/mbon_folds # дальность обнаружения
python tools/plot_benchmark.py # кривые и график python tools/plot_benchmark.py # кривые и график
python tools/compare_benchmark.py было.json стало.json # правка парно
``` ```
Любое поле `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 с, кадрами, поэтому разрезать одну запись нельзя. Замерено: полигон 134 → 36 с,
@ -168,13 +168,14 @@ python tools/plot_benchmark.py # кривые и граф
| Метрика | Значение | Чем измерено | | Метрика | Значение | Чем измерено |
|---|---|---| |---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.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` | | Ложные тревоги, leave-one-bag-out | **8.0 трека на км**, 13.8 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 12.9 на км, 25.9 % кадров | там же, без `--mbon-dir` | | То же без обученного считывания | 13.6 на км, 28.1 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **20.3 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` | | На незнакомой линии (памяти нет) | **23.5 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
| Дальность (полигон, 14 004 наблюдения) | рабочая дальность **100 м**, P@50 = 0.70, **P@100 = 0.53, P@150 = 0.33** | `tools/plot_benchmark.py` | | Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность 80 м, P@50 = 0.71, P@100 = 0.48, **P@150 = 0.49** | `tools/plot_benchmark.py` |
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 32 м | там же |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` | | Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.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**: память обучается на всех записях, кроме Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и

File diff suppressed because one or more lines are too long

View file

@ -5,44 +5,44 @@
"path_m": 0.0, "path_m": 0.0,
"alarm_frames": 183, "alarm_frames": 183,
"alarm_rate": 0.9631578947368421, "alarm_rate": 0.9631578947368421,
"fp_objects": 518, "fp_objects": 522,
"fp_tracks": 4, "fp_tracks": 4,
"fp_per_km": NaN, "fp_per_km": NaN,
"fp_median_d": 106.83187139894102, "fp_median_d": 106.83192412328344,
"obj_rate": 0.9947368421052631, "obj_rate": 0.9947368421052631,
"ms_p50": 32.263549999697716, "ms_p50": NaN,
"ms_p95": 33.94519999383192, "ms_p95": NaN,
"train_size": 37073 "train_size": 36428
}, },
{ {
"bag": "doubleT_platform", "bag": "doubleT_platform",
"frames": 239, "frames": 239,
"path_m": 200.74905739412534, "path_m": 200.74905739412534,
"alarm_frames": 28, "alarm_frames": 48,
"alarm_rate": 0.11715481171548117, "alarm_rate": 0.200836820083682,
"fp_objects": 28, "fp_objects": 66,
"fp_tracks": 1, "fp_tracks": 2,
"fp_per_km": 4.981343439320496, "fp_per_km": 9.962686878640993,
"fp_median_d": 33.983066409463774, "fp_median_d": 32.8633359224923,
"obj_rate": null, "obj_rate": null,
"ms_p50": 34.84300000491203, "ms_p50": NaN,
"ms_p95": 38.588240001990926, "ms_p95": NaN,
"train_size": 36151 "train_size": 35491
}, },
{ {
"bag": "roundT_doubleT", "bag": "roundT_doubleT",
"frames": 239, "frames": 239,
"path_m": 200.9598713551979, "path_m": 200.9598713551979,
"alarm_frames": 3, "alarm_frames": 0,
"alarm_rate": 0.012552301255230125, "alarm_rate": 0.0,
"fp_objects": 3, "fp_objects": 0,
"fp_tracks": 1, "fp_tracks": 0,
"fp_per_km": 4.976117835149752, "fp_per_km": 0.0,
"fp_median_d": 20.107822042272232, "fp_median_d": NaN,
"obj_rate": null, "obj_rate": null,
"ms_p50": 34.14939999493072, "ms_p50": NaN,
"ms_p95": 40.86443000596773, "ms_p95": NaN,
"train_size": 36584 "train_size": 35861
}, },
{ {
"bag": "roundT_pressureGate_roundT", "bag": "roundT_pressureGate_roundT",
@ -53,40 +53,40 @@
"fp_objects": 25, "fp_objects": 25,
"fp_tracks": 2, "fp_tracks": 2,
"fp_per_km": 8.104919303770778, "fp_per_km": 8.104919303770778,
"fp_median_d": 160.95257704675265, "fp_median_d": 160.95291512943237,
"obj_rate": null, "obj_rate": null,
"ms_p50": 39.37539999969886, "ms_p50": NaN,
"ms_p95": 41.14257999899564, "ms_p95": NaN,
"train_size": 36718 "train_size": 36114
}, },
{ {
"bag": "roundT_squareT_pressureGate_squareT", "bag": "roundT_squareT_pressureGate_squareT",
"frames": 239, "frames": 239,
"path_m": 273.9439574444854, "path_m": 273.9439574444854,
"alarm_frames": 35, "alarm_frames": 6,
"alarm_rate": 0.14644351464435146, "alarm_rate": 0.02510460251046025,
"fp_objects": 35, "fp_objects": 6,
"fp_tracks": 4, "fp_tracks": 2,
"fp_per_km": 14.601526667404583, "fp_per_km": 7.3007633337022915,
"fp_median_d": 105.12193877726114, "fp_median_d": 161.27067444604893,
"obj_rate": null, "obj_rate": null,
"ms_p50": 40.1726000054623, "ms_p50": NaN,
"ms_p95": 42.31233000136854, "ms_p95": NaN,
"train_size": 36083 "train_size": 35654
}, },
{ {
"bag": "squareT_platform_squareT_switch", "bag": "squareT_platform_squareT_switch",
"frames": 239, "frames": 239,
"path_m": 270.98139848673645, "path_m": 270.98139848673645,
"alarm_frames": 40, "alarm_frames": 86,
"alarm_rate": 0.16736401673640167, "alarm_rate": 0.3598326359832636,
"fp_objects": 40, "fp_objects": 91,
"fp_tracks": 2, "fp_tracks": 4,
"fp_per_km": 7.380580405772363, "fp_per_km": 14.761160811544727,
"fp_median_d": 104.76858112973235, "fp_median_d": 96.62033146128638,
"obj_rate": null, "obj_rate": null,
"ms_p50": 37.06099999908474, "ms_p50": NaN,
"ms_p95": 39.86911000647524, "ms_p95": NaN,
"train_size": 33658 "train_size": 33494
} }
] ]

Binary file not shown.

View file

@ -165,6 +165,11 @@ LC11 у мухи — детектор мелкого объекта: возбу
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край оставшихся внутри габарита (`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)` и сравнивается с дистанцией до объекта. Тормозной путь считается как `v·t_реакции + v²/(2a)` и сравнивается с дистанцией до объекта.
--- ---
@ -279,6 +294,7 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
|---|---| |---|---|
| `half_width` | шире габарит → больше находок и больше ложных на кромках платформ | | `half_width` | шире габарит → больше находок и больше ложных на кромках платформ |
| `h_lo` | выше порог → перестают ловиться низкие предметы (кабель, камень), меньше ложных от полотна | | `h_lo` | выше порог → перестают ловиться низкие предметы (кабель, камень), меньше ложных от полотна |
| `h_lo_core` / `core_from` | пол между рельсами и дальность, с которой он опущен. Пол ниже → виден лежащий человек (0.30 м), но ближе 30 м в полосу 0.16…0.28 м попадают головки рельсов, и предмет выбрасывается вместе с рельсом (EXPERIMENTS п. 16.3) |
| `min_rays` | строже → пропадают дальние мелкие объекты, падает поток кандидатов | | `min_rays` | строже → пропадают дальние мелкие объекты, падает поток кандидатов |
| `fov_deg` | шире сектор → лучше кривые и стрелки, дороже обработка | | `fov_deg` | шире сектор → лучше кривые и стрелки, дороже обработка |
| `gain` / `leak` (CX) | быстрее подтверждение против устойчивости к шуму | | `gain` / `leak` (CX) | быстрее подтверждение против устойчивости к шуму |

View file

@ -368,6 +368,40 @@ numba являются ламина и оценка движения — вме
* Реалистичная планка для предмета размером с человека на прямом участке — **около * Реалистичная планка для предмета размером с человека на прямом участке — **около
200 м**, и путь к ней измерен выше: накопление плюс карта. 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. Что не сработало ## 8. Что не сработало
@ -1104,6 +1138,23 @@ reflectivity. Ламбертова модель описывала физику
20 000 клеток дают ту же AUC (0.9859 и 0.9850 против 0.9860), а в худшем кадре 20 000 клеток дают ту же AUC (0.9859 и 0.9850 против 0.9860), а в худшем кадре
стоят 2.8 и 7.3 мс вместо 1.5 — платить не за что. стоят 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, отдельная модель на каждую складку. Без этого Проверка — 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` всё ровно как средним весом наблюдения, `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. Выборка по трекам ### 15.2. Выборка по трекам
Полигон научили попутно выкладывать описание каждого живого трека на каждом Полигон научили попутно выкладывать описание каждого живого трека на каждом
@ -1941,3 +2019,405 @@ leave-one-bag-out.
(п. 12.5), для новизны (п. 12.3) и для утечки улики. Это возможно, но (п. 12.5), для новизны (п. 12.3) и для утечки улики. Это возможно, но
смешивание даёт тот же эффект мягко и без ещё одной калибровочной кривой, смешивание даёт тот же эффект мягко и без ещё одной калибровочной кривой,
поэтому порог оставлен выключенным (`track_gate = 0`). поэтому порог оставлен выключенным (`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` — это следующий шаг, если понадобится ещё
снизить ложные на знакомой линии.

View file

@ -147,8 +147,10 @@ class CentralComplex:
inhibition: float = 0.05, max_misses: int = 12, inhibition: float = 0.05, max_misses: int = 12,
max_tracks: int = 48, use_shape: bool = True, max_tracks: int = 48, use_shape: bool = True,
mbon_power: float = 1.0, mbon_blend: float = 1.0, 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.use_shape = use_shape
self.lying_exempt = lying_exempt
self.mbon_power = mbon_power self.mbon_power = mbon_power
self.mbon_blend = mbon_blend self.mbon_blend = mbon_blend
self.nov_fade_from = nov_fade_from self.nov_fade_from = nov_fade_from
@ -224,7 +226,8 @@ class CentralComplex:
t.hits += 1 t.hits += 1
t.misses = 0 t.misses = 0
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend, 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.observe(c, self.s_world + c.d, w)
t.evidence = min(1.0, t.evidence + self.gain * w) t.evidence = min(1.0, t.evidence + self.gain * w)
if dt > 1e-3: if dt > 1e-3:
@ -251,7 +254,8 @@ class CentralComplex:
width=c.width, height=c.height, first_d=c.d, last_d=c.d, width=c.width, height=c.height, first_d=c.d, last_d=c.d,
last_n_rays=c.n_rays, novelty=c.novelty) last_n_rays=c.n_rays, novelty=c.novelty)
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend, 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.observe(c, self.s_world + c.d, w)
t.evidence = self.gain * w t.evidence = self.gain * w
t.hits = 1 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, def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
mbon_blend: float = 1.0, nov_fade_from: float = 0.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) span = max(c.width, c.height, 0.2)
# Предмет или человек, лежащий в колее вдоль пути (depth ~ 1.8 м при малой высоте), # Предмет или человек, лежащий в колее вдоль пути (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: if is_lying_on_track:
compact = 1.0 compact = 1.0
else: else:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,10 +36,23 @@ class Params:
fov_deg: float = 30.0 # полусектор обработки по азимуту fov_deg: float = 30.0 # полусектор обработки по азимуту
half_width: float = 1.6 # полуширина габарита, м half_width: float = 1.6 # полуширина габарита, м
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
h_lo_core: float = 0.16 # нижняя граница в колее (|u| <= core_width) # Три правки габарита (Zhirik1337, 22.09), каждая мерилась отдельно на
core_width: float = 0.85 # полуширина междупутья/колеи, м # двух сценах и полигоне (EXPERIMENTS п. 16):
k_sigma: float = 0.75 # расширение габарита по неопределённости оси # h_lo_core — пол в колее: между рельсами (|u| ≤ core_width) нижняя
platform_filter: bool = True # отсекать настил платформы от сшивки с предметом # граница опускается, чтобы не терять лежащего человека.
# ПРИНЯТО, но только дальше 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 # верхняя граница, м h_hi: float = 2.3 # верхняя граница, м
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure): # Разделение фигуры и фона по движению (см. lobula.split_by_figure):
@ -111,6 +124,16 @@ class Params:
# Считывание по треку (см. track_readout.py): геометрическое смешивание # Считывание по треку (см. track_readout.py): геометрическое смешивание
# обученного отсчёта с уликой перед сравнением с порогом. 0 — решает # обученного отсчёта с уликой перед сравнением с порогом. 0 — решает
# только улика, как раньше. Замер — EXPERIMENTS п. 15. # только улика, как раньше. Замер — 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_blend: float = 0.0
track_gate: float = 0.0 # жёсткий порог по тому же отсчёту track_gate: float = 0.0 # жёсткий порог по тому же отсчёту
# Чем считать «насколько это предмет» по треку: `w_mean` — средний вес # Чем считать «насколько это предмет» по треку: `w_mean` — средний вес
@ -129,9 +152,18 @@ class Params:
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3. # (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
min_hits: int = 2 min_hits: int = 2
novelty_floor: float = 0.10 novelty_floor: float = 0.10
warn_far: float = 0.35 # порог тревоги на дальнем краю (релаксация для дальней зоны) # Порог тревоги, линейно опускаемый с 0.5 до `warn_far` на отрезке от
warn_far_from: float = 90.0 # с какой дальности он начинает падать # `warn_far_from` до 200 м. В п. 12.2 ровно эта правка была отвергнута:
leak_far: float = 0.06 # утечка улики на дальнем краю (компенсирует прореживание лучей) # тогда улика далёкого трека колебалась около нуля, и опускать порог под
# нулевым сигналом было бессмысленно. После гашения знакомости и порога
# по лучам улика перестала быть нулём, и та же правка дала 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 # с какой дальности она начинает падать leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0 d_min: float = 4.0
d_max: float = 220.0 d_max: float = 220.0
@ -147,7 +179,11 @@ class Params:
min_rays_far_from: float = 90.0 min_rays_far_from: float = 90.0
calib_frames: int = 12 calib_frames: int = 12
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу 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_motion: bool = True
enable_memory: bool = True enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
@ -218,7 +254,8 @@ class FlyGuard:
leak_far=self.p.leak_far, leak_far=self.p.leak_far,
leak_far_from=self.p.leak_far_from, leak_far_from=self.p.leak_far_from,
nov_fade_from=self.p.nov_fade_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( self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence, warn_evidence=self.p.warn_evidence,
@ -231,7 +268,8 @@ class FlyGuard:
track_blend=self.p.track_blend, track_blend=self.p.track_blend,
track_gate=self.p.track_gate, track_gate=self.p.track_gate,
track_score=self.p.track_score, 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, else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1)) emergency_evidence=0.0, min_hits=1))
self._calib: list[PointCloud2] = [] self._calib: list[PointCloud2] = []
@ -346,6 +384,7 @@ class FlyGuard:
k_sigma=self.p.k_sigma, k_sigma=self.p.k_sigma,
h_lo_core=self.p.h_lo_core, h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width, core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter) platform_filter=self.p.platform_filter)
cands = find_candidates(tf, lam, self.corridor, masks=masks, cands = find_candidates(tf, lam, self.corridor, masks=masks,
half_width=self.p.half_width, h_lo=self.p.h_lo, half_width=self.p.half_width, h_lo=self.p.h_lo,
@ -362,6 +401,7 @@ class FlyGuard:
k_sigma=self.p.k_sigma, k_sigma=self.p.k_sigma,
h_lo_core=self.p.h_lo_core, h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width, core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter) platform_filter=self.p.platform_filter)
with t("mushroom"): with t("mushroom"):

View file

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

View file

@ -592,6 +592,20 @@ def test_gauge_mask_zonal_floor_and_corridor_expansion():
assert inside_exp[0] 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(): def test_gauge_mask_filters_platform_surface():
"""Настил платформы (h~1.1-1.2 м, |u| >= 1.30 м) отсекается, а препятствие в габарите остаётся.""" """Настил платформы (h~1.1-1.2 м, |u| >= 1.30 м) отсекается, а препятствие в габарите остаётся."""
from flyguard.geometry import STRAIGHT from flyguard.geometry import STRAIGHT
@ -622,7 +636,8 @@ def test_descending_far_soft_warning_triggers_without_emergency():
) )
cx.tracks = [far_track] 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) decision = dn.decide(cx, speed=15.0)
assert decision.detected is True assert decision.detected is True
@ -674,7 +689,6 @@ def test_export_frame_markers_and_threat_levels():
def test_benchmark_augmentation_logic(): def test_benchmark_augmentation_logic():
"""Проверка генерации сценариев и логики аугментаций в make_benchmark.""" """Проверка генерации сценариев и логики аугментаций в make_benchmark."""
sys.path.insert(0, str(ROOT / "tools"))
from make_benchmark import _ScenarioState from make_benchmark import _ScenarioState
from flyguard.pipeline import FlyGuard, Params from flyguard.pipeline import FlyGuard, Params
@ -749,7 +763,7 @@ def test_device_runtime_failure_and_fallback():
# Симуляция критического сбоя GPU # Симуляция критического сбоя GPU
notify_cuda_error(RuntimeError("Simulated CUDA device disconnect / OOM")) notify_cuda_error(RuntimeError("Simulated CUDA device disconnect / OOM"))
try:
assert not is_cuda_available() assert not is_cuda_available()
assert get_device("cuda") == "cpu" assert get_device("cuda") == "cpu"
assert get_device("auto") == "cpu" assert get_device("auto") == "cpu"
@ -762,7 +776,53 @@ def test_device_runtime_failure_and_fallback():
out = lamina.process(r, valid, device="cuda") out = lamina.process(r, valid, device="cuda")
assert out.on.shape == (16, 32) assert out.on.shape == (16, 32)
assert out.on[8, 16] > 0.0 assert out.on[8, 16] > 0.0
finally:
# Восстановление кэша
reset_device_cache() 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)

47
tools/_params.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -16,13 +16,15 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from dataclasses import dataclass
import json import json
import zlib
from dataclasses import dataclass
import numpy as np import numpy as np
import _bootstrap as B # noqa: F401 import _bootstrap as B # noqa: F401
import _parallel as P import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params from flyguard.pipeline import FlyGuard, Params
@ -32,6 +34,21 @@ from flyguard.track_readout import describe_track
HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект 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 @dataclass
class _ScenarioState: class _ScenarioState:
name: str name: str
@ -42,6 +59,7 @@ class _ScenarioState:
is_static: bool is_static: bool
s_start: float = 0.0 s_start: float = 0.0
t0: float | None = None t0: float | None = None
rng: np.random.Generator | None = None
def _work(task): def _work(task):
@ -107,7 +125,6 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
cat = catalogue() cat = catalogue()
scen = [(name, lat) for name in cat for lat in laterals] scen = [(name, lat) for name in cat for lat in laterals]
rng = np.random.default_rng(seed)
records = [[] for _ in scen] records = [[] for _ in scen]
# Выборка для считывания по трекам: описание каждого живого трека на # Выборка для считывания по трекам: описание каждого живого трека на
# каждом кадре и метка «это вставленный предмет». Собирается здесь, а не # каждом кадре и метка «это вставленный предмет». Собирается здесь, а не
@ -116,7 +133,10 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
# пороги. Обученное на другой обстановке считывание нечего и мерить. # пороги. Обученное на другой обстановке считывание нечего и мерить.
trk_rows: list = [] 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) p = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
if use_augment: if use_augment:
d_init = float(rng.uniform(d_start_min, d_start_max)) 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 d_init = d_start
is_static = False is_static = False
v_lat = 0.0 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) v_lat=v_lat, is_static=is_static)
scenarios = [_create_sc(name, lat) for name, lat in scen] 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 sc.s_start = s_now
dt = max(pc.stamp - sc.t0, 0.0) dt = max(pc.stamp - sc.t0, 0.0)
ds = 0.0 if sc.is_static else (s_now - sc.s_start) 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 d_true = sc.d_init - ds + noise_s
if d_true < 6.0: if d_true < 6.0:
if respawn and use_augment: 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 = scenarios[i]
sc.t0 = pc.stamp sc.t0 = pc.stamp
sc.s_start = s_now 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 d_true = sc.d_init + noise_s
if d_true < 6.0: if d_true < 6.0:
continue 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)) 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 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], 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) res = sc.pipe.process(pc2)
if res is None: if res is None:
continue continue
@ -291,6 +311,7 @@ def main() -> None:
ap.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"], ap.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"],
help="устройство вычислений ('auto', 'cuda', 'cpu')") help="устройство вычислений ('auto', 'cuda', 'cpu')")
P.add_argument(ap) P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args() args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True) B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
@ -353,7 +374,7 @@ def main() -> None:
if args.track_gate is not None: if args.track_gate is not None:
over["track_gate"] = args.track_gate over["track_gate"] = args.track_gate
over["device"] = args.device 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(",")) laterals = tuple(float(x) for x in args.laterals.split(","))
tasks = [] tasks = []

View file

@ -32,6 +32,7 @@ import numpy as np
import _bootstrap as B # noqa: F401 import _bootstrap as B # noqa: F401
import _parallel as P import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import FEATURES, describe from flyguard.mushroom_body import FEATURES, describe
from flyguard.pipeline import FlyGuard, Params from flyguard.pipeline import FlyGuard, Params
@ -141,11 +142,12 @@ def main() -> None:
ap.add_argument("--laterals", default="0,-0.6,0.6,-1.2,1.2") 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("--seed", type=int, default=20260921)
P.add_argument(ap) P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args() args = ap.parse_args()
d_starts = tuple(float(x) for x in args.d_starts.split(",")) d_starts = tuple(float(x) for x in args.d_starts.split(","))
laterals = tuple(float(x) for x in args.laterals.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) B.CACHE.mkdir(parents=True, exist_ok=True)
bags = [p for p in find_bags(args.root) if p.name != HOLDOUT] bags = [p for p in find_bags(args.root) if p.name != HOLDOUT]