Compare commits

..

No commits in common. "6964e4cc4e68f29445ad0bdc44330a268b62da8f" and "22eaa97bef9df8a19fb1ac8f01e9f547c6ee5437" have entirely different histories.

23 changed files with 158 additions and 956 deletions

1
.gitignore vendored
View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

Binary file not shown.

View file

@ -165,11 +165,6 @@ LC11 у мухи — детектор мелкого объекта: возбу
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край
стены. Расширение идёт в стороны и вверх, но **не вниз**: полотно проходит под каждым
предметом и на большой дальности попало бы в тот же допуск по глубине.
* **пол габарита в колее ниже, но не везде.** Между рельсами (|u| ≤ 0.85 м) нижняя
граница 0.16 м вместо 0.28 — иначе упавший на пути человек высотой 0.30 м виден
верхушкой в два сантиметра. Ближе 30 м пол прежний: там в полосу 0.16…0.28 м попадают
головки рельсов, рельс собирается в одну связную компоненту от самой кабины, и предмет,
коснувшийся его, выбрасывается вместе с ним (EXPERIMENTS п. 16.3).
Для каждого кандидата считаются: дальность, смещение от оси, высота нижней точки, габариты,
протяжённость вдоль пути, целостность, число лучей и колец, контраст, дефицит до пола,
@ -274,16 +269,6 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
и экстренное торможение по надёжному близкому объекту. Пороги с гистерезисом — без него трек
на границе даёт дребезг, а дребезжащая команда торможения хуже её отсутствия.
Порог предупреждения зависит от дальности: до 90 м он 0.5, дальше линейно сходит к 0.3 на
200 м. Далёкая улика физически слабее — четыре-восемь лучей вместо сотни, — а цена ошибки ниже:
до предмета ещё сотня метров и десятки кадров на подтверждение. Экстренный порог не снижается
никогда. Это подняло обнаружение на 150 м с 0.33 до 0.37 без потерь ближе (EXPERIMENTS п. 15.9).
Решение принимается по улике трека, а не по среднему качеству его наблюдений, хотя улика
насыщается: у всего, что видно дольше пары секунд, она равна единице. Проверено три способа
учесть среднее — обученное считывание по треку, жёсткий порог и смешивание, — и ни один не
дал размена лучше простого порога на незнакомой линии (EXPERIMENTS п. 15.5–15.8).
Тормозной путь считается как `v·t_реакции + v²/(2a)` и сравнивается с дистанцией до объекта.
---
@ -294,7 +279,6 @@ $$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) | быстрее подтверждение против устойчивости к шуму |

View file

