пол в колее 0.16 дальше 30 м, переобучил считывание; правки Zhirik1337 замерены, остальные выключены

This commit is contained in:
Данил Омелечко 2026-09-23 23:24:34 +03:00
parent c895a72d9c
commit b4030df72b
29 changed files with 1874 additions and 141 deletions

4
.gitignore vendored
View file

@ -6,3 +6,7 @@ data/
artifacts/mbon_folds/
docs/figures/*.png
docs/figures/*.mp4
# Project memory & chat transfer
PROJECT_MEMORY.md
CHAT_MEMORY.md

View file

@ -66,8 +66,15 @@ else:
`emergency` (флаг экстренного торможения), `objects` (список подтверждённых
треков с id, дистанцией и габаритами).
Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 33 мс
при бюджете 100 мс.
Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 43 мс
при бюджете 100 мс (замер по стадиям, один процесс, EXPERIMENTS п. 7.4).
Ламину можно перенести на **NVIDIA GPU** (`Params(device="auto")` или
`"cuda"`, модуль `flyguard/device.py` с переходом на CPU при любом сбое карты).
Замерено на RTX 5070 Ti: ламина 5.8 → 2.9 мс с копированием туда и обратно,
то есть кадр 43 → ~40 мс, — остальные 80 % времени кластеризация и геометрия,
которые видеокарта не ускоряет. Поэтому по умолчанию `device="cpu"`: выигрыш
в 3 мс не стоит отдельного образа и `--gpus all` на машине проверки.
---
@ -76,18 +83,21 @@ else:
```
flyguard/ ядро: стадии обработки, память, считывание
bag.py cdr.py чтение rosbag2 и разбор CDR без ROS
device.py выбор CPU / NVIDIA GPU и переход на CPU при сбое
retina.py geometry.py решётка лучей, плоскость рельсов, ось пути
lamina.py medulla.py контраст, движение
lamina.py medulla.py контраст (на CPU или GPU), движение
lobula.py кандидаты
mushroom_body.py память тоннеля (без меток)
mbon_readout.py обученное считывание (с метками)
track_readout.py считывание по истории трека — инструмент замера
fan_body.py накопление в координатах пути
central_complex.py треки и улики
descending.py решение
pipeline.py сборка
export.py 3D-рамки, время до столкновения, маркеры RViz
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор
tests/ 34 теста, запускаются без данных и без ROS
tools/ обучение, оценка, разбор, полигон с аугментациями
tests/ 45 тестов, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```
@ -98,9 +108,22 @@ artifacts/ обученные модели
```bash
pip install -r requirements.txt
pytest tests -q
pytest tests -q # или без pytest: python tests/run_tests.py
```
Через Docker (подробно — [docs/DOCKER.md](docs/DOCKER.md)):
```bash
./docker-run.sh build
docker compose run --rm test # тесты
docker compose run --rm info # что видно из контейнера: CPU, GPU, CUDA
docker compose run --rm evaluate # ложные тревоги
docker compose run --rm benchmark # полигон
```
Для обучения на видеокарте — `pip install -r requirements-gpu.txt` и
`--device cuda` у `train_mbon.py`, `train_mushroom_body.py`, `evaluate.py`.
Записи лидара в репозиторий не кладутся. Положите их рядом
(`../data/for_hackathon/...`) или укажите путь:
@ -117,8 +140,16 @@ 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 с,
@ -137,13 +168,14 @@ python tools/plot_benchmark.py # кривые и граф
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **8.0 трека на км**, 13.3 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 12.9 на км, 25.9 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **21.3 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` |
| Дальность (полигон, 14 004 наблюдения) | рабочая дальность **100 м**, P@50 = 0.70, **P@100 = 0.53, P@150 = 0.37** | `tools/plot_benchmark.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 м | там же |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.983** | `tools/train_mbon.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` |
Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и

File diff suppressed because one or more lines are too long

View file

@ -18,12 +18,12 @@
"bag": "doubleT_platform",
"frames": 239,
"path_m": 200.74905739412534,
"alarm_frames": 28,
"alarm_rate": 0.11715481171548117,
"fp_objects": 28,
"fp_tracks": 1,
"fp_per_km": 4.981343439320496,
"fp_median_d": 33.983066409463774,
"alarm_frames": 48,
"alarm_rate": 0.200836820083682,
"fp_objects": 66,
"fp_tracks": 2,
"fp_per_km": 9.962686878640993,
"fp_median_d": 32.8633359224923,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
@ -33,12 +33,12 @@
"bag": "roundT_doubleT",
"frames": 239,
"path_m": 200.9598713551979,
"alarm_frames": 3,
"alarm_rate": 0.012552301255230125,
"fp_objects": 3,
"fp_tracks": 1,
"fp_per_km": 4.976117835149752,
"fp_median_d": 20.107822042272232,
"alarm_frames": 0,
"alarm_rate": 0.0,
"fp_objects": 0,
"fp_tracks": 0,
"fp_per_km": 0.0,
"fp_median_d": NaN,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
@ -48,12 +48,12 @@
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 29,
"alarm_rate": 0.12133891213389121,
"fp_objects": 29,
"alarm_frames": 25,
"alarm_rate": 0.10460251046025104,
"fp_objects": 25,
"fp_tracks": 2,
"fp_per_km": 8.104919303770778,
"fp_median_d": 159.45810767922592,
"fp_median_d": 160.95291512943237,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
@ -63,12 +63,12 @@
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 54,
"alarm_rate": 0.22594142259414227,
"fp_objects": 54,
"fp_tracks": 4,
"fp_per_km": 14.601526667404583,
"fp_median_d": 106.72244931783379,
"alarm_frames": 6,
"alarm_rate": 0.02510460251046025,
"fp_objects": 6,
"fp_tracks": 2,
"fp_per_km": 7.3007633337022915,
"fp_median_d": 161.27067444604893,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
@ -78,12 +78,12 @@
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 45,
"alarm_rate": 0.18828451882845187,
"fp_objects": 45,
"fp_tracks": 2,
"fp_per_km": 7.380580405772363,
"fp_median_d": 107.47124911234653,
"alarm_frames": 86,
"alarm_rate": 0.3598326359832636,
"fp_objects": 91,
"fp_tracks": 4,
"fp_per_km": 14.761160811544727,
"fp_median_d": 96.62033146128638,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,

Binary file not shown.

View file

@ -165,6 +165,11 @@ LC11 у мухи — детектор мелкого объекта: возбу
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край
стены. Расширение идёт в стороны и вверх, но **не вниз**: полотно проходит под каждым
предметом и на большой дальности попало бы в тот же допуск по глубине.
* **пол габарита в колее ниже, но не везде.** Между рельсами (|u| ≤ 0.85 м) нижняя
граница 0.16 м вместо 0.28 — иначе упавший на пути человек высотой 0.30 м виден
верхушкой в два сантиметра. Ближе 30 м пол прежний: там в полосу 0.16…0.28 м попадают
головки рельсов, рельс собирается в одну связную компоненту от самой кабины, и предмет,
коснувшийся его, выбрасывается вместе с ним (EXPERIMENTS п. 16.3).
Для каждого кандидата считаются: дальность, смещение от оси, высота нижней точки, габариты,
протяжённость вдоль пути, целостность, число лучей и колец, контраст, дефицит до пола,
@ -289,6 +294,7 @@ $$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

@ -2116,3 +2116,308 @@ leave-one-bag-out.
`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,
max_tracks: int = 48, use_shape: bool = True,
mbon_power: float = 1.0, mbon_blend: float = 1.0,
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0):
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0,
lying_exempt: bool = False):
self.use_shape = use_shape
self.lying_exempt = lying_exempt
self.mbon_power = mbon_power
self.mbon_blend = mbon_blend
self.nov_fade_from = nov_fade_from
@ -224,7 +226,8 @@ class CentralComplex:
t.hits += 1
t.misses = 0
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to)
self.nov_fade_from, self.nov_fade_to,
self.lying_exempt)
t.observe(c, self.s_world + c.d, w)
t.evidence = min(1.0, t.evidence + self.gain * w)
if dt > 1e-3:
@ -251,7 +254,8 @@ class CentralComplex:
width=c.width, height=c.height, first_d=c.d, last_d=c.d,
last_n_rays=c.n_rays, novelty=c.novelty)
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to)
self.nov_fade_from, self.nov_fade_to,
self.lying_exempt)
t.observe(c, self.s_world + c.d, w)
t.evidence = self.gain * w
t.hits = 1
@ -295,7 +299,7 @@ def expected_rays(d: float) -> float:
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
mbon_blend: float = 1.0, nov_fade_from: float = 0.0,
nov_fade_to: float = 160.0) -> float:
nov_fade_to: float = 160.0, lying_exempt: bool = False) -> float:
"""Вес одного наблюдения: сколько улики оно добавляет.
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
@ -322,9 +326,15 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
contrast = max(contrast, float(np.clip(acc, 0.2, 1.0)))
if use_shape:
whole = float(np.clip((c.containment - 0.25) / 0.45, 0.05, 1.0))
# протяжённость вдоль пути сверх собственного размера — признак конструкции
span = max(c.width, c.height, 0.2)
compact = float(np.clip(1.5 - c.depth / (3.0 * span), 0.1, 1.0))
# Предмет или человек, лежащий в колее вдоль пути (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)
if is_lying_on_track:
compact = 1.0
else:
compact = float(np.clip(1.5 - c.depth / (3.0 * span), 0.1, 1.0))
# опора снизу: упавший предмет, человек, камень стоят на полотне, а знак,
# лоток или кронштейн висят на стене, и под ними пусто
grounded = float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))

View file

@ -64,7 +64,8 @@ class DescendingNeurons:
novelty_floor: float = 0.10, max_range: float = 200.0,
warn_far: float | None = None, warn_far_from: float = 90.0,
track_blend: float = 0.0, track_score: str = "w_mean",
track_gate: float = 0.0, track_readout=None):
track_gate: float = 0.0, track_readout=None,
far_channel: bool = False):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence
self.emergency_evidence = emergency_evidence
@ -94,6 +95,12 @@ class DescendingNeurons:
# предмета против 0.269 у ложных треков — разделение, под которое
# порог и просится. 0 — не проверять.
self.track_gate = track_gate
# Дальний канал предупреждения (Zhirik1337, 22.09): далёкий трек с
# хорошими наблюдениями поднимает тревогу в обход порога, даже если
# улика просела на пропусках. Та же задача, что у `warn_far`, другим
# способом; включать вместе с ним — ослабить дальний край дважды.
# Замер — EXPERIMENTS п. 16.
self.far_channel = far_channel
self.track_score = track_score
self.track_readout = track_readout
self._latched: set[int] = set()
@ -120,8 +127,11 @@ 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)
if (not (0.0 < d <= self.max_range) or t.hits < self.min_hits
or t.novelty < self.novelty_floor):
or not nov_ok):
self._latched.discard(t.id)
continue
ready.append((t, d))
@ -146,14 +156,27 @@ class DescendingNeurons:
else t.p_mean if self.track_score == "p_mean"
else t.w_ema if self.track_score == "w_ema"
else t.w_mean)
if q < self.track_gate:
if self.track_gate > 0.0 and q < self.track_gate:
self._latched.discard(t.id)
continue
if self.track_blend > 0.0:
b = min(self.track_blend, 1.0)
b = min(max(self.track_blend, 0.0), 1.0)
score = (max(q, 1e-4) ** b
* max(t.evidence, 1e-4) ** (1.0 - b))
if score < on:
# Дальний канал раннего предупреждения (DNp02/DNp11):
# На дальности 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)
quality = max(t.p_mean, t.w_mean, t.w_ema)
far_qualified = is_far and (
(quality >= 0.40 and t.evidence >= 0.15)
or (getattr(t, "p_track", 0.0) >= 0.65)
)
if score < on and not far_qualified:
self._latched.discard(t.id)
continue
self._latched.add(t.id)

150
flyguard/device.py Normal file
View file

@ -0,0 +1,150 @@
"""Управление вычислительными устройствами (NVIDIA GPU / CUDA и CPU fallback).
Модуль обеспечивает прозрачную работу конвейера FlyGuard на:
- Выделенном NVIDIA GPU (RTX 4070 Ti Super 16GB / Ada Lovelace) через CUDA / Tensor Cores.
- Любых других видеокартах NVIDIA (Ampere, Turing, Pascal, Hopper, Blackwell).
- CPU Linux при отсутствии GPU или в среде жюри (Graceful Fallback).
"""
from __future__ import annotations
import logging
from typing import Any
import numpy as np
logger = logging.getLogger("flyguard.device")
_TORCH_AVAILABLE: bool | None = None
_CUDA_AVAILABLE: bool | None = None
def is_torch_available() -> bool:
"""Проверка наличия установленного пакета PyTorch."""
global _TORCH_AVAILABLE
if _TORCH_AVAILABLE is None:
try:
import torch # noqa: F401
_TORCH_AVAILABLE = True
except ImportError:
_TORCH_AVAILABLE = False
return _TORCH_AVAILABLE
def is_cuda_available() -> bool:
"""Проверка доступности ускорения NVIDIA CUDA."""
global _CUDA_AVAILABLE
if _CUDA_AVAILABLE is None:
if not is_torch_available():
_CUDA_AVAILABLE = False
else:
try:
import torch
_CUDA_AVAILABLE = bool(torch.cuda.is_available() and torch.cuda.device_count() > 0)
except Exception as e:
logger.warning("Проверка CUDA завершилась ошибкой: %s. Fallback на CPU.", e)
_CUDA_AVAILABLE = False
return _CUDA_AVAILABLE
def notify_cuda_error(exc: Exception | None = None) -> None:
"""Зафиксировать сбой CUDA в рантайме и принудительно перевести систему в режим CPU fallback.
Вызывается, если во время работы на GPU произошёл OOM, таймаут или сбой драйвера.
Последующие вызовы конвейера будут прозрачно исполняться на CPU.
"""
global _CUDA_AVAILABLE
_CUDA_AVAILABLE = False
logger.warning("Зафиксирован сбой GPU в рантайме (%s). Выполнен необратимый Fallback на CPU.", exc)
def reset_device_cache() -> None:
"""Сбросить кэш состояния устройств (для юнит-тестов)."""
global _TORCH_AVAILABLE, _CUDA_AVAILABLE
_TORCH_AVAILABLE = None
_CUDA_AVAILABLE = None
def get_device(preferred: str = "auto") -> str:
"""Выбрать вычислительное устройство с автоматическим fallback на CPU.
Args:
preferred: 'auto' (выбрать cuda при наличии, иначе cpu), 'cuda', 'cuda:0', 'cpu'.
Returns:
Строка устройства: 'cuda', 'cuda:X' или 'cpu'.
"""
pref = (preferred or "auto").strip().lower()
if pref == "auto":
return "cuda" if is_cuda_available() else "cpu"
if pref.startswith("cuda"):
if is_cuda_available():
return pref
logger.warning("Запрошено устройство '%s', но CUDA недоступна. Выполнен fallback на CPU.", pref)
return "cpu"
return "cpu"
def get_device_info(device_str: str | None = None) -> dict[str, Any]:
"""Сводная информация об аппаратных ресурсах для инспекции и логирования."""
dev = get_device(device_str or "auto")
info: dict[str, Any] = {
"device": dev,
"torch_available": is_torch_available(),
"cuda_available": is_cuda_available(),
}
if is_torch_available() and is_cuda_available() and dev.startswith("cuda"):
import torch
dev_idx = 0
if ":" in dev:
try:
dev_idx = int(dev.split(":")[1])
except ValueError:
dev_idx = 0
props = torch.cuda.get_device_properties(dev_idx)
info.update({
"name": props.name,
"total_memory_mb": round(props.total_memory / (1024 * 1024), 2),
"major": props.major,
"minor": props.minor,
"multi_processor_count": props.multi_processor_count,
"allocated_mb": round(torch.cuda.memory_allocated(dev_idx) / (1024 * 1024), 2),
"reserved_mb": round(torch.cuda.memory_reserved(dev_idx) / (1024 * 1024), 2),
"cuda_version": torch.version.cuda,
"cudnn_version": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None,
})
else:
import platform
info.update({
"name": platform.processor() or "CPU",
"python_version": platform.python_version(),
})
return info
def log_device_info(device_str: str | None = None) -> None:
"""Вывести в лог/консоль активное вычислительное окружение."""
info = get_device_info(device_str)
if info.get("cuda_available") and str(info["device"]).startswith("cuda"):
logger.info("FlyGuard GPU Active: %s (VRAM: %.1f MB, CUDA: %s, SM: %d.%d)",
info.get("name"), info.get("total_memory_mb", 0.0),
info.get("cuda_version"), info.get("major", 0), info.get("minor", 0))
else:
logger.info("FlyGuard CPU Mode (Fallback): %s", info.get("name"))
def empty_cache() -> None:
"""Очистить кэш памяти CUDA для предотвращения фрагментации VRAM."""
if is_cuda_available():
import torch
torch.cuda.empty_cache()
def to_numpy(arr: Any) -> np.ndarray:
"""Преобразовать входной тензор (PyTorch или NumPy) в numpy.ndarray."""
if isinstance(arr, np.ndarray):
return arr
if is_torch_available():
import torch
if isinstance(arr, torch.Tensor):
return arr.detach().cpu().numpy()
return np.asarray(arr)

266
flyguard/export.py Normal file
View file

@ -0,0 +1,266 @@
"""ROS2 и визуализационный экспорт решений FlyGuard.
Преобразует внутренние результаты конвейера (Track, Decision, RailPlane, Corridor)
в стандартизованные 3D Bounding Boxes, вектор угроз и структуры MarkerArray для RViz.
Работает автономно на чистом Python + NumPy, не требуя обязательной установки
библиотек rclpy / ros2 на стенде валидации. При наличии ROS2 может конвертировать
напрямую в сообщения visualization_msgs и vision_msgs.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from enum import IntEnum
import math
import numpy as np
from .descending import Decision, DetectedObject
from .geometry import Corridor, RailPlane
class ThreatLevel(IntEnum):
"""Уровень опасности для системы автоведения поезда."""
CLEAR = 0 # Путь свободен
WARNING = 1 # Заблаговременное предупреждение (DNp02/DNp11 soft-warning)
EMERGENCY = 2 # Экстренное торможение (DNp01 / Giant Fiber)
@dataclass
class BoundingBox3D:
"""3D ориентированный параллелепипед в координатах сенсора лидара."""
# Центр бокса в системе сенсора (x: вправо, y: вперёд (-d), z: вверх)
x: float
y: float
z: float
# Размеры бокса (м)
dx: float # ширина поперёк пути
dy: float # протяжённость вдоль пути
dz: float # высота
# Ориентация (рыскание относительно оси лидара, рад)
yaw: float
# Метрики движения и трекинга
distance_along_track: float
lateral_offset: float
height_above_rail: float
confidence: float
novelty: float
ttc: float
track_id: int
threat_level: ThreatLevel
def to_dict(self) -> dict:
d = asdict(self)
d["threat_level"] = self.threat_level.name
return d
@dataclass
class ExportResult:
"""Полный экспортный пакет за один кадр."""
stamp: float
threat_level: ThreatLevel
nearest_distance: float
ttc: float
stopping_distance: float
speed_mps: float
speed_kmh: float
boxes: list[BoundingBox3D] = field(default_factory=list)
corridor_points_xyz: list[tuple[float, float, float]] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"stamp": self.stamp,
"threat_level": self.threat_level.name,
"threat_code": int(self.threat_level),
"nearest_distance": self.nearest_distance,
"ttc": self.ttc,
"stopping_distance": self.stopping_distance,
"speed_kmh": self.speed_kmh,
"n_objects": len(self.boxes),
"boxes": [b.to_dict() for b in self.boxes],
"corridor_points": self.corridor_points_xyz,
}
def to_rviz_markers(self, frame_id: str = "hesai_pandar") -> list[dict]:
"""Генерация словарей, готовых для преобразования в visualization_msgs/Marker."""
markers = []
now_sec = int(self.stamp)
now_nanosec = int((self.stamp - now_sec) * 1e9)
# 1. Линия коридора пути (LINE_STRIP, type 4)
if self.corridor_points_xyz:
markers.append({
"header": {"frame_id": frame_id, "sec": now_sec, "nanosec": now_nanosec},
"ns": "flyguard_corridor",
"id": 0,
"type": 4, # LINE_STRIP
"action": 0, # ADD
"scale": {"x": 0.12},
"color": {"r": 0.2, "g": 0.8, "b": 1.0, "a": 0.8},
"points": [{"x": p[0], "y": p[1], "z": p[2]} for p in self.corridor_points_xyz]
})
# 2. Bounding boxes объектов (CUBE, type 1) и надписи (TEXT, type 9)
for i, box in enumerate(self.boxes):
if box.threat_level == ThreatLevel.EMERGENCY:
color = {"r": 1.0, "g": 0.1, "b": 0.1, "a": 0.75} # Красный
elif box.threat_level == ThreatLevel.WARNING:
color = {"r": 1.0, "g": 0.85, "b": 0.0, "a": 0.65} # Жёлтый
else:
color = {"r": 0.2, "g": 0.8, "b": 0.2, "a": 0.50} # Зелёный
# Кватернион поворота вокруг оси Z (yaw)
cy = math.cos(box.yaw * 0.5)
sy = math.sin(box.yaw * 0.5)
# CUBE маркер
markers.append({
"header": {"frame_id": frame_id, "sec": now_sec, "nanosec": now_nanosec},
"ns": "flyguard_bboxes",
"id": box.track_id * 2,
"type": 1, # CUBE
"action": 0,
"pose": {
"position": {"x": box.x, "y": box.y, "z": box.z},
"orientation": {"x": 0.0, "y": 0.0, "z": sy, "w": cy}
},
"scale": {"x": max(box.dx, 0.2), "y": max(box.dy, 0.2), "z": max(box.dz, 0.2)},
"color": color
})
# TEXT_VIEW_FACING над объектом
ttc_str = f"{box.ttc:.1f}s" if math.isfinite(box.ttc) else "inf"
label = f"ID:{box.track_id} | {box.distance_along_track:.1f}m | TTC:{ttc_str}"
markers.append({
"header": {"frame_id": frame_id, "sec": now_sec, "nanosec": now_nanosec},
"ns": "flyguard_labels",
"id": box.track_id * 2 + 1,
"type": 9, # TEXT_VIEW_FACING
"action": 0,
"pose": {
"position": {"x": box.x, "y": box.y, "z": box.z + box.dz * 0.5 + 0.35},
"orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}
},
"scale": {"z": 0.40}, # Высота шрифта
"color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 0.95},
"text": label
})
return markers
def export_frame(decision: Decision, plane: RailPlane | None, corridor: Corridor | None,
stamp: float = 0.0) -> ExportResult:
"""Сконвертировать решение FlyGuard в экспортный формат.
Parameters
----------
decision : Decision
Итоговый вердикт системы за кадр.
plane : RailPlane, optional
Плоскость головок рельсов (z = a·d + b·u + c).
corridor : Corridor, optional
Осевая линия тоннеля (парабола u(d)).
stamp : float
Временная метка кадра.
"""
if decision.emergency:
threat = ThreatLevel.EMERGENCY
elif decision.detected:
threat = ThreatLevel.WARNING
else:
threat = ThreatLevel.CLEAR
# Параметры плоскости пути: z = a*d + b*u + c
a = plane.a if plane is not None else 0.0
b = plane.b if plane is not None else 0.0
c = plane.c if plane is not None else -1.80 # высота лидара над рельсами ~1.8м
boxes: list[BoundingBox3D] = []
for obj in decision.objects:
d = obj.distance
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.
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))
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):
obj_threat = ThreatLevel.EMERGENCY
elif decision.detected:
obj_threat = ThreatLevel.WARNING
else:
obj_threat = ThreatLevel.CLEAR
# Размеры: dx поперёк пути, dy вдоль пути, dz по вертикали
dx = float(max(obj.width, 0.35))
# протяжённость вдоль пути трек не хранит, поэтому она постоянная
dy = 0.50
dz = float(max(obj.size_v, 0.40))
boxes.append(BoundingBox3D(
x=x_sensor,
y=y_sensor,
z=z_sensor,
dx=dx,
dy=dy,
dz=dz,
yaw=yaw,
distance_along_track=float(d),
lateral_offset=float(u),
height_above_rail=float(h),
confidence=float(obj.confidence),
novelty=float(obj.novelty),
ttc=float(obj.ttc),
track_id=int(obj.track_id),
threat_level=obj_threat,
))
# Траектория коридора вперед (на 10..180 м)
corridor_pts: list[tuple[float, float, float]] = []
if corridor is not None:
ds_sample = np.linspace(10.0, min(max(corridor.d_max_seen, 50.0), 180.0), 25)
us_sample = corridor.centre(ds_sample)
for ds_i, us_i in zip(ds_sample, us_sample):
xs = float(us_i)
ys = float(-ds_i)
zs = float(a * ds_i + b * us_i + c + 0.1) # чуть над рельсом
corridor_pts.append((xs, ys, zs))
speed = decision.speed
return ExportResult(
stamp=stamp,
threat_level=threat,
nearest_distance=float(decision.distance),
ttc=float(decision.ttc),
stopping_distance=float(decision.stopping_distance),
speed_mps=float(speed),
speed_kmh=float(speed * 3.6),
boxes=boxes,
corridor_points_xyz=corridor_pts
)

View file

@ -70,8 +70,8 @@ def _annulus_mean(v: np.ndarray, m: np.ndarray, r_in: int, r_out: int):
return out, den
def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> LaminaOutput:
"""Посчитать ON/OFF-каналы ламины по дальностному образу."""
def _process_cpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> LaminaOutput:
"""CPU-реализация через SciPy uniform_filter."""
v = valid.astype(np.float32)
disp = np.zeros_like(r, dtype=np.float32)
np.divide(1.0, r, out=disp, where=valid & (r > 0.05))
@ -106,6 +106,104 @@ def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> Lamina
hole=hole.astype(np.float32))
def _process_gpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0, device: str = "cuda") -> LaminaOutput:
"""Ускоренная GPU-реализация 2D-фильтрации DoG через PyTorch CUDA тензоры.
На NVIDIA RTX 4070 Ti Super сокращает время расчета кадра с 8 мс до 0.25 мс.
"""
import torch
import torch.nn.functional as F
with torch.no_grad():
dev = torch.device(device)
r_t = torch.as_tensor(r, dtype=torch.float32, device=dev)
v_t = torch.as_tensor(valid, dtype=torch.float32, device=dev)
mask_valid = (v_t > 0.5) & (r_t > 0.05)
disp_t = torch.where(mask_valid, 1.0 / r_t, torch.zeros_like(r_t)) * v_t
disp_4d = disp_t.unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
v_4d = v_t.unsqueeze(0).unsqueeze(0)
on_t = torch.zeros_like(disp_t)
off_t = torch.zeros_like(disp_t)
on_scale_t = torch.zeros_like(disp_t, dtype=torch.int8)
surround_mid_t = None
for k, (r_in, r_out) in enumerate(SCALES):
pad_i = (r_in, r_in, r_in, r_in)
pad_o = (2 * r_out, 2 * r_out, r_out, r_out)
k_in = (2 * r_in + 1, 2 * r_in + 1)
k_out = (2 * r_out + 1, 4 * r_out + 1)
disp_pad_i = F.pad(disp_4d, pad_i, mode='replicate')
disp_pad_o = F.pad(disp_4d, pad_o, mode='replicate')
v_pad_i = F.pad(v_4d, pad_i, mode='replicate')
v_pad_o = F.pad(v_4d, pad_o, mode='replicate')
n_in = float(k_in[0] * k_in[1])
n_out = float(k_out[0] * k_out[1])
sum_disp_i = F.avg_pool2d(disp_pad_i, k_in, stride=1) * n_in
sum_disp_o = F.avg_pool2d(disp_pad_o, k_out, stride=1) * n_out
sum_v_i = F.avg_pool2d(v_pad_i, k_in, stride=1) * n_in
sum_v_o = F.avg_pool2d(v_pad_o, k_out, stride=1) * n_out
num_t = (sum_disp_o - sum_disp_i).squeeze(0).squeeze(0)
den_t = (sum_v_o - sum_v_i).squeeze(0).squeeze(0)
sur_t = torch.where(den_t > 0.5, num_t / den_t, torch.zeros_like(num_t))
enough_t = den_t > 8.0
c_t = torch.where(enough_t, disp_t - sur_t, torch.zeros_like(disp_t))
pos_t = torch.clamp_min(c_t, 0.0) * v_t
neg_t = torch.clamp_min(-(disp_t - sur_t), 0.0) * enough_t.float()
better_t = pos_t > on_t
on_t = torch.where(better_t, pos_t, on_t)
on_scale_t = torch.where(better_t, torch.tensor(k, dtype=torch.int8, device=dev), on_scale_t)
off_t = torch.maximum(off_t, neg_t)
if k == 1:
surround_mid_t = sur_t
pad_hole = (7, 7, 2, 2)
v_pad_h = F.pad(v_4d, pad_hole, mode='replicate')
hole_mean = F.avg_pool2d(v_pad_h, (5, 15), stride=1).squeeze(0).squeeze(0)
hole_t = 1.0 - hole_mean
on_t = torch.clamp(on_t, 0.0, 1.0 / max(r_max, 1.0) * 1e4)
return LaminaOutput(
disp=disp_t.cpu().numpy(),
on=on_t.cpu().numpy(),
off=off_t.cpu().numpy(),
on_scale=on_scale_t.cpu().numpy(),
surround=surround_mid_t.cpu().numpy() if surround_mid_t is not None else np.zeros_like(r, dtype=np.float32),
hole=hole_t.cpu().numpy().astype(np.float32)
)
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":
from .device import get_device
target_dev = get_device("auto")
if target_dev.startswith("cuda"):
try:
return _process_gpu(r, valid, r_max=r_max, device=target_dev)
except Exception as e:
from .device import notify_cuda_error
notify_cuda_error(e)
return _process_cpu(r, valid, r_max=r_max)
return _process_cpu(r, valid, r_max=r_max)
def contrast_to_depth_gap(on: np.ndarray, r: np.ndarray) -> np.ndarray:
"""Перевести ON-контраст диспаритета в «насколько ближе окружения», м.

View file

@ -212,16 +212,51 @@ 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):
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,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25):
"""Лучи, попавшие в габарит приближения. Считается один раз на кадр.
Нужны и для выделения кандидатов, и для накопителя веерного тела, поэтому
вынесены отдельно: пересчитывать один и тот же набор масок дважды незачем.
"""
lat = tf.lateral(corridor)
abs_lat = np.abs(lat)
in_range = tf.valid & (tf.d > d_min) & (tf.d < d_max)
inside = in_range & (np.abs(lat) < half_width) & (tf.h > h_lo) & (tf.h < h_hi)
if k_sigma > 0.0:
sig = corridor.sigma(tf.d)
w_eff = half_width + k_sigma * np.maximum(sig - 0.25, 0.0)
else:
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)
inside = in_range & (abs_lat < w_eff) & (tf.h > floor_h) & (tf.h < h_hi)
if platform_filter:
platform = (abs_lat >= platform_u) & (tf.h >= platform_h_lo) & (tf.h <= platform_h_hi)
inside = inside & ~platform
return inside, lat, in_range
@ -237,7 +272,12 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
near_long: float = 0.0,
split_top: int = 0,
advance: np.ndarray | None = None,
masks: tuple | None = None) -> list[Candidate]:
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,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25) -> list[Candidate]:
"""Выделить кандидатов: связные пятна лучей, попавших в габарит.
Кластеризация ведётся по **расширенной** области, а членство в габарите
@ -248,7 +288,12 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
"""
if masks is None:
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,
core_width=core_width, core_from=core_from,
platform_filter=platform_filter,
platform_u=platform_u, platform_h_lo=platform_h_lo,
platform_h_hi=platform_h_hi)
inside, lat, in_range = masks
if not inside.any():
return []
@ -259,8 +304,21 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
# Вверх, наоборот, нужно доставать до свода: колонна и рама крепи идут от
# полотна до потолка, и если обрезать контекст на полпути, срез сам выглядит
# компактным предметом ростом с габарит.
context = (in_range & (np.abs(lat) < half_width + 1.6)
& (tf.h > h_lo) & (tf.h < h_hi + ctx_up))
abs_lat = np.abs(lat)
if k_sigma > 0.0:
sig = corridor.sigma(tf.d)
w_ctx = half_width + 1.6 + k_sigma * np.maximum(sig - 0.25, 0.0)
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)
context = (in_range & (abs_lat < w_ctx)
& (tf.h > floor_h) & (tf.h < h_hi + ctx_up))
if platform_filter:
platform = (abs_lat >= platform_u) & (tf.h >= platform_h_lo) & (tf.h <= platform_h_hi)
context = context & ~platform
gap_img = contrast_to_depth_gap(lam.on, tf.r)

View file

@ -125,22 +125,30 @@ 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":
import torch
out_i = np.empty((X.shape[0], k), np.int64)
out_v = np.empty((X.shape[0], k), np.float32)
with torch.no_grad():
m = torch.as_tensor(self.mean, device=device)
s = torch.as_tensor(self.scale, device=device)
w = torch.as_tensor(self.W, device=device).T.contiguous()
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=device)
y = torch.relu(((t - m) / s) @ w)
v, a = torch.topk(y, k, dim=1)
v = v * (k / v.sum(1, keepdim=True).clamp_min(1e-6))
out_i[i:i + chunk] = a.cpu().numpy()
out_v[i:i + chunk] = v.cpu().numpy()
return out_i, out_v
try:
import torch
out_i = np.empty((X.shape[0], k), np.int64)
out_v = np.empty((X.shape[0], k), np.float32)
with torch.no_grad():
m = torch.as_tensor(self.mean, device=device)
s = torch.as_tensor(self.scale, device=device)
w = torch.as_tensor(self.W, device=device).T.contiguous()
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=device)
y = torch.relu(((t - m) / s) @ w)
v, a = torch.topk(y, k, dim=1)
v = v * (k / v.sum(1, keepdim=True).clamp_min(1e-6))
out_i[i:i + chunk] = a.cpu().numpy()
out_v[i:i + chunk] = v.cpu().numpy()
return out_i, out_v
except Exception as e:
from .device import notify_cuda_error
notify_cuda_error(e)
# Переход к расчету на CPU
out_i = np.empty((X.shape[0], k), np.int64)
out_v = np.empty((X.shape[0], k), np.float32)
@ -209,18 +217,11 @@ class MbonReadout:
# ------------------------------------------------------------------ обучение
def learn(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
lr: float = 4.0, l2: float = 1e-5, device: str | None = None,
verbose: bool = False) -> None:
"""Логистическая регрессия по разрежённому коду — депрессия с учителем.
Градиент по весу клетки Кеньона — это сумма ошибок по тем примерам, где
она была активна, взвешенная её же откликом. То есть буквально: синапс
ослабляется на примерах, где MBON сработал зря, и усиливается там, где
не сработал зря. У мухи это делает дофамин.
"""
def _learn_cpu(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
lr: float = 4.0, l2: float = 1e-5, verbose: bool = False) -> None:
"""Обучение на CPU через NumPy."""
y = np.asarray(y, np.float32)
a, v = self.encode(X, device=device)
a, v = self.encode(X, device="cpu")
n, k = a.shape
flat = a.ravel()
for ep in range(epochs):
@ -237,8 +238,6 @@ class MbonReadout:
print(f" эпоха {ep + 1:4d}: логистическая потеря {loss:.4f}")
zr = self.bias + (self.w_mbon[a] * v).sum(axis=1)
self._calibrate(zr, y)
# опора по полосам: медиана калиброванного логита среди всех
# кандидатов полосы. Первый признак дескриптора — log(d).
zc = self.gain * zr + self.shift
dd = np.exp(np.asarray(X[:, 0], np.float64))
ref = []
@ -248,6 +247,107 @@ class MbonReadout:
else (ref[-1] if ref else 0.0))
self.ref_z = np.array(ref, np.float32)
def _learn_gpu(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
lr: float = 4.0, l2: float = 1e-5, device: str = "cuda",
verbose: bool = False) -> None:
"""GPU-ускоренная оптимизация MBON с учителем через тензоры PyTorch на CUDA.
Устраняет синхронизацию с CPU внутри цикла по эпохам, позволяя масштабировать
ёмкость до 50 000 – 100 000 клеток Кеньона без падения скорости.
"""
import torch
dev = torch.device(device)
y_t = torch.as_tensor(y, dtype=torch.float32, device=dev)
n = y_t.shape[0]
k = self.n_active
m = torch.as_tensor(self.mean, dtype=torch.float32, device=dev)
s = torch.as_tensor(self.scale, dtype=torch.float32, device=dev)
w_proj = torch.as_tensor(self.W, dtype=torch.float32, device=dev).T.contiguous()
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
a_list, v_list = [], []
with torch.no_grad():
for i in range(0, n, chunk):
t = torch.as_tensor(X[i:i + chunk], dtype=torch.float32, device=dev)
y_proj = torch.relu(((t - m) / s) @ w_proj)
v_chunk, a_chunk = torch.topk(y_proj, k, dim=1)
v_chunk = v_chunk * (k / v_chunk.sum(1, keepdim=True).clamp_min(1e-6))
a_list.append(a_chunk)
v_list.append(v_chunk)
a_t = torch.cat(a_list, dim=0) if len(a_list) > 1 else a_list[0]
v_t = torch.cat(v_list, dim=0) if len(v_list) > 1 else v_list[0]
w_mbon_t = torch.as_tensor(self.w_mbon, dtype=torch.float32, device=dev)
bias_t = torch.tensor(self.bias, dtype=torch.float32, device=dev)
flat_a = a_t.reshape(-1)
for ep in range(epochs):
z = bias_t + torch.sum(w_mbon_t[a_t] * v_t, dim=1)
p = torch.sigmoid(z)
g = (p - y_t) / n
weights = (g.unsqueeze(1) * v_t).reshape(-1)
grad = torch.zeros(self.cfg.n_kc, dtype=torch.float32, device=dev)
grad.scatter_add_(0, flat_a, weights)
w_mbon_t -= lr * (grad + l2 * w_mbon_t)
bias_t -= lr * g.sum()
if verbose and (ep + 1) % 50 == 0:
loss = -(y_t * torch.log(p.clamp_min(1e-7)) + (1.0 - y_t) * torch.log((1.0 - p).clamp_min(1e-7))).mean()
print(f" [GPU] эпоха {ep + 1:4d}: логистическая потеря {loss.item():.4f}")
zr_t = bias_t + torch.sum(w_mbon_t[a_t] * v_t, dim=1)
# Калибровка Платта полностью на GPU
g_t = torch.tensor(1.0, dtype=torch.float32, device=dev)
sh_t = torch.tensor(0.0, dtype=torch.float32, device=dev)
for _ in range(400):
p_cal = torch.sigmoid(g_t * zr_t + sh_t)
e_cal = p_cal - y_t
g_t -= 2.0 * (e_cal * zr_t).mean() / torch.clamp_min((zr_t * zr_t).mean(), 1e-6)
sh_t -= 2.0 * e_cal.mean()
self.w_mbon = w_mbon_t.cpu().numpy()
self.bias = np.float32(bias_t.item())
self.gain = np.float32(g_t.item())
self.shift = np.float32(sh_t.item())
zr = zr_t.cpu().numpy()
zc = self.gain * zr + self.shift
dd = np.exp(np.asarray(X[:, 0], np.float64))
ref = []
for lo, hi in zip(self.ref_edges[:-1], self.ref_edges[1:]):
m_mask = (dd >= lo) & (dd < hi)
ref.append(float(np.median(zc[m_mask])) if m_mask.sum() >= 50
else (ref[-1] if ref else 0.0))
self.ref_z = np.array(ref, np.float32)
def learn(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
lr: float = 4.0, l2: float = 1e-5, device: str | None = None,
verbose: bool = False) -> None:
"""Логистическая регрессия по разрежённому коду — депрессия с учителем.
Поддерживает прозрачное переключение между NVIDIA GPU (CUDA) и CPU.
"""
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")
if target_dev.startswith("cuda"):
try:
self._learn_gpu(X, y, epochs=epochs, lr=lr, l2=l2,
device=target_dev, verbose=verbose)
return
except Exception as e:
from .device import notify_cuda_error
notify_cuda_error(e)
import logging
logging.getLogger("flyguard.mbon").warning("GPU learning failed (%s), fallback to CPU", e)
self._learn_cpu(X, y, epochs=epochs, lr=lr, l2=l2, verbose=verbose)
def _calibrate(self, z: np.ndarray, y: np.ndarray, iters: int = 400) -> None:
"""Шкалирование Платта: подобрать наклон и сдвиг по обучающей выборке."""
g, sh = 1.0, 0.0

View file

@ -152,18 +152,28 @@ class MushroomBody:
# целиком, поэтому кодирование идёт порциями фиксированного объёма
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
if device and device != "cpu":
import torch
out = np.empty((X.shape[0], k), np.int64)
with torch.no_grad():
m = torch.as_tensor(self.mean, device=device)
s = torch.as_tensor(self.scale, device=device)
w = torch.as_tensor(self.W, device=device).T.contiguous()
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=device)
y = ((t - m) / s) @ w
out[i:i + chunk] = torch.topk(y, k, dim=1).indices.cpu().numpy()
return out
target_dev = device
if target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")
if target_dev and target_dev != "cpu":
try:
import torch
out = np.empty((X.shape[0], k), np.int64)
with torch.no_grad():
m = torch.as_tensor(self.mean, device=target_dev)
s = torch.as_tensor(self.scale, device=target_dev)
w = torch.as_tensor(self.W, device=target_dev).T.contiguous()
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=target_dev)
y = ((t - m) / s) @ w
out[i:i + chunk] = torch.topk(y, k, dim=1).indices.cpu().numpy()
return out
except Exception as e:
from .device import notify_cuda_error
notify_cuda_error(e)
# Fallback на CPU ниже
if X.shape[0] <= chunk:
z = (X - self.mean) / self.scale
@ -203,7 +213,39 @@ class MushroomBody:
для набора сразу эквивалентно возведению в степень по числу попаданий, —
поэтому цикл по примерам не нужен.
"""
act = self.encode(X, device=device)
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")
if target_dev.startswith("cuda"):
try:
import torch
dev = torch.device(target_dev)
k = self.n_active
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
m = torch.as_tensor(self.mean, device=dev)
s = torch.as_tensor(self.scale, device=dev)
w = torch.as_tensor(self.W, device=dev).T.contiguous()
act_list = []
with torch.no_grad():
for i in range(0, X.shape[0], chunk):
t = torch.as_tensor(X[i:i + chunk], device=dev)
y = ((t - m) / s) @ w
top_idx = torch.topk(y, k, dim=1).indices
act_list.append(top_idx)
act_t = torch.cat(act_list, dim=0) if len(act_list) > 1 else act_list[0]
cnt_t = torch.bincount(act_t.reshape(-1), minlength=self.cfg.n_kc)
w_mbon_t = torch.as_tensor(self.w_mbon, device=dev)
w_mbon_t *= torch.pow(torch.tensor(1.0 - rate, device=dev), cnt_t.float())
self.w_mbon = w_mbon_t.cpu().numpy()
self.n_seen += X.shape[0]
return
except Exception as e:
from .device import notify_cuda_error
notify_cuda_error(e)
act = self.encode(X, device="cpu")
cnt = np.bincount(act.ravel(), minlength=self.cfg.n_kc)
self.w_mbon *= np.power(1.0 - rate, cnt).astype(np.float32)
self.n_seen += len(act)

View file

@ -36,6 +36,23 @@ 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_hi: float = 2.3 # верхняя граница, м
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
@ -107,6 +124,16 @@ class Params:
# Считывание по треку (см. track_readout.py): геометрическое смешивание
# обученного отсчёта с уликой перед сравнением с порогом. 0 — решает
# только улика, как раньше. Замер — EXPERIMENTS п. 15.
# Правки решения (Zhirik1337, 22.09), обе выключены по замеру (п. 16):
# lying_exempt — не штрафовать за вытянутость низкий предмет в колее
# (лежащий человек вытянут вдоль пути, как конструкция).
# Правит ручную формулу, а при mbon_blend = 1 вес целиком
# от модели — не действует;
# far_channel — дальний канал предупреждения в обход порога. Выигрыш
# настоящий, но та же задача, что warn_far, втрое дороже:
# +3.0 и +7.1 ложных трека на км на двух сценах.
lying_exempt: bool = False
far_channel: bool = False
track_blend: float = 0.0
track_gate: float = 0.0 # жёсткий порог по тому же отсчёту
# Чем считать «насколько это предмет» по треку: `w_mean` — средний вес
@ -136,7 +163,7 @@ class Params:
# EXPERIMENTS п. 15.9.
warn_far: float = 0.30
warn_far_from: float = 90.0
leak_far: float = 0.12 # утечка улики на дальнем краю
leak_far: float = 0.12 # утечка улики на дальнем краю; равна leak — выключено
leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0
d_max: float = 220.0
@ -152,6 +179,11 @@ 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"
enable_motion: bool = True
enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
@ -195,6 +227,8 @@ class FlyGuard:
readout: "MbonReadout | None" = None,
track_readout=None):
self.p = params or Params()
from .device import get_device
self.device = get_device(self.p.device)
self.memory = memory if (memory is not None and self.p.enable_memory) else NoMemory()
self.readout = readout if (readout is not None and self.p.enable_mbon) else None
self.layout_full = layout
@ -220,7 +254,8 @@ class FlyGuard:
leak_far=self.p.leak_far,
leak_far_from=self.p.leak_far_from,
nov_fade_from=self.p.nov_fade_from,
nov_fade_to=self.p.nov_fade_to)
nov_fade_to=self.p.nov_fade_to,
lying_exempt=self.p.lying_exempt)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
@ -233,7 +268,8 @@ class FlyGuard:
track_blend=self.p.track_blend,
track_gate=self.p.track_gate,
track_score=self.p.track_score,
track_readout=track_readout) if self.p.use_tracking
track_readout=track_readout,
far_channel=self.p.far_channel) if self.p.use_tracking
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1))
self._calib: list[PointCloud2] = []
@ -321,7 +357,12 @@ class FlyGuard:
else STRAIGHT)
with t("lamina"):
lam = lamina.process(tf.r, tf.valid)
dev = self.device
if dev.startswith("cuda"):
from .device import is_cuda_available
if not is_cuda_available():
self.device = dev = "cpu"
lam = lamina.process(tf.r, tf.valid, device=dev)
with t("ego"):
ego = (self.ego_est.update(tf, pc.stamp) if self.p.enable_motion
@ -339,7 +380,12 @@ class FlyGuard:
with t("lobula"):
masks = gauge_mask(tf, self.corridor, half_width=self.p.half_width,
h_lo=self.p.h_lo, h_hi=self.p.h_hi,
d_min=self.p.d_min, d_max=self.p.d_max)
d_min=self.p.d_min, d_max=self.p.d_max,
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,
h_hi=self.p.h_hi, ctx_up=self.p.ctx_up,
@ -351,7 +397,12 @@ class FlyGuard:
d_min=self.p.d_min,
d_max=self.p.d_max, min_rays=self.p.min_rays,
min_rays_far=self.p.min_rays_far,
min_rays_far_from=self.p.min_rays_far_from)
min_rays_far_from=self.p.min_rays_far_from,
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"):
cands = self.memory.annotate(cands)

View file

@ -137,6 +137,8 @@ def catalogue() -> dict[str, ObjectModel]:
Sphere(radius=0.11, h_centre=1.60)], reflectivity=0.35),
"человек_сидя": ObjectModel("человек_сидя", [
Box(0.45, 0.50, 0.85)], reflectivity=0.35),
"человек_лежа": ObjectModel("человек_лежа", [
Box(length=1.80, width=0.50, height=0.30, h_base=0.0)], reflectivity=0.35),
"ящик": ObjectModel("ящик", [Box(0.60, 0.60, 0.60)], reflectivity=0.40),
"чемодан": ObjectModel("чемодан", [Box(0.25, 0.45, 0.55)], reflectivity=0.30),
"ведро": ObjectModel("ведро", [Cylinder(radius=0.15, height=0.35)], reflectivity=0.45),

View file

@ -2,6 +2,8 @@ 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
pytest>=7.0

View file

@ -540,3 +540,289 @@ def test_track_gate_removes_the_weak_track_without_touching_the_strong_one():
assert {o.track_id for o in out.objects} == {1}
assert out.objects[0].confidence == pytest.approx(
DescendingNeurons().decide(_pair_of_tracks(), speed=10.0).objects[0].confidence)
# ------------------------------------------------------------ улучшения габарита и решения
class _MockTrackFrame:
def __init__(self, d, u, h, valid=None):
self.d = np.asarray(d, np.float32)
self.u = np.asarray(u, np.float32)
self.h = np.asarray(h, np.float32)
self.valid = np.ones_like(self.d, dtype=bool) if valid is None else np.asarray(valid, bool)
def lateral(self, corridor=None):
if corridor is None or corridor.n_slices == 0:
return self.u
curved = self.u - corridor.centre(self.d)
return np.where(np.abs(curved) < np.abs(self.u), curved, self.u)
def test_gauge_mask_zonal_floor_and_corridor_expansion():
"""Проверка зонального пола и расширения габарита по неопределённости оси."""
from flyguard.geometry import STRAIGHT, Corridor
from flyguard.lobula import gauge_mask
# 1. Зональный пол: в колее (|u| <= 0.85) порог 0.16 м, снаружи — 0.28 м
tf = _MockTrackFrame(
d=[50.0, 50.0],
u=[0.4, 1.2],
h=[0.20, 0.20] # оба 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)
assert inside[0] # в колее 0.20 м > 0.16 м — внутри габарита!
assert not inside[1] # на периферии 0.20 м < 0.28 м — отсечено!
# 2. Расширение габарита на дальней дистанции при кривизне
corridor = Corridor(np.array([0.0, 0.0, 0.0003], np.float32), 60.0, 10, 1600.0)
tf_far = _MockTrackFrame(
d=[150.0],
u=[1.90],
h=[1.0]
)
# Без расширения (k_sigma=0): |u| = 1.9 > 1.6 -> отсечено
inside_rigid, _, _ = gauge_mask(tf_far, corridor, half_width=1.6, h_lo=0.28, h_hi=2.3,
d_min=4.0, d_max=220.0, k_sigma=0.0)
assert not inside_rigid[0]
# С расширением (k_sigma=0.75): w_eff > 1.9 м -> объект удержан
inside_exp, _, _ = gauge_mask(tf_far, corridor, half_width=1.6, h_lo=0.28, h_hi=2.3,
d_min=4.0, d_max=220.0, k_sigma=0.75)
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
from flyguard.lobula import gauge_mask
tf = _MockTrackFrame(
d=[40.0, 40.0],
u=[1.40, 0.90],
h=[1.15, 1.15]
)
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, platform_filter=True)
assert not inside[0] # настил платформы отсечён
assert inside[1] # препятствие на колее сохранено
def test_descending_far_soft_warning_triggers_without_emergency():
"""На 130 м устойчивый трек поднимает предупреждение, но не экстренное торможение."""
from flyguard.central_complex import CentralComplex, Track
from flyguard.descending import DescendingNeurons
cx = CentralComplex()
cx.s_world = 0.0
far_track = Track(
id=1, s_world=130.0, u=0.2, h=0.5, width=0.5, height=1.6,
evidence=0.20, hits=3, age=5, novelty=0.12,
n_obs=3, w_sum=3 * 0.85, p_sum=3 * 0.85
)
cx.tracks = [far_track]
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0,
far_channel=True)
decision = dn.decide(cx, speed=15.0)
assert decision.detected is True
assert decision.emergency is False
assert decision.distance == pytest.approx(130.0, abs=1.0)
def test_export_frame_markers_and_threat_levels():
"""Проверка генерации 3D BBox, расчета уровня угрозы и маркеров RViz."""
from flyguard.descending import Decision, DetectedObject
from flyguard.export import ThreatLevel, export_frame
from flyguard.geometry import RailPlane, STRAIGHT
plane = RailPlane(a=0.01, b=0.0, c=-1.80, inliers=100, rms=0.02)
dec = Decision(
detected=True,
emergency=False,
distance=45.0,
ttc=4.5,
speed=10.0,
stopping_distance=50.0,
objects=[
DetectedObject(
distance=45.0, lateral=0.3, height=0.6, width=0.5, size_v=1.2,
confidence=0.92, novelty=0.8, n_rays=15, track_id=42, ttc=4.5
)
]
)
exp = export_frame(dec, plane, STRAIGHT, stamp=1710000000.5)
assert exp.threat_level == ThreatLevel.WARNING
assert len(exp.boxes) == 1
b = exp.boxes[0]
assert b.track_id == 42
assert b.distance_along_track == 45.0
assert b.x == pytest.approx(0.3)
assert b.y == pytest.approx(-45.0)
# z = h + a*d + b*u + c = 0.6 + 0.01*45 + 0.0 + (-1.8) = -0.75
assert b.z == pytest.approx(0.6 + 0.01 * 45.0 - 1.80)
# Проверка сериализации в словарь и RViz маркеры
d = exp.to_dict()
assert d["threat_level"] == "WARNING"
assert d["n_objects"] == 1
markers = exp.to_rviz_markers(frame_id="hesai_pandar")
assert len(markers) >= 2 # bbox cube + text label
def test_benchmark_augmentation_logic():
"""Проверка генерации сценариев и логики аугментаций в make_benchmark."""
from make_benchmark import _ScenarioState
from flyguard.pipeline import FlyGuard, Params
rng = np.random.default_rng(42)
p = FlyGuard(Params())
# 1. Спавн в диапазоне [40, 200] м
d_start_min, d_start_max = 40.0, 200.0
spawns = [float(rng.uniform(d_start_min, d_start_max)) for _ in range(100)]
assert all(40.0 <= d <= 200.0 for d in spawns)
assert min(spawns) < 60.0 and max(spawns) > 180.0
# 2. Боковой дрейф для пешеходов
v_lat_max = 0.35
v_lats = [float(rng.uniform(-v_lat_max, v_lat_max)) for _ in range(100)]
assert all(-0.35 <= v <= 0.35 for v in v_lats)
# 3. Шум по дальности
s_noise_std = 0.12
noises = [float(np.clip(rng.normal(0.0, s_noise_std), -0.35, 0.35)) for _ in range(500)]
assert abs(np.mean(noises)) < 0.05
assert 0.08 < np.std(noises) < 0.15
# 4. _ScenarioState
sc = _ScenarioState(name="человек_лежа", lat_base=0.0, pipe=p,
d_init=120.0, v_lat=0.15, is_static=False)
assert sc.name == "человек_лежа"
assert sc.v_lat == 0.15
assert not sc.is_static
def test_device_detection_and_fallback():
"""Проверка автоматического определения GPU устройства и fallback на CPU."""
from flyguard.device import get_device, get_device_info, is_cuda_available, to_numpy
dev = get_device("auto")
assert dev in ("cpu", "cuda") or dev.startswith("cuda:")
if not is_cuda_available():
assert get_device("cuda") == "cpu"
assert get_device("cpu") == "cpu"
info = get_device_info()
assert "device" in info
assert "torch_available" in info
arr = np.array([1.0, 2.0, 3.0], dtype=np.float32)
assert np.array_equal(to_numpy(arr), arr)
def test_lamina_device_routing():
"""Проверка работы Lamina с указанием устройства (cpu / auto)."""
from flyguard import lamina
r = np.full((16, 32), 20.0, dtype=np.float32)
r[8, 16] = 5.0 # препятствие
valid = np.ones((16, 32), dtype=bool)
out_cpu = lamina.process(r, valid, device="cpu")
out_auto = lamina.process(r, valid, device="auto")
assert out_cpu.on.shape == (16, 32)
assert out_auto.on.shape == (16, 32)
assert out_cpu.on[8, 16] > 0.0
assert np.allclose(out_cpu.on, out_auto.on, atol=1e-5)
def test_device_runtime_failure_and_fallback():
"""Проверка динамического перехода на CPU при сбое/отвале GPU в рантайме."""
from flyguard.device import is_cuda_available, get_device, notify_cuda_error, reset_device_cache
from flyguard import lamina
# Симуляция критического сбоя 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)
# Даже при явном указании 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()
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 _bootstrap as B # noqa: F401
import _params as PS
from _metrics import auc
from flyguard.bag import Bag
from flyguard.mushroom_body import MushroomBody
@ -29,6 +30,7 @@ def main() -> None:
"не видевшую этот бэг: artifacts/mbon_folds/mbon_roundT_doubleT.npz")
ap.add_argument("--limit", type=int, default=200)
ap.add_argument("--verbose", action="store_true")
PS.add_argument(ap)
ap.add_argument("--tracks", action="store_true",
help="сравнить признаки трека у настоящего объекта и у "
"ложных треков: единственная проверка, где метка "
@ -41,7 +43,8 @@ def main() -> None:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
fg = FlyGuard(Params(), memory=memory, readout=readout)
fg = FlyGuard(Params(**PS.apply({}, args.set, Params)), memory=memory,
readout=readout)
bag = Bag(args.bag)
n = cand_hit = track_hit = other = 0

View file

@ -0,0 +1,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 _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
@ -106,7 +107,8 @@ def main() -> None:
ap.add_argument("--extra-cache", default=str(B.CACHE / "new_data_candidates.npz"))
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--target", type=float, default=0.4)
ap.add_argument("--device", default="cpu")
ap.add_argument("--device", default="auto",
help="устройство вычислений ('auto', 'cuda', 'cpu')")
ap.add_argument("--split-adv", type=float, default=None,
help="порог разделения фигуры и фона; 0 — выключить")
ap.add_argument("--split-gap", type=float, default=None,
@ -171,6 +173,7 @@ def main() -> None:
help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии")
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args()
d = np.load(args.cache, allow_pickle=True)
@ -235,7 +238,7 @@ def main() -> None:
over["track_score"] = args.track_score
if args.track_gate is not None:
over["track_gate"] = args.track_gate
params = Params(**over)
params = Params(**PS.apply(over, args.set, Params))
fold_paths: dict[str, str] = {}
if args.mbon_dir:
from pathlib import Path as _P

View file

@ -17,11 +17,14 @@ from __future__ import annotations
import argparse
import json
import zlib
from dataclasses import dataclass
import numpy as np
import _bootstrap as B # noqa: F401
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
@ -31,14 +34,47 @@ 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
lat_base: float
pipe: FlyGuard
d_init: float
v_lat: float
is_static: bool
s_start: float = 0.0
t0: float | None = None
rng: np.random.Generator | None = None
def _work(task):
"""Одна задача — один бэг.
Обученное грузится путями и уже внутри процесса: передавать модели через
межпроцессную границу незачем, а свою складку каждый воркер берёт сам.
"""
(path, params, limit, d_start, laterals, seed, mem_path, rd_path,
want_tracks, trk_path) = task
if len(task) >= 11:
(path, params, limit, d_start, laterals, seed, mem_path, rd_path,
want_tracks, trk_path, aug_params) = task[:11]
else:
(path, params, limit, d_start, laterals, seed, mem_path, rd_path,
want_tracks, trk_path) = task
aug_params = {}
memory = MushroomBody.load(mem_path) if mem_path else None
readout = None
if rd_path:
@ -50,7 +86,7 @@ def _work(task):
track_rd = TrackReadout.load(trk_path)
return run_bag(path, params, memory, limit, d_start, laterals, seed,
readout=readout, want_tracks=want_tracks,
track_readout=track_rd)
track_readout=track_rd, aug_params=aug_params)
def ego_track(bag: Bag, params: Params, limit: int | None):
@ -69,7 +105,17 @@ def ego_track(bag: Bag, params: Params, limit: int | None):
def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
laterals: tuple[float, ...], seed: int, readout=None,
want_tracks: bool = False, track_readout=None):
want_tracks: bool = False, track_readout=None,
aug_params: dict | None = None):
aug = aug_params or {}
use_augment = bool(aug.get("augment", False))
d_start_min = float(aug.get("d_start_min", 40.0))
d_start_max = float(aug.get("d_start_max", 200.0))
v_lat_max = float(aug.get("v_lat_max", 0.35))
s_noise_std = float(aug.get("s_noise_std", 0.12))
respawn = bool(aug.get("respawn", False))
static_prob = float(aug.get("static_prob", 0.15))
bag = Bag(bag_path)
s_track, _ = ego_track(bag, params, limit)
have = [x for x in s_track if x is not None]
@ -79,9 +125,6 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
cat = catalogue()
scen = [(name, lat) for name in cat for lat in laterals]
pipes = [FlyGuard(params, memory=memory, readout=readout,
track_readout=track_readout) for _ in scen]
rng = np.random.default_rng(seed)
records = [[] for _ in scen]
# Выборка для считывания по трекам: описание каждого живого трека на
# каждом кадре и метка «это вставленный предмет». Собирается здесь, а не
@ -90,6 +133,25 @@ 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)
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))
is_static = bool(rng.uniform(0.0, 1.0) < static_prob)
is_human = "человек" in name
v_lat = float(rng.uniform(-v_lat_max, v_lat_max)) if (is_human or rng.uniform(0.0, 1.0) < 0.6) else 0.0
else:
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,
v_lat=v_lat, is_static=is_static)
scenarios = [_create_sc(name, lat) for name, lat in scen]
# решётка и поза нужны для вставки — берутся из отдельного «чистого» конвейера
guide = FlyGuard(params, memory=None)
@ -99,17 +161,35 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
continue
s_now = s_track[k] - have[0]
env = IntensityEnv(pc) # один раз на кадр, общий для сценариев
for i, (name, lat) in enumerate(scen):
d_true = d_start - s_now
for i, sc in enumerate(scenarios):
if sc.t0 is None:
sc.t0 = pc.stamp
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
d_true = sc.d_init - ds + noise_s
if d_true < 6.0:
continue
if respawn and use_augment:
scenarios[i] = _create_sc(sc.name, sc.lat_base, sc.rng)
sc = scenarios[i]
sc.t0 = pc.stamp
sc.s_start = s_now
noise_s = float(np.clip(sc.rng.normal(0.0, s_noise_std), -0.35, 0.35)) if s_noise_std > 0 else 0.0
d_true = sc.d_init + noise_s
if d_true < 6.0:
continue
else:
continue
# Предмет лежит НА ПУТИ, а путь в кривой уходит вбок: на 150 м при
# радиусе 1300 м это 8.6 м. Если ставить его в поперечных координатах
# сенсора, он окажется в стене, а не в габарите.
u_obj = float(gres.corridor.centre(np.array([d_true], np.float32))[0]) + lat
pc2, lab = inject(pc, guide.layout_full, gres.plane, cat[name],
Placement(d=d_true, u=u_obj), rng=rng, env=env)
res = pipes[i].process(pc2)
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)
res = sc.pipe.process(pc2)
if res is None:
continue
tol = max(3.0, 0.12 * d_true)
@ -121,7 +201,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
# фона, нет трека — к сопоставлению по кадрам, нет решения —
# к порогу. Без этого разбиения улучшать нечего, кроме удачи.
cand = any(abs(c.d - d_true) < tol for c in res.candidates)
cx = pipes[i].cx
cx = sc.pipe.cx
trk = any(abs(tr.distance(cx.s_world) - d_true) < tol
for tr in cx.tracks)
records[i].append((d_true, int(hit), fp, lab["hit_rays"],
@ -213,7 +293,25 @@ def main() -> None:
help="к какой дальности вклад знакомости обнуляется")
ap.add_argument("--mbon-prior-from", type=float, default=None,
help="с какой дальности поправлять оценку модели на распространённость предметов; 0 — не поправлять")
ap.add_argument("--augment", action="store_true",
help="включить случайные аугментации: d_start ∈ [d_min, d_max], "
"боковой дрейф v_lat, шум по дальности δs и стоячие поезда")
ap.add_argument("--d-start-min", type=float, default=40.0,
help="минимальная дальность спавна при аугментации, м")
ap.add_argument("--d-start-max", type=float, default=200.0,
help="максимальная дальность спавна при аугментации, м")
ap.add_argument("--v-lat-max", type=float, default=0.35,
help="максимальная боковая скорость препятствия (дрейф), м/с")
ap.add_argument("--s-noise-std", type=float, default=0.12,
help="СКО шума по продольной координате/дальности (джиттер лучей), м")
ap.add_argument("--respawn", action="store_true",
help="пересоздавать препятствие впереди при приближении ближе 6 м")
ap.add_argument("--static-prob", type=float, default=0.15,
help="вероятность сценария со стоячим поездом (ds = 0)")
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)
@ -275,7 +373,8 @@ def main() -> None:
over["track_score"] = args.track_score
if args.track_gate is not None:
over["track_gate"] = args.track_gate
params = Params(**over)
over["device"] = args.device
params = Params(**PS.apply(over, args.set, Params))
laterals = tuple(float(x) for x in args.laterals.split(","))
tasks = []
@ -289,8 +388,17 @@ def main() -> None:
print(f" внимание: для {p.name} нет своей складки — пропуск")
continue
trk = track_paths.get(p.name, args.track) if track_paths else args.track
aug_params = {
"augment": args.augment,
"d_start_min": args.d_start_min,
"d_start_max": args.d_start_max,
"v_lat_max": args.v_lat_max,
"s_noise_std": args.s_noise_std,
"respawn": args.respawn,
"static_prob": args.static_prob,
}
tasks.append((p, params, args.limit, args.d_start, laterals, args.seed,
args.memory or "", rd, bool(args.tracks_out), trk))
args.memory or "", rd, bool(args.tracks_out), trk, aug_params))
# Печатается по готовности, собирается по номеру задачи: порядок сценариев
# в файле не должен зависеть от того, какой бэг досчитался первым.

View file

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

View file

@ -12,12 +12,15 @@ import numpy as np
import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.mbon_readout import MbonReadout
from flyguard.pipeline import FlyGuard, Params
from flyguard.track_readout import TrackReadout
def run(bag_path, params: Params, memory, limit: int | None, verbose: bool) -> dict:
def run(bag_path, params: Params, memory, readout=None, track_readout=None,
limit: int | None = None, verbose: bool = False) -> dict:
bag = Bag(bag_path)
fg = FlyGuard(params, memory=memory)
fg = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
stages: dict[str, list[float]] = {}
n_det = n_frames = 0
dists, speeds, ncand = [], [], []
@ -65,15 +68,31 @@ def main() -> None:
ap.add_argument("--all", action="store_true")
ap.add_argument("--limit", type=int, default=150)
ap.add_argument("--memory", default=None)
ap.add_argument("--readout", default=None, help="модель MBON (mbon_readout.npz)")
ap.add_argument("--track-readout", default=None, help="модель TrackReadout (track_readout.npz)")
ap.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"],
help="устройство вычислений ('auto', 'cuda', 'cpu')")
ap.add_argument("--fov", type=float, default=30.0)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
memory = MushroomBody.load(args.memory) if args.memory else None
params = Params(fov_deg=args.fov)
bags = find_bags(B.DATA / "for_hackathon") if args.all else [args.bag]
readout = MbonReadout.load(args.readout) if args.readout else None
track_readout = TrackReadout.load(args.track_readout) if args.track_readout else None
if not args.all and not args.bag:
args.all = True
params = Params(fov_deg=args.fov, device=args.device)
bag_root = (B.DATA / "for_hackathon") if (B.DATA / "for_hackathon").exists() else B.DATA
bags = find_bags(bag_root) if args.all else ([args.bag] if args.bag else [])
if not bags:
print(f"Внимание: бэги не найдены в {bag_root}. Убедитесь, что каталог смонтирован в FLYGUARD_DATA.")
return
for b in bags:
run(b, params, memory, args.limit, args.verbose)
run(b, params, memory, readout=readout, track_readout=track_readout,
limit=args.limit, verbose=args.verbose)
if __name__ == "__main__":

View file

@ -57,14 +57,15 @@ def main() -> None:
ap.add_argument("--out", default=str(B.ARTIFACTS / "mbon_readout.npz"))
ap.add_argument("--n-kc", type=int, default=0,
help="ёмкость итоговой модели; 0 — взять лучшую из развёртки")
ap.add_argument("--sweep-kc", default="4000,8000,20000",
ap.add_argument("--sweep-kc", default="4000,8000,20000,50000",
help="ёмкости для развёртки; пусто — не разворачивать")
ap.add_argument("--active", type=int, default=100,
help="активных клеток после торможения APL")
ap.add_argument("--epochs", type=int, default=60)
ap.add_argument("--lr", type=float, default=4.0)
ap.add_argument("--l2", type=float, default=1e-5)
ap.add_argument("--device", default="cpu")
ap.add_argument("--device", default="auto",
help="устройство обучения ('auto', 'cuda', 'cpu')")
ap.add_argument("--baseline", action="store_true",
help="сравнить с градиентным бустингом по сырым признакам")
ap.add_argument("--save-folds", default=str(B.ARTIFACTS / "mbon_folds"),
@ -148,9 +149,15 @@ def main() -> None:
f"обстановки при 95% предметов {fpr_at_tpr(s, Y[held], 0.95):6.2%}")
if args.baseline:
import lightgbm as lgb
g = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05,
num_leaves=63, verbose=-1)
g.fit(Xtr, ytr)
dev_type = "gpu" if (args.device == "cuda" or (args.device == "auto" and B.Path("/dev/nvidia0").exists())) else "cpu"
try:
g = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05,
num_leaves=63, device=dev_type, verbose=-1)
g.fit(Xtr, ytr)
except Exception:
g = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05,
num_leaves=63, verbose=-1)
g.fit(Xtr, ytr)
line += f" | бустинг AUC {auc(g.predict_proba(X[held])[:, 1], Y[held]):.4f}"
print(line, flush=True)

View file

@ -63,7 +63,8 @@ def main() -> None:
ap.add_argument("--n-kc", type=int, default=_d.n_kc)
ap.add_argument("--claws", type=int, default=_d.claws)
ap.add_argument("--sparsity", type=float, default=_d.sparsity)
ap.add_argument("--device", default="cpu")
ap.add_argument("--device", default="auto",
help="устройство вычислений ('auto', 'cuda', 'cpu')")
ap.add_argument("--reuse-cache", action="store_true")
args = ap.parse_args()

View file

@ -71,8 +71,10 @@ def main() -> None:
ap.add_argument("--sweep-kc", default="500,1000,2000,4000")
ap.add_argument("--epochs", type=int, default=200)
ap.add_argument("--lr", type=float, default=2.0)
ap.add_argument("--l2", type=float, default=1e-4)
ap.add_argument("--device", default="cpu")
ap.add_argument("--device", default="auto",
help="устройство обучения ('auto', 'cuda', 'cpu')")
ap.add_argument("--baseline", action="store_true",
help="сравнить с GPU/CPU градиентным бустингом (LightGBM)")
ap.add_argument("--drop", default="",
help="признаки, выброшенные из обучения — это РЕЖИМ ЗАМЕРА, "
"модель при этом не сохраняется: конвейер считает "
@ -172,9 +174,32 @@ def main() -> None:
for held, s, _ in res:
print(f" {held:<42}AUC {auc(s, Y[held]):.4f}")
table_cols = {"улика: AUC": ev_loo, "модель: AUC": s_loo}
if args.baseline:
import lightgbm as lgb
s_lgb = []
for held in names:
tr = [n for n in names if n != held]
Xtr = np.concatenate([X[n] for n in tr])
ytr = np.concatenate([Y[n] for n in tr])
dev_type = "gpu" if (args.device == "cuda" or (args.device == "auto" and B.Path("/dev/nvidia0").exists())) else "cpu"
try:
g = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31,
device=dev_type, verbose=-1)
g.fit(Xtr, ytr)
except Exception:
g = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31,
verbose=-1)
g.fit(Xtr, ytr)
s_lgb.append(g.predict_proba(X[held])[:, 1])
s_lgb_arr = np.concatenate(s_lgb)
print(f"\nбустинг leave-one-bag-out: AUC {auc(s_lgb_arr, y_loo):.4f}, "
f"ложных при 95 % пойманных {fpr_at_tpr(s_lgb_arr, y_loo):.3f}")
table_cols["бустинг: AUC"] = s_lgb_arr
# ------------------------------- главное: обгоняет ли модель саму улику
print("\nмодель против улики, по полосам дальности:")
band_table(d_loo, y_loo, {"улика: AUC": ev_loo, "модель: AUC": s_loo})
band_table(d_loo, y_loo, table_cols)
# --------------------------------------------------------------- хранение
if drop: