Обученное считывание MBON: ложных тревог вдвое меньше

This commit is contained in:
Данил Омелечко 2026-09-21 22:13:26 +03:00
parent b8e95eccbe
commit 8778bae632
15 changed files with 597 additions and 82 deletions

View file

@ -87,7 +87,7 @@ flyguard/ ядро: стадии обработки, память, сч
pipeline.py сборка
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор
tests/ 27 тестов, запускаются без данных и без ROS
tests/ 28 тестов, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```
@ -114,8 +114,9 @@ set FLYGUARD_DATA=D:\lidar\data
python tools/make_training_set.py # размеченная выборка
python tools/train_mbon.py --device cuda --baseline # считывание MBON
python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тревоги
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz
python tools/plot_benchmark.py # дальность обнаружения
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
--mbon-dir artifacts/mbon_folds # дальность обнаружения
python tools/plot_benchmark.py # кривые и график
```
---
@ -124,12 +125,14 @@ python tools/plot_benchmark.py # дальность обн
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **98.9 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **7.5 трека на км**, 16.7 % кадров | `tools/evaluate.py` |
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **3.3 трека на км**, 4.5 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 7.4 на км, 16.7 % кадров | там же, без `--mbon-dir` |
| То же на новой линии, без обученной памяти | 21.8 на км | там же, холодный старт |
| Рабочая дальность (полигон, 14 004 наблюдения) | **100 м** человек стоя, P@50 = 0.70 | `tools/plot_benchmark.py` |
| Рабочая дальность (полигон, 14 004 наблюдения) | **80 м** человек стоя и сидя, P@50 = 0.70, P@100 = 0.31 | `tools/plot_benchmark.py` |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» | ROC AUC 0.905 | `tools/tune_memory.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` |
Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и
@ -150,6 +153,11 @@ python tools/plot_benchmark.py # дальность обн
полотну, и самая низкая видимая точка у оси пути оказывается выше головки
рельса на 0.1–0.6 м.
* Привыкание внутри проезда сделано и **отвергнуто замером** — п. 10.
* **Яркость как признак мы себе не засчитываем.** Абсолютной шкалы
интенсивности в записях нет: медиана по кандидатам обстановки 3…7 в пяти
бэгах и 23.5 в шестом. Вставка берёт яркость реальных возвратов с тех же
лучей, то есть признак намеренно обесточен, и дальность из-за этого
занижена — настоящий предмет был в 1.47 раза ярче окружения. Разбор — п. 11.1.
---

File diff suppressed because one or more lines are too long

View file

@ -9,45 +9,30 @@
"fp_tracks": 5,
"fp_per_km": NaN,
"fp_median_d": 76.65631835070762,
"obj_rate": 0.9894736842105263,
"ms_p50": 33.958500005610404,
"ms_p95": 37.71089500642119,
"obj_rate": 0.9947368421052631,
"ms_p50": 33.38889999940875,
"ms_p95": 37.88361000169971,
"train_size": 37073
},
{
"bag": "doubleT_platform",
"frames": 239,
"path_m": 200.74905739412534,
"alarm_frames": 56,
"alarm_rate": 0.23430962343096234,
"fp_objects": 56,
"alarm_frames": 28,
"alarm_rate": 0.11715481171548117,
"fp_objects": 28,
"fp_tracks": 1,
"fp_per_km": 4.981343439320496,
"fp_median_d": 62.79671678753603,
"fp_median_d": 33.983066409463774,
"obj_rate": null,
"ms_p50": 40.0374000055308,
"ms_p95": 43.40603000018746,
"ms_p50": 39.082800001779106,
"ms_p95": 45.35096999898087,
"train_size": 36151
},
{
"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": 59.10944286836971,
"obj_rate": null,
"ms_p50": 39.84129999298602,
"ms_p95": 49.274240005979664,
"train_size": 36584
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 0,
"alarm_rate": 0.0,
"fp_objects": 0,
@ -55,38 +40,53 @@
"fp_per_km": 0.0,
"fp_median_d": NaN,
"obj_rate": null,
"ms_p50": 46.89270000017132,
"ms_p95": 52.964819991757395,
"ms_p50": 37.19460000138497,
"ms_p95": 53.73476000095251,
"train_size": 36584
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 4,
"alarm_rate": 0.016736401673640166,
"fp_objects": 4,
"fp_tracks": 1,
"fp_per_km": 4.052459651885389,
"fp_median_d": 57.421836173736494,
"obj_rate": null,
"ms_p50": 45.79000000376254,
"ms_p95": 48.463629999241675,
"train_size": 36718
},
{
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 86,
"alarm_rate": 0.3598326359832636,
"fp_objects": 89,
"fp_tracks": 3,
"fp_per_km": 10.951145000553437,
"fp_median_d": 108.44065443448855,
"alarm_frames": 13,
"alarm_rate": 0.05439330543933055,
"fp_objects": 13,
"fp_tracks": 1,
"fp_per_km": 3.6503816668511457,
"fp_median_d": 95.9543811760172,
"obj_rate": null,
"ms_p50": 46.32040000797133,
"ms_p95": 50.264649999007815,
"ms_p50": 46.54909999590018,
"ms_p95": 49.73449000317487,
"train_size": 36083
},
{
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 34,
"alarm_rate": 0.14225941422594143,
"fp_objects": 34,
"fp_tracks": 2,
"fp_per_km": 7.380580405772363,
"fp_median_d": 98.006467832879,
"alarm_frames": 9,
"alarm_rate": 0.03765690376569038,
"fp_objects": 9,
"fp_tracks": 1,
"fp_per_km": 3.6902902028861817,
"fp_median_d": 96.63277916999479,
"obj_rate": null,
"ms_p50": 40.29609999633976,
"ms_p95": 47.709809999651036,
"ms_p50": 41.41209999579587,
"ms_p95": 46.84415000701847,
"train_size": 33658
}
]

BIN
artifacts/mbon_readout.npz Normal file

Binary file not shown.

View file

@ -968,3 +968,251 @@ MBON-α′3, а момент депрессии разрешает коорди
параметрами: ёмкость, квантиль отсчёта, огрубление, шаг «разных мест», путь
полузабывания. На линии, где обстановка стандартизована сильнее, чем в этих записях,
он может заработать — но проверять это надо замером на той линии, а не надеждой.
---
## 11. Обученное считывание MBON: метки из физики
До сих пор грибовидное тело училось **без учителя**: оно запоминало частоту
обстановки и отвечало «знакомо/ново». Вставка предметов трассировкой лучей даёт
то, чего в датасете нет, — **метку**. `synth.inject` возвращает индексы лучей,
которые он переписал, поэтому кандидат размечается точным пересечением: предмет,
если не меньше половины его лучей пришли от вставки (`tools/make_training_set.py`,
`MIN_OVERLAP = 0.5`). Слои остаются те же — разрежённый код клеток Кеньона,
торможение APL, один выход MBON, — меняется только учитель: вместо частоты
обстановки различение «предмет / тоннель» (`flyguard/mbon_readout.py`).
### 11.1. Сначала — найденный артефакт: интенсивность выдавала вставку
Первое же обучение дало подозрительно красивые цифры: leave-one-bag-out AUC
**0.9935**, по полосам дальности от 0.998 вблизи до 0.941 на 160–230 м. Разбор
важности перестановкой поставил наверх не форму, а **интенсивность**:
```
inten +0.0187 ← втрое больше следующего
elongation +0.0051
fill +0.0048
log_rays +0.0030
```
Проверка показала, откуда это взялось. Интенсивность вставки считалась по
ламбертовой модели ρ·cosθ/r², и произведение `intensity · r²` у вставленных
предметов держалось в пределах **1.8×** между p10 и p90, тогда как у настоящих
кандидатов разброс **64×**. Иначе говоря, вставка — и только она — точно
подчинялась учебниковому закону обратных квадратов, и это распознаётся надёжнее
любой формы.
Хуже того, у нижнего среза шкалы признак переворачивается. Разделяющая
способность **одной интенсивности** по полосам:
| полоса | intensity вставки | intensity обстановки | AUC по одной интенсивности |
|---|---:|---:|---:|
| 0–30 м | 37.2 | 5.0 | 0.956 |
| 30–55 м | 4.6 | 4.5 | 0.513 |
| 55–80 м | 1.7 | 4.0 | 0.287 |
| 80–110 м | 1.0 | 3.0 | 0.197 |
| 110–160 м | 1.0 | 5.0 | **0.049** |
| 160–230 м | 1.0 | 7.5 | **0.010** |
За 110 м расчётная яркость вставки падала ниже единицы, упиралась в срез шкалы —
и «тускло» становилось почти безошибочным признаком предмета. AUC 0.010 означает
почти идеальное разделение с обратным знаком.
**В реальных данных всё наоборот.** Медиана интенсивности по всему облаку,
20 кадров на бэг:
| бэг | 5–15 м | 15–30 | 30–50 | 50–80 | 80–120 | 120–170 | 170–230 |
|---|---:|---:|---:|---:|---:|---:|---:|
| `doubleT_obstacle` | 11 | 9 | 8 | 9 | 6 | 6 | 6 |
| `roundT_doubleT` | 10 | 8 | 8 | 4 | 2 | 3 | 4 |
| `squareT_platform_squareT_switch` | 8 | 8 | 7 | 3 | 2 | 2 | 4 |
Никакого 1/r². Прибор отдаёт не принятую энергию, а **отражательную способность
с компенсацией дальности** — то, что в руководстве Hesai называется
reflectivity. Ламбертова модель описывала физику фотона, а не то число, которое
лежит в поле `intensity`.
Настоящий предмет даёт точку привязки. В `doubleT_obstacle` он стоит на 55.9 м,
57 лучей, и его медианная интенсивность — **34.5** (p10…p90 = 31…39) при
медиане окружающих кандидатов 23.5. Старая формула на этой дальности дала бы
**1.45**, то есть в 24 раза тусклее настоящего.
**Первая попытка починки была неправильной.** Раз прибор компенсирует
дальность, яркость вставки сделали постоянной: `100 · ρ · √cosθ` со шкалой,
привязанной к настоящему предмету (ρ = 0.35 → около 34). Выборка пересобрана,
модель переобучена — и артефакт просто перевернулся:
| полоса | вставка | обстановка | AUC по одной интенсивности |
|---|---:|---:|---:|
| 0–30 м | 35.0 | 5.0 | 0.966 |
| 30–55 м | 35.2 | 4.5 | 0.960 |
| 55–80 м | 34.9 | 4.0 | 0.959 |
| 80–110 м | 35.1 | 3.0 | 0.970 |
| 110–160 м | 34.8 | 5.0 | 0.832 |
| 160–230 м | 34.2 | 7.5 | 0.798 |
Важность интенсивности при этом выросла втрое (+0.0533 против +0.0187), то есть
модель стала опираться на подсказку ещё сильнее.
**Настоящая причина: абсолютной шкалы в этих данных нет.** Медиана яркости
кандидатов обстановки по бэгам — 3.0, 4.0, 4.0, 4.0, 7.0, а в
`doubleT_obstacle`, где лежит настоящий предмет, 23.5 при 34.5 у самого
предмета. Разброс между записями впятеро больше, чем контраст предмета к фону
внутри записи. Любое абсолютное число, назначенное вставке, оказывается
подарком детектору — в ту или в другую сторону.
**Как сделано в итоге** (`synth.local_intensity` и `synth.IntensityEnv`):
вставка берёт яркость **реальных возвратов с тех же самых лучей** — того, что
предмет заслонил. Лучи, ушедшие в пустоту (за 100 м таких большинство),
получают образец из реальных возвратов **той же полосы дальности** в этом же
кадре; разложение кадра по дальности считается один раз и переиспользуется
всеми 135 сценариями. Ни отражательной способности, ни закона яркости от
дальности в функции нет; ρ осталась там, где ей место, — в вероятности, что
эхо вообще вернётся. Проверка вставками в реальные бэги:
| дальность | 30 м | 60 м | 100 м | 150 м | 200 м |
|---|---:|---:|---:|---:|---:|
| вставка | 8.8 | 6.9 | 4.3 | 4.3 | 10.1 |
| реальные возвраты рядом | 10.0 | 4.0 | 2.0 | 3.0 | 3.0 |
Это **заниженная** оценка: настоящий предмет в записи был в 1.47 раза ярче
окружения, и этот запас мы себе не засчитываем. На линии с откалиброванной
яркостью его можно вернуть — замером, а не верой. Тест
`test_injected_intensity_copies_the_surroundings` держит свойство: среди
аргументов нет отражения, а без разложения окружения дальность ничего не меняет.
Замечание, важное для оценки ущерба: складка, не видевшая `doubleT_obstacle`,
и с артефактом ставила настоящему предмету **1.000** (p10 = 0.998) против 0.393
у обстановки — на ближней дальности модель опиралась не только на подсказку.
Но полигонная дальность, измеренная на таких вставках, мерила бы признак,
которого в реальности нет. Поэтому все цифры ниже получены на выборке, где
яркость взята из самой записи.
Отдельный урок про метрику: таблицы выше потребовали починить подсчёт AUC.
Ранги раздавались по порядку в массиве, а не усреднялись на совпадениях, и
признак, у которого все значения равны, получал 0 или 1 вместо 0.5. Первым
ложным сигналом такого рода оказался контраст к фону за 160 м — там он
структурно равен нулю у всех кандидатов сразу. Теперь ранги средние, а сторож
«что делит выборку само по себе» встроен в `tools/train_mbon.py` и печатается
перед каждым обучением.
### 11.2. Что даёт обученное считывание
Обучение — полнопакетный логистический спуск по разрежённому коду: 4 000
клеток Кеньона, 100 активных после торможения APL, 23 признака кандидата,
185 252 примера, 19 445 из них предметы. Ёмкость выбрана развёрткой: 8 000 и
20 000 клеток дают ту же AUC (0.9859 и 0.9850 против 0.9860), а в худшем кадре
стоят 2.8 и 7.3 мс вместо 1.5 — платить не за что.
Проверка — leave-one-bag-out, отдельная модель на каждую складку. Без этого
сквозные цифры были бы ложными: считывание увидело бы проверяемый бэг ровно
так же, как память тоннеля, обученная на всём подряд.
| бэг | AUC | обстановки при 95 % предметов | бустинг по сырым признакам |
|---|---:|---:|---:|
| `doubleT_platform` | 0.9923 | 3.54 % | 0.9951 |
| `roundT_doubleT` | 0.9939 | 2.79 % | 0.9987 |
| `roundT_pressureGate_roundT` | 0.9759 | 16.30 % | 0.9866 |
| `roundT_squareT_pressureGate_squareT` | 0.9828 | 11.65 % | 0.9930 |
| `squareT_platform_squareT_switch` | 0.9851 | 5.87 % | 0.9902 |
| **среднее** | **0.9860** | **8.03 %** | 0.9927 |
AUC по полосам дальности: 0.995 / 0.996 / 0.992 / 0.973 / 0.955 / 0.738 —
качество честно падает с дальностью, как и должно быть, когда от предмета
остаётся четыре луча.
Градиентный бустинг по тем же сырым признакам стабильно лучше на 0.005…0.01
AUC. Это цена разрежённого кода, и мы её знаем: взамен получаем схему, которая
считается за 1.5 мс на худшем кадре и держит непрерывное дообучение.
Наверху важности — форма, а не положение и не яркость:
```
fill +0.0079 abs_lat +0.0051
elongation +0.0076 acc_support +0.0046
h +0.0055 lat +0.0044
```
Положение в сечении (`abs_lat`, `lat`) стоит на 4–6 месте. Формально это
законный признак — опасны именно предметы в габарите, — но границу
«положительной» области задали наши же расстановки (0, ±0.6, ±1.2 м от оси),
и на защите это надо называть вслух, а не прятать.
**Сквозной результат, leave-one-bag-out:**
| | без считывания | со считыванием |
|---|---:|---:|
| разных ложных треков на километр | 7.5 | **3.3** |
| кадров с ложной тревогой | 16.7 % | **4.4 %** |
| реальный объект на 55 м | 98.9 % | **98.9 %** |
| задержка кадра, медиана | 33 мс | 35–41 мс |
**На размеченном полигоне** (модель по складкам, то есть для каждого бэга та,
что его не видела):
| человек стоя | без считывания | со считыванием |
|---|---:|---:|
| рабочая дальность «как есть» | 62 м | **80 м** |
| P@50 м | 0.70 | 0.70 |
| P@100 м | 0.19 | **0.31** |
| посторонних тревог на кадр | 0.169 | **0.030** |
Прибавка не только на человеке: ящик P@100 0.02 → 0.24, человек сидя
0.25 → 0.39, чемодан P@50 0.55 → 0.60, ведро впервые поднялось над нулём
(рабочая дальность 8 → 20 м).
Отдельно: прежние заявленные 100 м рабочей дальности и P@100 = 0.53 получены
на полигоне с артефактом яркости и **завышены**. Честные числа без считывания —
80 м и 0.19, со считыванием — 80 м и 0.31.
### 11.3. Сколько дальности покупается за ложные тревоги
Смысл обученного считывания не в том, чтобы просто показать меньшее число
ложных. Оно даёт **запас**, а запас надо тратить: поезд движется, и дальность
обнаружения — это время на торможение. Поэтому пороги решения вынесены в
`Params` и развёрнуты замером. Все строки — leave-one-bag-out по ложным и
полигон по дальности, модель по складкам:
| конфигурация | ложных треков/км | кадров с тревогой | реальный объект | P@50 | P@100 | тревог на кадр, полигон |
|---|---:|---:|---:|---:|---:|---:|
| без считывания | 7.4 | 16.65 % | 98.9 % | 0.70 | 0.19 | 0.169 |
| считывание, три подтверждения | 3.3 | 4.44 % | 98.9 % | 0.70 | 0.31 | 0.030 |
| **по умолчанию: считывание, два подтверждения** | **3.3** | **4.52 %** | **99.5 %** | **0.70** | **0.31** | **0.030** |
| тише: смешивание 0.7 | 2.5 | 4.85 % | 98.9 % | 0.70 | 0.23 | 0.016 |
| дальше: порог улики 0.40 | 5.3 | 6.19 % | 98.9 % | 0.71 | 0.34 | 0.039 |
| смешивание 0.7 + порог 0.32 | 4.3 | 7.28 % | 98.9 % | 0.71 | 0.31 | 0.055 |
| порог 0.30 | 7.8 | 8.37 % | — | — | — | — |
| порог 0.22 | 9.8 | 10.04 % | — | — | — | — |
Три вывода, каждый замером.
**Смешивание с ручной формулой — не способ купить дальность.** Строка
«смешивание 0.7 + порог 0.32» даёт ровно ту же P@100 = 0.31, что и чистая
модель при штатном пороге, но стоит 4.3 ложных трека вместо 3.3 и вдвое
больше посторонних тревог на полигоне. Смешивание полезно в другом качестве —
как **тихий режим**: при штатном пороге оно опускает ложные до 2.5 на км и
0.016 на кадр, отдавая дальность (P@100 0.31 → 0.23). Оно же — страховка на
случай, когда предмет не похож ни на что из каталога: ручная формула
продолжает работать по геометрии.
**Понижение порога работает, но дорого.** 0.50 → 0.40 добавляет 0.03 к P@100
человека и 0.07 ящику, а платит 3.3 → 5.3 ложных трека. Дальше кривая только
круче: 0.30 стоит 7.8, 0.22 — 9.8, то есть хуже, чем было без считывания
вообще. Оставлено пресетом, а не умолчанием.
**Два подтверждения вместо трёх оказались бесплатными.** Ложные не изменились
(3.3 на км, 4.44 → 4.52 % кадров), посторонние тревоги на полигоне не
изменились тоже (0.030 на кадр), а обнаружение выросло: реальный объект
98.9 → **99.5 %**, рабочая дальность человека сидя 20 → **80 м**, чемодана
«как есть» 20 → **62 м**. Причина видна из данных: на 60…100 м предмет
среднего размера виден через кадр, и третьего подтверждения приходится ждать
дольше, чем он успевает приблизиться. Это стало умолчанием.
Строки пресетов измерены при трёх подтверждениях — прежнем умолчании. В
базовой точке переход на два ложных не изменил, но для пресетов это отдельно
не перемерялось.
**Чего порогами не изменить.** P@150 равна нулю в любой конфигурации. Это не
алгоритм: прямая видимость в этих тоннелях 121–167 м, а прирельсовая зона за
80–90 м не наблюдается вовсе — луч скользит по полотну (п. 3 и п. 7.3).
Потолок здесь геометрический.

View file

@ -60,7 +60,7 @@ class DescendingNeurons:
"""Два порога с гистерезисом поверх подтверждённых треков."""
def __init__(self, *, warn_evidence: float = 0.5, clear_evidence: float = 0.3,
emergency_evidence: float = 0.75, min_hits: int = 3,
emergency_evidence: float = 0.75, min_hits: int = 2,
novelty_floor: float = 0.10, max_range: float = 200.0):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence

View file

@ -83,6 +83,20 @@ class Params:
enable_mbon: bool = True
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
# Пороги решения (см. descending.py). Вынесены сюда, потому что обученное
# считывание меняет цену улики: когда ложные тревоги падают, запас можно
# потратить на дальность, опустив порог. Замер — EXPERIMENTS п. 11.
warn_evidence: float = 0.5 # улика, с которой трек поднимает тревогу
clear_evidence: float = 0.3 # и до которой держится (гистерезис)
emergency_evidence: float = 0.75
# Наблюдений, без которых трек не считается. Три подтверждения стоили
# дальности на предметах среднего размера: на 60…100 м они видны через
# кадр, и третьего приходится ждать слишком долго. Два подтверждения
# подняли рабочую дальность человека сидя с 20 до 80 м, а чемодана —
# с 20 до 62 м, и не стоили ни одного лишнего ложного трека
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
min_hits: int = 2
novelty_floor: float = 0.10
d_min: float = 4.0
d_max: float = 220.0
min_rays: int = 4
@ -153,9 +167,14 @@ class FlyGuard:
mbon_power=self.p.mbon_power,
mbon_blend=self.p.mbon_blend)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons() if self.p.use_tracking
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1))
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
clear_evidence=self.p.clear_evidence,
emergency_evidence=self.p.emergency_evidence,
min_hits=self.p.min_hits,
novelty_floor=self.p.novelty_floor) 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] = []
self.frames_seen = 0

View file

@ -10,10 +10,12 @@
через конвейер проходит ровно тот же путь, что и настоящие данные, начиная
с ретины.
Модель сенсора намеренно пессимистична: добавляется шум дальности, вероятность
несостоявшегося эха растёт с расстоянием и с углом падения, а интенсивность
считается по ламбертовой модели. Лучше недооценить свой детектор, чем на
защите обнаружить, что полигон был слишком добрым.
Модель сенсора намеренно пессимистична: добавляется шум дальности, а
вероятность несостоявшегося эха растёт с расстоянием и с углом падения. Лучше
недооценить свой детектор, чем на защите обнаружить, что полигон был слишком
добрым. Яркость вставки берётся из самой записи — из возвратов на тех же
лучах: абсолютной шкалы интенсивности в этих данных нет, и любое назначенное
число делает вставку узнаваемой (см. длинный комментарий ниже).
"""
from __future__ import annotations
@ -181,6 +183,101 @@ BEAM_DIV_V = 2.18e-3 # рад
_CHANNEL_RANGE: np.ndarray | None = None
# Интенсивность вставки. Здесь нельзя придумать ни формулы, ни числа.
#
# Сначала стояла ламбертова ρ·cosθ/r². Прибор, однако, отдаёт не принятую
# энергию, а отражательную способность с компенсацией дальности: медиана по
# облаку держится 8…9 от 5 до 50 м и как 1/r² не падает. Вставка получалась на
# 55 м в двадцать раз тусклее настоящего предмета, а за 110 м упиралась в
# нижний срез шкалы — и «тускло» становилось безошибочным признаком предмета.
#
# Замена на постоянную яркость 100·ρ·√cosθ, привязанную к настоящему предмету
# (34.5 на 55.9 м), просто перевернула артефакт: 35 против 3…7 у обстановки на
# каждой полосе дальности, AUC по одной интенсивности 0.96…0.97.
#
# Причина в том, что абсолютной шкалы тут нет. Медиана яркости кандидатов
# обстановки по бэгам: 3.0, 4.0, 4.0, 4.0, 7.0 — а в `doubleT_obstacle`, где
# лежит настоящий предмет, 23.5 при 34.5 у самого предмета. Разница между
# записями впятеро больше, чем контраст предмета к фону внутри записи. Любое
# абсолютное число, назначенное вставке, оказывается подарком детектору — в ту
# или в другую сторону.
#
# Поэтому вставка берёт яркость **реальных возвратов с тех же самых лучей** —
# того, что предмет заслонил; где эха не было, из возвратов вдоль остальных его
# лучей. Признак становится неинформативным, и полигон меряет геометрию и
# движение, то есть то, что мы моделируем честно. Оценка заниженная: настоящий
# предмет в записи был в 1.47 раза ярче окружения. На линии с откалиброванной
# яркостью этот запас можно вернуть — замером, а не верой.
#
# Отражательная способность никуда не делась: она определяет, вернётся ли эхо
# вообще (`dropout_probability`), а это и есть её настоящая роль.
INTEN_SPREAD = 0.12 # разброс отсчёта, логнормальный, ≈ ±12 %
class IntensityEnv:
"""Яркость реальных возвратов кадра, разложенная по дальности.
Нужна, чтобы луч предмета, ушедший в пустоту, получил яркость такую же, как
у настоящих возвратов С ТОЙ ЖЕ дальности, а не с ближних и ярких. Считается
один раз на кадр и переиспользуется всеми сценариями: в сборе выборки их
135 на кадр, и пересчитывать корни по полутора миллионам точек для каждого
незачем.
"""
EDGES = np.array([0, 20, 40, 60, 90, 130, 180, 260], np.float32)
def __init__(self, pc: PointCloud2):
q = pc.points
it = np.asarray(q["intensity"], np.float32)
x, y, z = q["x"], q["y"], q["z"]
r2 = x * x + y * y + z * z
ok = (it > 0) & (r2 > 1.0) & np.isfinite(r2)
r = np.sqrt(r2[ok], dtype=np.float32)
v = it[ok]
b = np.clip(np.searchsorted(self.EDGES, r, side="right") - 1,
0, self.EDGES.size - 2)
order = np.argsort(b, kind="stable")
b_s, v_s = b[order], v[order]
cut = np.searchsorted(b_s, np.arange(self.EDGES.size - 1), side="left")
cut = np.append(cut, b_s.size)
self._pools = [v_s[cut[i]:cut[i + 1]] for i in range(self.EDGES.size - 1)]
self._all = v_s
def sample(self, d: float, n: int, rng: np.random.Generator) -> np.ndarray:
i = int(np.clip(np.searchsorted(self.EDGES, d, side="right") - 1,
0, self.EDGES.size - 2))
pool = self._pools[i]
if pool.size < 32: # на этой дальности возвратов нет
pool = self._all
if pool.size == 0:
return np.full(n, 5.0, np.float32)
return rng.choice(pool, size=n).astype(np.float32)
def local_intensity(prev: np.ndarray, pool: np.ndarray, frame: np.ndarray,
rng: np.random.Generator, env: "IntensityEnv | None" = None,
d: float = 0.0) -> np.ndarray:
"""Яркость вставки по окружению. Отражения среди аргументов нет намеренно.
Дальность участвует ровно в одном качестве — какую полосу реальных
возвратов брать для лучей, ушедших в пустоту. Никакого закона яркости от
дальности здесь не задаётся.
"""
v = np.asarray(prev, np.float32).copy()
miss = ~(v > 0)
if miss.any():
n = int(miss.sum())
if env is not None:
v[miss] = env.sample(d, n, rng)
else:
src = pool[pool > 0] if pool.size else np.empty(0, np.float32)
if src.size == 0: # все лучи предмета в пустоту
wide = frame[::997]
src = wide[wide > 0]
v[miss] = (rng.choice(src, size=n) if src.size else np.float32(5.0))
v = v * rng.lognormal(0.0, INTEN_SPREAD, v.shape)
return np.clip(v, 1.0, 255.0).astype(np.float32)
def channel_max_range() -> np.ndarray:
"""Паспортная дальность каждого канала при 10 % отражения, (128,).
@ -235,7 +332,8 @@ def dropout_probability(r: np.ndarray, reflectivity: float,
def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
obj: ObjectModel, place: Placement, *,
rng: np.random.Generator | None = None,
range_noise: float = 0.02, cols: slice | None = None) -> tuple[PointCloud2, dict]:
range_noise: float = 0.02, cols: slice | None = None,
env: "IntensityEnv | None" = None) -> tuple[PointCloud2, dict]:
"""Вставить предмет в облако точек. Возвращает (новое облако, разметка)."""
rng = rng or np.random.default_rng()
n_rings, n_az, n_echo = layout.n_rings, layout.n_az, layout.n_echo
@ -286,6 +384,10 @@ def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
r_exist = np.minimum(r_exist, r_e)
closer = new_r < r_exist
# яркость того, что было на лучах предмета: на выбранных — то, что он
# заслонил, остальные идут в запасной набор для лучей без эха
prev_sel = pts["intensity"][base[closer]]
pool = pts["intensity"][base]
ring_i, col_i, base, new_r = ring_i[closer], col_i[closer], base[closer], new_r[closer]
n_written = int(base.size)
if n_written:
@ -293,9 +395,8 @@ def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
nx = (dir_sel[:, 0] * new_r).astype(np.float32)
ny = (dir_sel[:, 1] * new_r).astype(np.float32)
nz = (dir_sel[:, 2] * new_r).astype(np.float32)
# ламбертова интенсивность: ρ·cosθ/r², приведена к шкале прибора 0…255
cos_inc = np.clip(np.abs(dir_sel[:, 1]), 0.05, 1.0)
inten = np.clip(2.2e4 * obj.reflectivity * cos_inc / (new_r ** 2), 1, 255)
inten = local_intensity(prev_sel, pool, pts["intensity"], rng,
env=env, d=place.d)
for e in range(n_echo):
idx = base + e * n_rings
fx[idx] = nx