@ -368,40 +368,6 @@ numba являются ламина и оценка движения — вме
* Реалистичная планка для предмета размером с человека на прямом участке — **около
200 м**, и путь к ней измерен выше: накопление плюс карта.
### 7.4. Стоит ли переносить вывод на GPU
На машине проверки RTX 4070 Ti Super, драйвер 580.173.02 (CUDA до 13.0), toolkit 12.9.
Рассогласование toolkit колёсам PyTorch не мешает — они несут свой CUDA runtime, — так
что технически перенос возможен. Вопрос в том, что он даёт. Время кадра по стадиям, один
процесс, свободная машина, 390 кадров трёх записей:
| стадия | мс | доля |
|---|---:|---:|
| lobula — кластеризация с учётом глубины | 15.3 | 35 % |
| ego — собственное движение | 6.4 | 15 % |
| lamina — центр-окружение на трёх масштабах | 6.0 | 14 % |
| retina — проекция облака на решётку | 5.3 | 12 % |
| corridor — ось пути | 4.5 | 10 % |
| stabilize | 4.0 | 9 % |
| память тоннеля, веерное тело, считывание MBON, треки, решение | 1.9 | 4 % |
| **всего, медиана / p95** | **43.1 / 49.0** | из 100 |
Под видеокарту годится только ламина — это фильтры по сетке 128 × 600. Остальные 80 %
времени — связные компоненты и подгонка геометрии к сотням точек: нерегулярная работа с
ветвлениями, которую GPU не ускоряет, а копирование туда-обратно только замедлит.
Ламину на CUDA проверили на 49 реальных кадрах (реализация сокомандника,
`F.avg_pool2d` вместо `uniform_filter`): 5.8 мс на процессоре, **2.9 мс** на RTX 5070 Ti
с копированием образа на карту и результата обратно. Совпадение не побитовое: ON-канал
расходится на 1·10⁻⁵, а в 60 пикселях из 3.76 млн (0.0016 %) выбирается другой масштаб —
при равных откликах решает порядок суммирования.
Итог: перенос сэкономил бы около 3 мс из 43 при бюджете 100, а взамен потребовал бы
другого контейнера (наш собран на `ros:humble-ros-base-jammy`, без CUDA), установленного
у жюри nvidia-container-toolkit и запуска с `--gpus all`. Отказ любого из трёх звеньев —
это не медленный кадр, а не запустившееся решение. **Вывод остаётся на CPU**, видеокарта
— для обучения.
---
## 8. Что не сработало
@ -1138,23 +1104,6 @@ reflectivity. Ламбертова модель описывала физику
20 000 клеток дают ту же AUC (0.9859 и 0.9850 против 0.9860), а в худшем кадре
стоят 2.8 и 7.3 мс вместо 1.5 — платить не за что.
Позднее развёртку продолжили до 100 000 клеток на полной выборке (214 958
кандидатов), leave-one-bag-out:
| клеток | AUC | обстановки при 95 % предметов |
|---|---:|---:|
| **4 000** | **0.9829** | **9.89 %** |
| 20 000 | 0.9815 | 10.85 % |
| 50 000 | 0.9794 | 11.73 % |
| 100 000 | 0.9791 | 11.79 % |
Больше клеток — **хуже**, и монотонно. Активных после торможения APL всегда сто,
поэтому с ростом популяции каждая клетка становится специфичнее, и код превращается
в справочник по обучающим записям, который на невиданной записи обобщает хуже. Узкое
место не в ёмкости, а во входе: бустинг по тем же 23 признакам даёт 0.9927, то есть
запас есть, но лежит в признаках. Для сравнения, у мухи около двух тысяч клеток
Кеньона на полушарие.
Проверка — leave-one-bag-out, отдельная модель на каждую складку. Без этого
сквозные цифры были бы ложными: считывание увидело бы проверяемый бэг ровно
так же, как память тоннеля, обученная на всём подряд.
@ -1449,10 +1398,6 @@ h +0.0055 lat +0.0044
могут пригодиться на линии с другой статистикой. Значения по умолчанию
выключают их полностью.
*Позднее:* `warn_far` перемерен на конфигурации после п. 12.3 и 12.5 и
**принят** — P@150 0.33 → 0.37 без потерь ближе. Почему отказ устарел —
п. 15.9.
Итоговая конфигурация и её цена:
| | без гашения | **принято** |
@ -1845,33 +1790,10 @@ P@150 0.32), а на всём, что меньше, — заметно хуже:
потолок. **Тот самый вес, из которого улика складывается, различает их
идеально.** Сумма потеряла то, что было в слагаемых.
**Где именно это бьёт — оговорка.** `doubleT_obstacle` записан со **стоящего**
поезда, и там наблюдений у каждого трека сотни, так что потолка достигают и
предмет, и обстановка. На полигоне поезд едет, ложный трек столько не живёт, и
картина другая: улика на потолке у 79 % наблюдений предмета в полосе 0…30 м и
лишь у 1.5 % ложных, на 80…110 м — 31 % против 3.4 %.
То есть насыщение само по себе ложных тревог не создаёт. Оно **обесценивает
улику как меру**: у предмета она перестаёт расти, и весь запас, который он
заработал качеством наблюдений, пропадает — вместо «этот трек вчетверо
убедительнее» остаётся «оба по единице». Хуже всего это там, где поезд стоит
или ползёт, то есть ровно в ситуации, когда препятствие опаснее всего.
Отсюда и решение: сравнивать с порогом не улику, а её геометрическую смесь со
средним весом наблюдения, `w_mean^b · улика^(1−b)`. При `b = 0` всё ровно как
раньше, так что размен меряется, а не объявляется.
Что смешивание делает с отсчётом, посчитано прямо по выборке. При `b = 0.3`
через порог 0.5 уходят вниз 180 предметных наблюдений и 668 ложных — на одно
потерянное обнаружение три и семь десятых убранных ложных. При `b = 0.2`
соотношение лучше: 73 против 348, то есть 4.8. При `b = 0.5` оно рушится до
1.3, и это то же колено, что видно в таблице п. 15.6.
Смешивание при этом **не только давит**: у 21 % наблюдений отсчёт растёт — это
молодые треки с хорошими наблюдениями, у которых улика ещё мала. Среди ложных
таких даже больше (25.5 % против 14.6 % среди предметных), и на незнакомой
линии, где память ничего не подавляет, это выходит боком (п. 15.8).
### 15.2. Выборка по трекам
Полигон научили попутно выкладывать описание каждого живого трека на каждом
@ -2019,405 +1941,3 @@ leave-one-bag-out.
(п. 12.5), для новизны (п. 12.3) и для утечки улики. Это возможно, но
смешивание даёт тот же эффект мягко и без ещё одной калибровочной кривой,
поэтому порог оставлен выключенным (`track_gate = 0`).
### 15.8. Смешивание меняет не в ту сторону
Ложные треки на километр, обе сцены, считывание по складкам:
| настройка | знакомая линия | незнакомая линия | объект на 55 м |
|---|---:|---:|---:|
| база | 8.0 | **20.3** | 99.5 % |
| один дальний порог 0.30 | **8.0** | 21.3 | 99.5 % |
| смешивание 0.3 + дальний порог с 60 м | 6.3 | **24.8** | 99.5 % |
| смешивание 0.2 + дальний порог с 60 м | 8.0 | 24.0 | 99.5 % |
Смешивание улучшает знакомую линию и **портит незнакомую**. Это не случайность,
и механизм виден из разбора п. 15.1: смешивание не только давит — у 21 %
наблюдений оно отсчёт поднимает, и среди ложных таких больше, чем среди
предметных (25.5 % против 14.6 %). Поднимаются молодые треки с хорошими
наблюдениями и малой уликой. На знакомой линии память тоннеля такие треки уже
погасила через новизну, а на незнакомой гасить нечем — и смешивание их
вытаскивает.
Приватный тест — это незнакомая линия. Значит, смешивание отвергается:
`track_blend = 0` по умолчанию.
И это ещё не всё. Смешивание надо сравнивать не с базой, а с **тем же разменом,
полученным простым поднятием порога** — иначе мы сравниваем две разные рабочие
точки и выдаём сдвиг по кривой за улучшение кривой. Контроль:
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---:|
| смешивание 0.2 + дальний порог с 60 м | 0.71 | 0.47 | 0.37 | 817 |
| **порог 0.60 + дальний порог, без смешивания** | 0.70 | **0.49** | 0.37 | **816** |
Одинаковое число посторонних тревог, и при нём простой порог даёт обнаружение
на 100 м **лучше**. Никакой новой кривой смешивание не открывает — оно двигает
по той же, что и ключ, который у нас уже был.
Итог: `track_blend = 0`, `track_gate = 0`. Накопители на треке и считывание по
трекам остаются в коде как инструмент замера — они и дали понимание, что улика
насыщается, — но в решении не участвуют.
### 15.9. Отрицательные результаты стареют
Выигрыш в этой главе дал не считывание по трекам, ради которого всё
затевалось, а ключ, **отвергнутый тремя разделами раньше**. П. 12.2 говорит
дословно: «порог тревоги, линейно опущенный с 0.5 до 0.3 начиная с 90 м, не
изменил ни ложных (3.3 на км), ни обнаружения (P@100 = 0.31, решения за 120 м
по-прежнему 0)».
Тогда это было правдой, и причина там же, в п. 12.3: улика далёкого трека
колебалась около нуля, потому что знакомость была на той дальности перевёрнута
и входила в вес **множителем**. Опускать порог под сигналом, который равен
нулю, бессмысленно — что замер и показал.
Потом приняли гашение знакомости с 90 м (п. 12.3) и порог по лучам,
нормированный на дальность (п. 12.5). Улика далёкого трека перестала быть
нулём. Тот же самый ключ, тот же самый замер — и P@150 растёт с 0.33 до 0.37
ценой нуля ложных треков на знакомой линии и одного на незнакомой.
Отсюда правило, которое стоит дороже самой правки: **отрицательный результат
верен только для той конфигурации, в которой он получен.** Каждое принятое
изменение обесценивает часть прежних отказов, и дешёвые из них надо
перепроверять. Здесь это стоило одного прогона на четыре минуты.
Варианты той же правки, полигон, `человек_стоя`:
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---:|
| база | 0.70 | 0.53 | 0.33 | 999 |
| **порог 0.30 с 90 м** | 0.70 | 0.53 | **0.37** | **1091** |
| порог 0.30 с 60 м | 0.70 | 0.53 | 0.37 | 1197 |
| порог 0.20 с 90 м | 0.70 | 0.53 | 0.38 | 1181 |
| порог 0.30 + утечка 0.03 | 0.70 | **0.48** | 0.40 | 1172 |
Начинать раньше незачем: обнаружение то же, ложных больше. Опускать до 0.20 —
сотая на 150 м за восемь процентов посторонних тревог. Утечку, отвергнутую
в п. 12.4, на нынешней базе тоже перемерили: теперь она даёт P@150 0.40, но
забирает 100 м (0.53 → 0.48) и рабочую дальность 100 → 80 м — отвергнута
снова, уже по другой причине. Правило «перепроверять» не означает
«перепринимать».
Принято: `warn_far = 0.30`, `warn_far_from = 90`. Итог на всех сценах:
| | было | **стало** |
|---|---:|---:|
| P@150, человек стоя | 0.33 | **0.37** |
| P@150, человек сидя | 0.22 | **0.24** |
| P@100 / P@150, ящик | 0.38 / 0.03 | **0.40 / 0.06** |
| P@50 и P@100, человек | 0.70 / 0.53 | 0.70 / 0.53 |
| решений в полосе 90–120 м / 120–160 м | 0.49 / 0.38 | **0.51 / 0.41** |
| ложных треков на км, знакомая линия | 8.0 | **8.0** |
| то же, незнакомая линия | 20.3 | 21.3 |
| настоящий объект на 55 м | 99.5 % | **99.5 %** |
Экстренного торможения правка не касается: у него свой порог и условие
`d ≤ max(тормозной путь, 25 м)`, поэтому далёкий ложный трек физически не
может его вызвать. Мы снова платим предупреждениями за дальность — для
движущегося поезда это правильная сторона размена (как и в п. 12.3).
## 16. Работа Zhirik1337: что перенесено и что из этого принято
22.09 в командный репозиторий пришло семь коммитов Zhirik1337: вывод на
видеокарте, пять правок габарита и решения, «человек лёжа» в каталоге,
случайные сценарии полигона, выгрузка рамок, ROS-узел и Docker. Всё перенесено
в основной проект, код детектора — за флагами, выключенными по умолчанию.
Потом каждая правка мерилась отдельно и на тех же вставках. Флаги
выставляются из командной строки без правки кода:
`--set k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py`
и `check_obstacle.py`.
### 16.1. Полигон шумит сильнее, чем меняют правки
Две реализации случайности полигона при **одних и тех же** настройках (зерна
12345 и 777) расходятся на P@150 у человека стоя 0.39 против 0.32, на P@100 у
ящика 0.40 против 0.29. Большинство правок меняют цифры меньше, поэтому
сравнивать два прогона по итоговой таблице нельзя: разница окажется шумом.
Сравнение ведётся **парно**. Генератор у каждого сценария свой (п. 16.2), и от
решений конвейера он не зависит, поэтому при другой настройке на вход идут те же
кадры с теми же вставками, до луча. Разница двух прогонов — только от настройки,
и её видно по переворотам отдельных наблюдений: сколько было 0 и стало 1 и
наоборот (`tools/compare_benchmark.py`). Если перевороты идут в одну сторону,
эффект настоящий, даже когда он меньше межзернового шума.
Так перепроверен и дальний порог из п. 15.9, на обоих зёрнах: 0.50 → 0.30
переворачивает 8 и 2 наблюдения человека стоя на 130–170 м в плюс и **ни одного**
в минус. Эффект маленький, но настоящий, и правка остаётся.
### 16.2. Новый предмет сдвигал цифры всех остальных
Генератор был один на запись, и сценарии тянули из него по очереди, кадр за
кадром. Когда в каталог добавили человека лёжа, поток сдвинулся для **всех**
сценариев: цифры по ящику менялись оттого, что где-то появился новый предмет.
Это проверено прямо: со старым каталогом перенесённый код давал полигон
побайтово, с новым цифры прежних предметов разошлись.
Теперь у каждого сценария свой генератор, зерно — из имени предмета и бокового
смещения через crc32 (`scenario_rng`; встроенный `hash()` солится в каждом
процессе заново). Случайные сценарии Zhirik1337 (`--augment`) переведены на него
же. Тест `test_scenario_stream_does_not_depend_on_the_rest_of_the_catalogue`.
База с новым каталогом (порог 0.30 за 90 м, как в п. 15.9):
| предмет | рабочая дальность | P@50 | P@100 | P@150 |
|---|---:|---:|---:|---:|
| человек стоя | 80 м | 0.70 | 0.48 | 0.39 |
| человек сидя | 100 м | 0.67 | 0.52 | 0.20 |
| **человек лёжа** | 20 м | **0.27** | **0.00** | 0.00 |
| ящик | 20 м | 0.58 | 0.40 | 0.06 |
Человек лёжа виден хуже всех, кроме совсем мелочи. Воронка (п. 12) показывает
почему: на 30–50 м кандидат рождается только в 35 % наблюдений. Высота 0.30 м,
а пол габарита стоит на 0.28 м, так что в габарит попадает верхушка в два
сантиметра.
### 16.3. Пол в колее: вдали выигрыш, вблизи рельсы
Правка: между рельсами (|u| ≤ 0.85 м) нижняя граница габарита опускается с
0.28 до 0.16 м, снаружи колеи остаётся прежней. Вдали она даёт много, но
вблизи обнаружение **падает вдвое**:
| полоса | человек стоя: база → пол 0.16 | лёжа | ящик |
|---|---|---|---|
| 0–15 м | 0.93 → 0.92 | 0.70 → **0.58** | 0.75 → **0.62** |
| 15–25 м | 0.71 → **0.37** | 0.55 → **0.27** | 0.56 → **0.34** |
| 25–40 м | 0.58 → **0.42** | 0.37 → 0.35 | 0.48 → **0.35** |
| 55–70 м | 0.83 → 0.83 | 0.17 → **0.62** | 0.70 → 0.71 |
| 110–135 м | 0.43 → 0.46 | 0.00 → 0.00 | 0.30 → **0.60** |
| 160–190 м | 0.07 → **0.27** | 0.03 → 0.03 | 0.04 → **0.12** |
Рабочая дальность у всех предметов стала 8 м, то есть обнаружение
проваливается уже на втором поясе.
**Причина.** Один и тот же кадр со вставкой прогнан через оба конвейера, и там,
где обычный видит кандидата, а опущенный нет, кандидаты пересчитаны без запрета
на длину. Во всех 12 потерях на 12–45 м одной записи предмет входит в
компоненту длиной 50–140 м, которая начинается **с 4 м** — с ближней границы
габарита. У разобранных подробно её нижняя точка лежит ровно на опущенном
полу, 0.16–0.18 м: это головки рельсов. Вблизи соседние кольца ложатся на
полотно плотнее допуска по глубине, и рельс собирается в одну компоненту. Компоненту
длиннее 15 м конвейер отбрасывает как «полотно или стену под скользящим углом»
и выбрасывает вместе с ней предмет. Разделение фигуры и фона могло бы вырезать
предмет, но ближе `split_near = 55 м` оно выключено — то же слипание, что с
кромкой платформы в п. 12.7.
**Лечение** — не опускать пол ближе заданной дальности (`core_from`). Почему
дальше рельсы предмет не губят, отдельно не разбирали; замер показывает, что
с 30 м опущенный пол вреда не делает (старые складки, база → пол с 30 м):
| полоса | человек стоя | лёжа | ящик |
|---|---|---|---|
| 0–15 м | 0.93 → 0.93 | 0.70 → 0.70 | 0.75 → 0.75 |
| 15–25 м | 0.71 → 0.71 | 0.55 → 0.55 | 0.56 → 0.56 |
| 25–40 м | 0.58 → 0.58 | 0.37 → **0.50** | 0.48 → 0.49 |
| 55–70 м | 0.83 → 0.83 | 0.17 → **0.62** | 0.70 → 0.71 |
| 110–135 м | 0.43 → 0.46 | 0.00 → 0.00 | 0.30 → **0.60** |
| 160–190 м | 0.07 → **0.27** | 0.03 → 0.03 | 0.04 → **0.12** |
Где ставить порог, мерилось тремя прогонами. Ближе 25 м ни один из них ничего
не теряет: из 3 469 наблюдений всех предметов в минус не перевернулось ни одно
(в плюс 8, 6 и 0 — треки, начатые дальше порога, доживают до ближней зоны).
Различаются пороги на мелком и низком:
| порог | ведро 25–40 м | ведро 40–55 м | лёжа 25–40 м | посторонних на полигоне |
|---|---:|---:|---:|---:|
| база (пол не опущен) | 0.23 | 0.02 | 0.37 | 1196 |
| **с 30 м** | **0.49** | **0.52** | **0.50** | **1313** |
| с 40 м | 0.49 | 0.52 | 0.45 | 1333 |
| с 55 м | 0.23 | 0.29 | 0.45 | 1336 |
Порог 30 м не хуже остальных ни в одной полосе и даёт меньше посторонних.
Парно против 40 м он добавляет 7 наблюдений лежачего и 2 ящика и не теряет
ни одного.
Парно, на тех же вставках (пол 0.16 с 30 м против базы, старые складки):
| предмет | полоса | было | стало | 0→1 | 1→0 |
|---|---|---:|---:|---:|---:|
| человек стоя | 130–170 м | 0.36 | 0.50 | 37 | 0 |
| человек сидя | 130–170 м | 0.19 | 0.47 | 68 | 0 |
| человек лёжа | 50–90 м | 0.13 | 0.46 | 74 | 0 |
| ящик | 90–130 м | 0.36 | 0.56 | 46 | 0 |
| чемодан | 130–170 м | 0.01 | 0.26 | 51 | 0 |
| ведро | 50–90 м | 0.00 | 0.39 | 85 | 0 |
На всех предметах и полосах вместе +677 наблюдений и −7. Вблизи с порогом по
дальности ничего не теряется (человек стоя на 6–50 м: +1 и −0). Цена:
Со старыми моделями пол обходится в два с лишним трека на километр на
знакомой линии и в четыре на незнакомой (таблица ниже, средний столбец).
Лишние треки — не сбой, а то, что пол открыл: низкие конструкции в колее. На
записи со стрелочным переводом появились три новых трека, и один из них —
предмет высотой 0.42 м прямо между рельсами (u = −0.40 м) на 78–104 м.
Лидар его видел и раньше, но он стоял ниже пола габарита.
Считывание таких кандидатов не видело никогда: оно обучалось на выборке,
собранной при прежнем поле. Поэтому, как в п. 12.6, выборка пересобрана на
новом полу — **333 739 кандидатов против 214 958**. Прибавка почти целиком
фоновая, это и есть низкие конструкции в колее, плюс человек лёжа, которого
раньше в каталоге не было (предметов 24 651 против 20 150). Складки
переобучены с прежней ёмкостью 4 000 клеток. AUC leave-one-bag-out **0.9856**
против 0.9829 на прежней выборке (по складкам 0.980–0.992): новые фоновые
кандидаты отделяются от предметов легко.
| | база | пол 30 м, старые складки | **пол 30 м, новые складки** |
|---|---:|---:|---:|
| ложных треков/км, знакомая линия | 8.0 | 10.2 | **8.0** |
| кадров с тревогой | 13.3 % | 19.3 % | 13.8 % |
| то же, незнакомая линия | 21.3 | 25.4 | **23.5** |
| кадров с тревогой | 28.3 % | 37.4 % | 34.6 % |
| посторонних на полигоне | 1196 | 1313 | **1226** |
| настоящий объект на 55 м | 99.5 % | 99.5 % | **99.5 %** |
| задержка кадра p50 / p95, мс (один процесс) | 33–45 / 37–49 | — | 35–42 / 41–52 |
Переобученное считывание убрало всю цену на знакомой линии и половину на
незнакомой. Из выигрыша в обнаружении отдана малая часть — у лежачего P@100
0.32 → 0.22 против старых складок, остальное в пределах сотых. Против базы:
| предмет | рабочая дальность | P@50 | P@100 | P@150 |
|---|---|---|---|---|
| человек стоя | 80 → 80 м | 0.70 → 0.71 | 0.48 → 0.48 | 0.39 → **0.49** |
| человек сидя | 100 → **148 м** | 0.67 → 0.67 | 0.52 → 0.52 | 0.20 → **0.49** |
| **человек лёжа** | 20 → **32 м** | 0.27 → **0.50** | 0.00 → **0.22** | 0.00 → 0.02 |
| ящик | 20 → 20 м | 0.58 → 0.54 | 0.40 → **0.50** | 0.06 → **0.26** |
| чемодан | 62 → **80 м** | 0.56 → 0.61 | 0.25 → **0.45** | 0.00 → **0.24** |
Парно против базы, все предметы:
| полоса | наблюдений | 0→1 | 1→0 |
|---|---:|---:|---:|
| 0–15 м | 2 158 | 0 | **16** |
| 15–40 м | 2 583 | 93 | 1 |
| 40–90 м | 2 644 | 224 | 7 |
| 90–200 м | 2 746 | 368 | 9 |
Шестнадцать ближних потерь — цена именно переобучения: со старыми складками
ближе 25 м не терялось ничего. Тринадцать из них — человек стоя у края
габарита (смещение 0.9 м) на 9–15 м, на трёх записях, две из них с платформой;
ещё сидящий там же и две бутылки на оси. Кандидат и трек у человека есть,
решения нет. Отдельно причину не разбирали. По месту это тот же случай, что в
п. 12.7: предмет у края габарита вблизи, где разрез фигуры выключен.
**Принято**: `h_lo_core = 0.16`, `core_from = 30`, выборка и складки
пересобраны. Самый важный для метро случай — человек, упавший на пути, — был
виден хуже всех (P@50 = 0.27, дальше 50 м почти никогда), теперь P@50 = 0.50 и
рабочая дальность 32 м. Цена — два трека на километр только на незнакомой линии
и 16 ближних наблюдений из двух тысяч у края габарита.
### 16.4. Лежащий предмет без штрафа — при обученном считывании не действует
`lying_exempt` снимает штраф за вытянутость по пути с низкого предмета в колее
(высота до 0.40 м, длина до 2.2 м): человек лёжа вдоль пути тянется на 1.8 м и
по ручной формуле похож на кусок лотка. Замер: полигон совпал с базой до
последнего наблюдения, и с опущенным полом тоже.
Причина не в правке, а в том, куда она встроена. Множитель компактности
живёт в ручной формуле веса, а в рабочей конфигурации `mbon_blend = 1.0`: вес
наблюдения целиком берёт обученное считывание, и ручная формула входит в него
в нулевой степени (`central_complex._quality`). Правка работает только там,
где модели нет. Флаг оставлен выключенным. Лежащему человеку помогает пол
(п. 16.3). Считывание, переобученное на выборке, где лежачий уже есть, ему не
помогло: P@100 0.32 → 0.22 против старых складок (п. 16.3).
### 16.5. Габарит в кривых, вырез платформы, дальний канал
Все три мерились одинаково: полигон парно против базы (сумма переворотов по
всем полосам) и ложные треки на двух сценах со старыми моделями.
| правка | человек стоя | сидя | лёжа | ящик | чемодан | всего | ложных/км, знакомая | незнакомая |
|---|---|---|---|---|---|---|---:|---:|
| база | | | | | | | 8.0 | 21.3 |
| `k_sigma = 0.75` | +0/−1 | +19/−0 | +0/−10 | +37/−0 | +0/−5 | +56/−16 | 9.5 | 25.7 |
| `platform_filter` | +9/−5 | +17/−3 | +0/−11 | +2/−7 | +1/−0 | +35/−26 | 10.2 | 25.2 |
| `far_channel` | +24/−0 | +21/−0 | +10/−0 | +43/−0 | +15/−0 | +115/−0 | 11.0 | 28.4 |
| для сравнения: пол с 30 м | +51/−1 | +92/−0 | +135/−0 | +108/−5 | +131/−0 | **+677/−7** | 10.2 | 25.4 |
**Габарит по неопределённости оси** (`k_sigma`) расширяет коридор там, где ось
пути известна хуже, — вдали и в кривых. Ящику это помогает, лежачему и
чемодану мешает: в широком габарите к низкому предмету чаще прилипает
посторонняя компонента. Ложные треки добавляются ровно там, где габарит
расширился, — на кривых записях, с медианной дальностью около 100 м. Отвергнуто.
**Вырез платформы** (`platform_filter`) удаляет из габарита горизонтальную
полосу 1.05–1.25 м у края (|u| ≥ 1.30 м) — настил платформы. На записях с
платформой ложных треков ровно столько же (1 → 1 и 2 → 2), а весь рост
приходится на `roundT_doubleT`, где платформы нет вовсе: 1 → 4 на знакомой
линии, 8 → 12 на незнакомой. Полоса, вырезанная из стены или лотка, режет
конструкцию на верхний и нижний обрывки, и каждый выглядит компактным
предметом. На полигоне перевороты в обе стороны почти поровну, то есть это
шум. Отвергнуто.
**Дальний канал** (`far_channel`) — отдельный путь к тревоге для треков дальше
90 м с послабленными условиями. Выигрыш настоящий (+115 и ни одного минуса), но
он решает ту же задачу, что и принятый дальний порог (п. 15.9), и стоит втрое
больше: +3.0 трека на км на знакомой линии и +7.1 на незнакомой, посторонних на
полигоне 1196 → 1676. Для сравнения, пол с 30 м даёт вшестеро больше
переворотов за меньшую цену. Отвергнуто; включать его вместе с дальним
порогом — ослаблять дальний край дважды.
### 16.6. Всё вместе, как было отправлено
Все пять правок разом плюс свои пороги дальнего края (`warn_far = 0.35`,
`leak_far = 0.06`): дальние цифры самые высокие из всех прогонов (P@150 у
человека сидя 0.56, у ящика 0.37), но посторонних тревог на полигоне **2299
против 1196** — почти вдвое, — и рабочая дальность 8 м у всех предметов из-за
рельсов (п. 16.3). Парно против базы:
| полоса | наблюдений | было | стало | 0→1 | 1→0 |
|---|---:|---:|---:|---:|---:|
| 6–40 м | 2 926 | 0.63 | **0.50** | 107 | **496** |
| 40–90 м | 1 982 | 0.47 | 0.61 | 271 | 3 |
| 90–200 м | 2 684 | 0.19 | 0.38 | 531 | 0 |
Дальше 40 м всё в плюс, ближе — всё в минус. А ближняя зона — это экстренное
торможение (у него условие `d ≤ max(тормозной путь, 25 м)`, п. 15.9) и вся
работа на малой скорости, у платформы и на подходе к ней: там других
дальностей просто нет. По отдельности видно, откуда что: дальний выигрыш почти
целиком даёт пол, ближний провал — он же без порога по дальности, а из
посторонних тревог больше всего добавляет дальний канал: +480 в одиночку, пол
без порога +99, остальные — десятки.
### 16.7. Выгрузка рамок, устройство, ROS-узел
* **Рамка в кривой стояла не там.** Боковое смещение трека отсчитано от оси
пути, а шло прямо в `x` сенсора: на радиусе 1300 м рамка уезжала на 1.2 м на
55 м и на 8.6 м на 150 м — в стену. Теперь `x = u + corridor.centre(d)`.
* **Поворот рамки был зеркальным**: `yaw = −atan(наклон)` вместо `+atan`, на
150 м это 13°. Оба случая проверяет `test_export_box_follows_a_curved_track`.
* **Устройство по умолчанию — процессор.** У Zhirik1337 `device=None` означал
«видеокарта, если есть», и обучение с выводом тихо уезжали на GPU. Вывод на
видеокарте ускоряет только ламину (п. 7.4). `auto` и `cuda` работают, если их
передать явно; Docker так и делает. Ошибка по дороге: `encode(device="auto")`
отдавал строку `auto` прямо в torch, падал, и падение навсегда помечало CUDA
сломанной.
* **ROS-узел `tools/flyguard_ros2_node.py`** отдаёт конвейеру массив numpy
вместо разобранного `PointCloud2`, публикует все треки, а не решение, и у
трека нет полей рамки. Узел ведёт интеграция, он оставлен за ними
(`TEAM_OWNED` в `export_team.py`). Рабочий узел — `ros2_ws/.../node.py`.
### 16.8. Итог
| правка | решение | почему |
|---|---|---|
| пол в колее `h_lo_core = 0.16` | **принято**, дальше 30 м (`core_from`) | лежачий P@50 0.27 → 0.50, P@150 у стоя 0.39 → 0.49; без порога по дальности вблизи провал вдвое |
| выборка и складки на новом полу | **принято** | цена пола на знакомой линии 2.2 → 0 треков на км, на незнакомой 4.1 → 2.2 |
| `lying_exempt` | не действует | правит ручную формулу, а вес целиком от модели |
| `k_sigma` | отвергнуто | +56/−16 на полигоне за +1.5 и +4.4 трека на км |
| `platform_filter` | отвергнуто | на платформах ничего, в тоннеле без платформы режет стену: +2.2 и +3.9 |
| `far_channel` | отвергнуто | та же задача, что `warn_far`, втрое дороже: +3.0 и +7.1 |
| человек лёжа в каталоге | **принято** | самый важный для метро случай и самый трудный |
| генератор на сценарий | **принято** | новый предмет больше не сдвигает цифры остальных |
| выгрузка рамок `export.py` | **принято** с двумя исправлениями | рамка в кривой и знак поворота |
| вывод на видеокарте | в коде, по умолчанию процессор | ускоряет только ламину (п. 7.4) |
Всё отвергнутое осталось в коде за флагами, с ценой в этом разделе:
отрицательный результат верен только для той конфигурации, в которой получен
(п. 15.9), и перепроверка любой правки стоит одного ключа `--set`.
**Что не сделано.** Память тоннеля (грибовидное тело) обучена на кандидатах,
собранных 19.09 при прежнем полу, и низких конструкций в колее не знает: на
знакомой линии их гасит только считывание. Выборку памяти надо собирать
проходом по 90 ГБ `new_data` — это следующий шаг, если понадобится ещё
снизить ложные на знакомой линии.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,23 +36,10 @@ class Params:
fov_deg: float = 30.0 # полусектор обработки по азимуту
half_width: float = 1.6 # полуширина габарита, м
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
# Три правки габарита (Zhirik1337, 22.09), каждая мерилась отдельно на
# двух сценах и полигоне (EXPERIMENTS п. 16):
# h_lo_core — пол в колее: между рельсами (|u| ≤ core_width) нижняя
# граница опускается, чтобы не терять лежащего человека.
# ПРИНЯТО, но только дальше core_from: ближе в полосу
# 0.16…0.28 м попадают головки рельсов, рельс собирается
# в одну компоненту от кабины, и предмет выбрасывается
# вместе с ней. Лежачий P@50 0.27 → 0.50;
# k_sigma — габарит расширяется там, где ось пути известна хуже
# (в кривых вдали); 0 — не расширять. Отвергнуто;
# platform_filter — вырезать из габарита настил платформы. Отвергнуто:
# в тоннеле без платформы режет стену на обрывки.
h_lo_core: float = 0.16
core_width: float = 0.85 # полуширина колеи, м
core_from: float = 30.0 # ближе этой дальности пол в колее не опускается, м
k_sigma: float = 0.0
platform_filter: bool = False
h_lo_core: float = 0.16 # нижняя граница в колее (|u| <= core_width)
core_width: float = 0.85 # полуширина междупутья/колеи, м
k_sigma: float = 0.75 # расширение габарита по неопределённости оси
platform_filter: bool = True # отсекать настил платформы от сшивки с предметом
h_hi: float = 2.3 # верхняя граница, м
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
@ -124,16 +111,6 @@ class Params:
# Считывание по треку (см. track_readout.py): геометрическое смешивание
# обученного отсчёта с уликой перед сравнением с порогом. 0 — решает
# только улика, как раньше. Замер — EXPERIMENTS п. 15.
# Правки решения (Zhirik1337, 22.09), обе выключены по замеру (п. 16):
# lying_exempt — не штрафовать за вытянутость низкий предмет в колее
# (лежащий человек вытянут вдоль пути, как конструкция).
# Правит ручную формулу, а при mbon_blend = 1 вес целиком
# от модели — не действует;
# far_channel — дальний канал предупреждения в обход порога. Выигрыш
# настоящий, но та же задача, что warn_far, втрое дороже:
# +3.0 и +7.1 ложных трека на км на двух сценах.
lying_exempt: bool = False
far_channel: bool = False
track_blend: float = 0.0
track_gate: float = 0.0 # жёсткий порог по тому же отсчёту
# Чем считать «насколько это предмет» по треку: `w_mean` — средний вес
@ -152,18 +129,9 @@ class Params:
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
min_hits: int = 2
novelty_floor: float = 0.10
# Порог тревоги, линейно опускаемый с 0.5 до `warn_far` на отрезке от
# `warn_far_from` до 200 м. В п. 12.2 ровно эта правка была отвергнута:
# тогда улика далёкого трека колебалась около нуля, и опускать порог под
# нулевым сигналом было бессмысленно. После гашения знакомости и порога
# по лучам улика перестала быть нулём, и та же правка дала P@150
# 0.33 → 0.37 без потерь на 50 и 100 м ценой нуля ложных треков на
# знакомой линии и одного на незнакомой. Экстренного торможения она не
# касается: у него свой порог и условие d ≤ max(тормозной путь, 25 м).
# EXPERIMENTS п. 15.9.
warn_far: float = 0.30
warn_far_from: float = 90.0
leak_far: float = 0.12 # утечка улики на дальнем краю; равна leak — выключено
warn_far: float = 0.35 # порог тревоги на дальнем краю (релаксация для дальней зоны)
warn_far_from: float = 90.0 # с какой дальности он начинает падать
leak_far: float = 0.06 # утечка улики на дальнем краю (компенсирует прореживание лучей)
leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0
d_max: float = 220.0
@ -179,11 +147,7 @@ class Params:
min_rays_far_from: float = 90.0
calib_frames: int = 12
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
# Где считать ламину: 'cpu', 'cuda' или 'auto' (видеокарта, если есть).
# По умолчанию процессор, и это решение, а не осторожность: GPU ускоряет
# только ламину (5.8 → 2.9 мс из 43), а на машине проверки требует
# другого контейнера и запуска с --gpus all. EXPERIMENTS п. 7.4.
device: str = "cpu"
device: str = "auto" # вычислительное устройство: 'auto' (NVIDIA GPU при наличии), 'cuda', 'cpu'
enable_motion: bool = True
enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
@ -254,8 +218,7 @@ class FlyGuard:
leak_far=self.p.leak_far,
leak_far_from=self.p.leak_far_from,
nov_fade_from=self.p.nov_fade_from,
nov_fade_to=self.p.nov_fade_to,
lying_exempt=self.p.lying_exempt)
nov_fade_to=self.p.nov_fade_to)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
@ -268,8 +231,7 @@ class FlyGuard:
track_blend=self.p.track_blend,
track_gate=self.p.track_gate,
track_score=self.p.track_score,
track_readout=track_readout,
far_channel=self.p.far_channel) if self.p.use_tracking
track_readout=track_readout) if self.p.use_tracking
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1))
self._calib: list[PointCloud2] = []
@ -384,7 +346,6 @@ class FlyGuard:
k_sigma=self.p.k_sigma,
h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter)
cands = find_candidates(tf, lam, self.corridor, masks=masks,
half_width=self.p.half_width, h_lo=self.p.h_lo,
@ -401,7 +362,6 @@ class FlyGuard:
k_sigma=self.p.k_sigma,
h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter)
with t("mushroom"):