View file

@ -307,6 +307,46 @@ def test_mbon_absent_leaves_pipeline_unchanged():
assert _quality(c, mbon_blend=1.0) == _quality(c, mbon_blend=0.0)
# ------------------------------------------------------- модель сенсора: яркость
def test_injected_intensity_copies_the_surroundings():
"""Яркость вставки берётся из записи, а не назначается.
Абсолютной шкалы интенсивности в данных нет: медиана кандидатов по бэгам
3…7, а в записи с настоящим предметом 23.5. Любое назначенное число делает
вставку опознаваемой по одной яркости — сначала как «тускло = предмет»
(ламбертова ρ·cosθ/r²), потом как «ярко = предмет» (постоянные 35).
Поэтому у функции нет аргументов ни дальности, ни отражения.
"""
import inspect
from flyguard.synth import local_intensity
# отражения среди аргументов нет; дальность есть, но только как выбор
# полосы реальных возвратов для лучей, ушедших в пустоту
args = set(inspect.signature(local_intensity).parameters)
assert not args & {"reflectivity", "rho", "cos_inc"}
rng = np.random.default_rng(0)
prev = np.array([10.0, 20.0, 0.0, 0.0], np.float32) # два луча в пустоту
pool = np.full(50, 30.0, np.float32)
frame = np.full(10_000, 7.0, np.float32)
v = local_intensity(prev, pool, frame, rng)
assert 8.0 < v[0] < 13.0 and 16.0 < v[1] < 25.0 # взято с тех же лучей
assert 24.0 < v[2] < 38.0 and 24.0 < v[3] < 38.0 # взято из запаса
# запас пуст — остаётся кадр целиком
v2 = local_intensity(np.zeros(200, np.float32), np.zeros(0, np.float32),
frame, rng)
assert 5.5 < float(np.median(v2)) < 9.0
# без разложения окружения по дальности сама дальность ничего не меняет
a = local_intensity(prev, pool, frame, np.random.default_rng(3), d=20.0)
b = local_intensity(prev, pool, frame, np.random.default_rng(3), d=200.0)
assert np.allclose(a, b)
# --------------------------------------------------------------------------- данные
@pytest.mark.skipif(not DATA.exists(), reason="датасет не распакован")