View file

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

View file

@ -592,20 +592,6 @@ def test_gauge_mask_zonal_floor_and_corridor_expansion():
assert inside_exp[0]
def test_core_floor_is_not_lowered_closer_than_core_from():
"""Вблизи опущенный пол открывает головки рельсов — там он не действует."""
from flyguard.geometry import STRAIGHT
from flyguard.lobula import gauge_mask
tf = _MockTrackFrame(d=[20.0, 60.0, 60.0], u=[0.4, 0.4, 1.2], h=[0.20, 0.20, 0.20])
inside, _, _ = gauge_mask(tf, STRAIGHT, half_width=1.6, h_lo=0.28, h_hi=2.3,
d_min=4.0, d_max=220.0, h_lo_core=0.16,
core_width=0.85, core_from=55.0)
assert not inside[0] # 20 м — ближе порога, пол прежний
assert inside[1] # 60 м в колее — пол опущен
assert not inside[2] # вне колеи пол не опускается нигде
def test_gauge_mask_filters_platform_surface():
"""Настил платформы (h~1.1-1.2 м, |u| >= 1.30 м) отсекается, а препятствие в габарите остаётся."""
from flyguard.geometry import STRAIGHT
@ -636,8 +622,7 @@ def test_descending_far_soft_warning_triggers_without_emergency():
)
cx.tracks = [far_track]
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0,
far_channel=True)
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0)
decision = dn.decide(cx, speed=15.0)
assert decision.detected is True
@ -689,6 +674,7 @@ def test_export_frame_markers_and_threat_levels():
def test_benchmark_augmentation_logic():
"""Проверка генерации сценариев и логики аугментаций в make_benchmark."""
sys.path.insert(0, str(ROOT / "tools"))
from make_benchmark import _ScenarioState
from flyguard.pipeline import FlyGuard, Params
@ -763,66 +749,20 @@ 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"
r = np.full((16, 32), 20.0, dtype=np.float32)
r[8, 16] = 4.0
valid = np.ones((16, 32), dtype=bool)
assert not is_cuda_available()
assert get_device("cuda") == "cpu"
assert get_device("auto") == "cpu"
# Даже при явном указании 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()
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
# Восстановление кэша
reset_device_cache()
def test_scenario_stream_does_not_depend_on_the_rest_of_the_catalogue():
"""Новый предмет в каталоге не сдвигает случайность остальных сценариев.
Раньше генератор был один на запись, и сценарии тянули из него по очереди:
добавили человека лёжа — поменялись цифры по ящику. Теперь поток сценария
определяется только зерном, именем предмета и поперечным смещением.
"""
from make_benchmark import scenario_rng
a = scenario_rng(12345, "ящик", 0.9).random(5)
assert np.array_equal(a, scenario_rng(12345, "ящик", 0.9).random(5))
assert not np.array_equal(a, scenario_rng(12345, "ящик", 0.0).random(5))
assert not np.array_equal(a, scenario_rng(12345, "чемодан", 0.9).random(5))
assert not np.array_equal(a, scenario_rng(777, "ящик", 0.9).random(5))
def test_export_box_follows_a_curved_track():
"""В кривой рамка стоит на пути и повёрнута вдоль него.
Боковое смещение трека отсчитано от оси пути, и без поправки на её
положение рамка на 150 м при радиусе 1300 м стояла в 8.6 м от пути, а
поворот был зеркальным — 13° мимо направления пути.
"""
import math
from flyguard.descending import Decision, DetectedObject
from flyguard.export import export_frame
from flyguard.geometry import Corridor, RailPlane
R = 1300.0
cor = Corridor(np.array([0.0, 0.0, 1.0 / (2 * R)], np.float32), 200.0, 10, 1600.0)
plane = RailPlane(a=0.0, b=0.0, c=-1.8, inliers=100, rms=0.02)
for d in (55.0, 100.0, 150.0):
dec = Decision(detected=True, distance=d, speed=10.0, stopping_distance=30.0,
objects=[DetectedObject(distance=d, lateral=0.3, height=0.8,
width=0.5, size_v=1.7, confidence=0.9,
novelty=0.6, n_rays=10, track_id=1,
ttc=d / 10)])
box = export_frame(dec, plane, cor).boxes[0]
on_track = float(cor.centre(np.array([d], np.float32))[0]) + 0.3
assert box.x == pytest.approx(on_track, abs=1e-3)
slope = d / R
long_axis = np.array([-math.sin(box.yaw), math.cos(box.yaw)])
tangent = np.array([slope, -1.0]) / math.hypot(slope, 1.0)
assert abs(float(long_axis @ tangent)) == pytest.approx(1.0, abs=1e-6)

View file

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

View file

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

View file

@ -1,83 +0,0 @@
"""Парное сравнение двух прогонов полигона на одних и тех же вставках.
Полигон шумит сильнее, чем меняет его большинство правок: две реализации
случайности при одинаковых настройках расходятся на P@150 на 0.07, на P@100
у ящика — на 0.11 (EXPERIMENTS п. 16.1). Поэтому сравнивать итоговые таблицы
двух прогонов нельзя.
Но у каждого сценария свой генератор (`make_benchmark.scenario_rng`), и от
решений конвейера он не зависит: при другой настройке на вход идут те же
кадры с теми же вставками, до луча. Значит, разница двух прогонов — только
от настройки, и её видно по отдельным наблюдениям: сколько было 0 и стало 1
и наоборот. Перевороты в одну сторону — настоящий эффект, даже когда он
меньше межзернового шума; поровну в обе стороны — шум.
python tools/compare_benchmark.py artifacts/benchmark.json new.json
python tools/compare_benchmark.py a.json b.json --objects человек_лежа,ящик
"""
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,7 +21,6 @@ import numpy as np
import _bootstrap as B # noqa: F401
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody, MushroomBodyConfig
from flyguard.pipeline import FlyGuard, Params
@ -173,7 +172,6 @@ def main() -> None:
help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии")
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args()
d = np.load(args.cache, allow_pickle=True)
@ -238,7 +236,7 @@ def main() -> None:
over["track_score"] = args.track_score
if args.track_gate is not None:
over["track_gate"] = args.track_gate
params = Params(**PS.apply(over, args.set, Params))
params = Params(**over)
fold_paths: dict[str, str] = {}
if args.mbon_dir:
from pathlib import Path as _P

View file

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

View file

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