View file

@ -3,7 +3,7 @@
Печатает, в скольких кадрах он попал в кандидаты и в подтверждённые треки,
и сколько при этом было посторонних тревог.
python tools/check_obstacle.py [--memory artifacts/mushroom_body.npz]
python tools/check_obstacle.py [--memory artifacts/mushroom_body.npz] [--mbon artifacts/mbon_folds/mbon_roundT_doubleT.npz]
"""
from __future__ import annotations
@ -23,12 +23,20 @@ def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag", default=str(B.DATA / "for_hackathon" / "doubleT_obstacle"))
ap.add_argument("--memory")
ap.add_argument("--mbon", default="",
help="обученное считывание; для честной цифры берите складку, "
"не видевшую этот бэг: artifacts/mbon_folds/mbon_roundT_doubleT.npz")
ap.add_argument("--limit", type=int, default=200)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
memory = MushroomBody.load(args.memory) if args.memory else None
fg = FlyGuard(Params(), memory=memory)
readout = None
if args.mbon:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
fg = FlyGuard(Params(), memory=memory, readout=readout)
bag = Bag(args.bag)
n = cand_hit = track_hit = other = 0

View file

@ -107,6 +107,12 @@ def main() -> None:
help="1 — только модель, 0 — только ручная формула")
ap.add_argument("--mbon-power", type=float, default=None,
help="резкость вероятности модели")
ap.add_argument("--warn", type=float, default=None,
help="порог улики для тревоги (по умолчанию 0.5)")
ap.add_argument("--clear", type=float, default=None,
help="нижний порог гистерезиса (по умолчанию 0.3)")
ap.add_argument("--min-hits", type=int, default=None,
help="наблюдений, без которых трек не считается")
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
args = ap.parse_args()
@ -135,6 +141,13 @@ def main() -> None:
over["mbon_blend"] = args.mbon_blend
if args.mbon_power is not None:
over["mbon_power"] = args.mbon_power
if args.warn is not None:
over["warn_evidence"] = args.warn
over["clear_evidence"] = args.warn * 0.6 if args.clear is None else args.clear
elif args.clear is not None:
over["clear_evidence"] = args.clear
if args.min_hits is not None:
over["min_hits"] = args.min_hits
params = Params(**over)
readout = None
folds = {}

View file

@ -25,7 +25,7 @@ import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import Placement, catalogue, inject
from flyguard.synth import IntensityEnv, Placement, catalogue, inject
HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект
@ -67,6 +67,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
if gres is None or s_track[k] is None:
continue
s_now = s_track[k] - have[0]
env = IntensityEnv(pc) # один раз на кадр, общий для сценариев
for i, (name, lat) in enumerate(scen):
d_true = d_start - s_now
if d_true < 6.0:
@ -76,7 +77,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
# сенсора, он окажется в стене, а не в габарите.
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)
Placement(d=d_true, u=u_obj), rng=rng, env=env)
res = pipes[i].process(pc2)
if res is None:
continue
@ -102,24 +103,52 @@ def main() -> None:
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--memory")
ap.add_argument("--mbon", default="",
help="обученное считывание MBON; как и память, оно "
"видело эти бэги: полигон меряет дальность, "
"а не обобщаемость")
help="обученное считывание MBON одной моделью; она видела "
"эти бэги — годится только для отладки")
ap.add_argument("--mbon-dir", default="",
help="каталог с моделями по складкам (mbon_<бэг>.npz): для "
"каждого бэга берётся модель, его НЕ видевшая. Иначе "
"дальность завышена: считывание обучалось ровно на "
"таких же вставках в этот же тоннель")
ap.add_argument("--out", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--d-start", type=float, default=200.0)
ap.add_argument("--laterals", default="0.0,0.9")
ap.add_argument("--seed", type=int, default=12345)
ap.add_argument("--mbon-blend", type=float, default=None,
help="1 — только модель, 0 — только ручная формула")
ap.add_argument("--warn", type=float, default=None,
help="порог улики для тревоги (по умолчанию 0.5)")
ap.add_argument("--clear", type=float, default=None)
ap.add_argument("--min-hits", type=int, default=None)
args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
memory = MushroomBody.load(args.memory) if args.memory else None
readout = None
if args.mbon:
folds: dict = {}
if args.mbon_dir:
from pathlib import Path as _P
from flyguard.mbon_readout import MbonReadout
for f in _P(args.mbon_dir).glob("mbon_*.npz"):
folds[f.stem[len("mbon_"):]] = MbonReadout.load(f)
print(f"считывание MBON по складкам: {args.mbon_dir} "
f"({len(folds)} моделей)")
elif args.mbon:
from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}")
params = Params()
over = {}
if args.mbon_blend is not None:
over["mbon_blend"] = args.mbon_blend
if args.warn is not None:
over["warn_evidence"] = args.warn
over["clear_evidence"] = args.warn * 0.6 if args.clear is None else args.clear
elif args.clear is not None:
over["clear_evidence"] = args.clear
if args.min_hits is not None:
over["min_hits"] = args.min_hits
params = Params(**over)
laterals = tuple(float(x) for x in args.laterals.split(","))
all_rec = []
@ -127,8 +156,14 @@ def main() -> None:
if p.name == HOLDOUT:
continue
t0 = time.time()
rd = readout
if folds:
rd = folds.get(p.name)
if rd is None:
print(f" внимание: для {p.name} нет своей складки — пропуск")
continue
rec = run_bag(p, params, memory, args.limit, args.d_start, laterals,
args.seed, readout=readout)
args.seed, readout=rd)
all_rec.extend(rec)
n = sum(len(r["d"]) for r in rec)
print(f" {p.name:42s} сценариев {len(rec):3d}, наблюдений {n:6d}, "

View file

@ -35,7 +35,7 @@ import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import FEATURES, describe
from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import Placement, catalogue, inject
from flyguard.synth import IntensityEnv, Placement, catalogue, inject
HOLDOUT = "doubleT_obstacle" # там реальный объект — только для проверки
MIN_OVERLAP = 0.5 # доля лучей ядра, пришедших от предмета
@ -77,13 +77,14 @@ def collect_bag(path, params: Params, limit: int, d_starts, laterals, seed: int)
if g is None or s_track[k] is None:
continue
s_now = s_track[k] - s0
env = IntensityEnv(pc) # один раз на кадр, общий для сценариев
for i, (name, lat, d0) in enumerate(scen):
d_true = d0 - s_now
if d_true < 6.0:
continue
u = float(g.corridor.centre(np.array([d_true], np.float32))[0]) + lat
pc2, lab = inject(pc, layout, g.plane, cat[name],
Placement(d=d_true, u=u), rng=rng)
Placement(d=d_true, u=u), rng=rng, env=env)
res = pipes[i].process(pc2)
if res is None or not res.candidates:
continue

View file

@ -17,10 +17,12 @@ from __future__ import annotations
import argparse
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402
@ -61,6 +63,7 @@ def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--in", dest="path", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--thr", type=float, default=0.5)
ap.add_argument("--out", default="", help="куда сохранить рисунок")
args = ap.parse_args()
rec = json.load(open(args.path, encoding="utf-8"))
@ -112,7 +115,7 @@ def main() -> None:
ax.legend(fontsize=7, ncol=2)
B.FIGURES.mkdir(parents=True, exist_ok=True)
out = B.FIGURES / "detection_range.png"
out = Path(args.out) if args.out else B.FIGURES / "detection_range.png"
fig.tight_layout(); fig.savefig(out)
print("\nсохранено:", out)

View file

@ -35,14 +35,24 @@ BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230))
def auc(score: np.ndarray, y: np.ndarray) -> float:
pos, neg = score[y == 1], score[y == 0]
if pos.size == 0 or neg.size == 0:
"""Ранговый AUC со СРЕДНИМИ рангами на совпадениях.
Без усреднения признак, у которого все значения равны (за 160 м таков,
например, контраст к фону — там его структурно нет), получает AUC 0 или 1
просто по порядку в массиве. Один такой ложный сигнал уже стоил разбора.
"""
n_pos, n_neg = int((y == 1).sum()), int((y == 0).sum())
if n_pos == 0 or n_neg == 0:
return float("nan")
order = np.argsort(np.concatenate([pos, neg]))
ranks = np.empty(order.size, np.float64)
ranks[order] = np.arange(1, order.size + 1)
return float((ranks[:pos.size].sum() - pos.size * (pos.size + 1) / 2)
/ (pos.size * neg.size))
order = np.argsort(score, kind="mergesort")
s = score[order]
start = np.flatnonzero(np.r_[True, s[1:] != s[:-1]])
end = np.r_[start[1:], s.size]
avg = (start + end - 1) / 2.0 + 1.0
ranks = np.empty(s.size, np.float64)
ranks[order] = np.repeat(avg, end - start)
return float((ranks[y == 1].sum() - n_pos * (n_pos + 1) / 2)
/ (n_pos * n_neg))
def fpr_at_tpr(score: np.ndarray, y: np.ndarray, tpr: float = 0.95) -> float:
@ -106,6 +116,35 @@ def main() -> None:
print(f" {n:<42}{X[n].shape[0]:7d} предметов {int(Y[n].sum()):6d} "
f"({Y[n].mean():5.1%})")
# --------------------------------------------- что можно выучить по одному
# признаку. Эта таблица нужна не для качества, а для ловли артефактов
# вставки: если один признак сам по себе почти идеально делит предмет и
# обстановку — особенно с обратным знаком на дальности, — значит модель
# сенсора врёт, а не детектор хорош. Так была найдена интенсивность
# (EXPERIMENTS п. 11.1).
Xa = np.concatenate([X[n] for n in names])
ya = np.concatenate([Y[n] for n in names])
Da = np.concatenate([D[n] for n in names])
single = np.array([auc(Xa[:, j], ya) for j in range(n_pn)])
print()
print("что делит выборку само по себе (AUC одного признака):")
for j in np.argsort(-np.abs(single - 0.5))[:8]:
print(f" {feats[j]:<14}{single[j]:.3f}")
worst = []
for j in range(n_pn):
for lo, hi in BANDS:
m = (Da >= lo) & (Da < hi)
if (ya[m] == 1).sum() < 20:
continue
a = auc(Xa[m, j], ya[m])
if a > 0.97 or a < 0.03:
worst.append((feats[j], lo, hi, a))
if worst:
print(" ВНИМАНИЕ: один признак почти полностью решает задачу — "
"похоже на артефакт вставки, а не на сигнал:")
for f, lo, hi, a in worst[:8]:
print(f" {f:<14}{lo:4d}-{hi:<4d} м AUC {a:.3f}")
# ------------------------------------------------------ развёртка по ёмкости
best_kc = args.n_kc
if args.sweep_kc.strip():