Обученное считывание 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 сборка pipeline.py сборка
synth.py вставка предметов трассировкой лучей synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор tools/ обучение, оценка, разбор
tests/ 27 тестов, запускаются без данных и без ROS tests/ 28 тестов, запускаются без данных и без ROS
docs/ методика и результаты docs/ методика и результаты
artifacts/ обученные модели artifacts/ обученные модели
``` ```
@ -114,8 +114,9 @@ set FLYGUARD_DATA=D:\lidar\data
python tools/make_training_set.py # размеченная выборка python tools/make_training_set.py # размеченная выборка
python tools/train_mbon.py --device cuda --baseline # считывание MBON python tools/train_mbon.py --device cuda --baseline # считывание MBON
python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тревоги python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тревоги
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
python tools/plot_benchmark.py # дальность обнаружения --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` | | Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **7.5 трека на км**, 16.7 % кадров | `tools/evaluate.py` | | Ложные тревоги, leave-one-bag-out | **3.3 трека на км**, 4.5 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 7.4 на км, 16.7 % кадров | там же, без `--mbon-dir` |
| То же на новой линии, без обученной памяти | 21.8 на км | там же, холодный старт | | То же на новой линии, без обученной памяти | 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` | | Обработка кадра | медиана 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**: память обучается на всех записях, кроме Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и
@ -150,6 +153,11 @@ python tools/plot_benchmark.py # дальность обн
полотну, и самая низкая видимая точка у оси пути оказывается выше головки полотну, и самая низкая видимая точка у оси пути оказывается выше головки
рельса на 0.1–0.6 м. рельса на 0.1–0.6 м.
* Привыкание внутри проезда сделано и **отвергнуто замером** — п. 10. * Привыкание внутри проезда сделано и **отвергнуто замером** — п. 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_tracks": 5,
"fp_per_km": NaN, "fp_per_km": NaN,
"fp_median_d": 76.65631835070762, "fp_median_d": 76.65631835070762,
"obj_rate": 0.9894736842105263, "obj_rate": 0.9947368421052631,
"ms_p50": 33.958500005610404, "ms_p50": 33.38889999940875,
"ms_p95": 37.71089500642119, "ms_p95": 37.88361000169971,
"train_size": 37073 "train_size": 37073
}, },
{ {
"bag": "doubleT_platform", "bag": "doubleT_platform",
"frames": 239, "frames": 239,
"path_m": 200.74905739412534, "path_m": 200.74905739412534,
"alarm_frames": 56, "alarm_frames": 28,
"alarm_rate": 0.23430962343096234, "alarm_rate": 0.11715481171548117,
"fp_objects": 56, "fp_objects": 28,
"fp_tracks": 1, "fp_tracks": 1,
"fp_per_km": 4.981343439320496, "fp_per_km": 4.981343439320496,
"fp_median_d": 62.79671678753603, "fp_median_d": 33.983066409463774,
"obj_rate": null, "obj_rate": null,
"ms_p50": 40.0374000055308, "ms_p50": 39.082800001779106,
"ms_p95": 43.40603000018746, "ms_p95": 45.35096999898087,
"train_size": 36151 "train_size": 36151
}, },
{ {
"bag": "roundT_doubleT", "bag": "roundT_doubleT",
"frames": 239, "frames": 239,
"path_m": 200.9598713551979, "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_frames": 0,
"alarm_rate": 0.0, "alarm_rate": 0.0,
"fp_objects": 0, "fp_objects": 0,
@ -55,38 +40,53 @@
"fp_per_km": 0.0, "fp_per_km": 0.0,
"fp_median_d": NaN, "fp_median_d": NaN,
"obj_rate": null, "obj_rate": null,
"ms_p50": 46.89270000017132, "ms_p50": 37.19460000138497,
"ms_p95": 52.964819991757395, "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 "train_size": 36718
}, },
{ {
"bag": "roundT_squareT_pressureGate_squareT", "bag": "roundT_squareT_pressureGate_squareT",
"frames": 239, "frames": 239,
"path_m": 273.9439574444854, "path_m": 273.9439574444854,
"alarm_frames": 86, "alarm_frames": 13,
"alarm_rate": 0.3598326359832636, "alarm_rate": 0.05439330543933055,
"fp_objects": 89, "fp_objects": 13,
"fp_tracks": 3, "fp_tracks": 1,
"fp_per_km": 10.951145000553437, "fp_per_km": 3.6503816668511457,
"fp_median_d": 108.44065443448855, "fp_median_d": 95.9543811760172,
"obj_rate": null, "obj_rate": null,
"ms_p50": 46.32040000797133, "ms_p50": 46.54909999590018,
"ms_p95": 50.264649999007815, "ms_p95": 49.73449000317487,
"train_size": 36083 "train_size": 36083
}, },
{ {
"bag": "squareT_platform_squareT_switch", "bag": "squareT_platform_squareT_switch",
"frames": 239, "frames": 239,
"path_m": 270.98139848673645, "path_m": 270.98139848673645,
"alarm_frames": 34, "alarm_frames": 9,
"alarm_rate": 0.14225941422594143, "alarm_rate": 0.03765690376569038,
"fp_objects": 34, "fp_objects": 9,
"fp_tracks": 2, "fp_tracks": 1,
"fp_per_km": 7.380580405772363, "fp_per_km": 3.6902902028861817,
"fp_median_d": 98.006467832879, "fp_median_d": 96.63277916999479,
"obj_rate": null, "obj_rate": null,
"ms_p50": 40.29609999633976, "ms_p50": 41.41209999579587,
"ms_p95": 47.709809999651036, "ms_p95": 46.84415000701847,
"train_size": 33658 "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, 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): novelty_floor: float = 0.10, max_range: float = 200.0):
self.warn_evidence = warn_evidence self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence self.clear_evidence = clear_evidence

View file

@ -83,6 +83,20 @@ class Params:
enable_mbon: bool = True enable_mbon: bool = True
mbon_power: float = 1.0 # резкость: p**power перед смешиванием mbon_power: float = 1.0 # резкость: p**power перед смешиванием
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула 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_min: float = 4.0
d_max: float = 220.0 d_max: float = 220.0
min_rays: int = 4 min_rays: int = 4
@ -153,7 +167,12 @@ class FlyGuard:
mbon_power=self.p.mbon_power, mbon_power=self.p.mbon_power,
mbon_blend=self.p.mbon_blend) mbon_blend=self.p.mbon_blend)
# без накопления улик трек подтверждается первым же наблюдением # без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons() if self.p.use_tracking 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, else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1)) emergency_evidence=0.0, min_hits=1))
self._calib: list[PointCloud2] = [] self._calib: list[PointCloud2] = []

View file

@ -10,10 +10,12 @@
через конвейер проходит ровно тот же путь, что и настоящие данные, начиная через конвейер проходит ровно тот же путь, что и настоящие данные, начиная
с ретины. с ретины.
Модель сенсора намеренно пессимистична: добавляется шум дальности, вероятность Модель сенсора намеренно пессимистична: добавляется шум дальности, а
несостоявшегося эха растёт с расстоянием и с углом падения, а интенсивность вероятность несостоявшегося эха растёт с расстоянием и с углом падения. Лучше
считается по ламбертовой модели. Лучше недооценить свой детектор, чем на недооценить свой детектор, чем на защите обнаружить, что полигон был слишком
защите обнаружить, что полигон был слишком добрым. добрым. Яркость вставки берётся из самой записи — из возвратов на тех же
лучах: абсолютной шкалы интенсивности в этих данных нет, и любое назначенное
число делает вставку узнаваемой (см. длинный комментарий ниже).
""" """
from __future__ import annotations from __future__ import annotations
@ -181,6 +183,101 @@ BEAM_DIV_V = 2.18e-3 # рад
_CHANNEL_RANGE: np.ndarray | None = None _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: def channel_max_range() -> np.ndarray:
"""Паспортная дальность каждого канала при 10 % отражения, (128,). """Паспортная дальность каждого канала при 10 % отражения, (128,).
@ -235,7 +332,8 @@ def dropout_probability(r: np.ndarray, reflectivity: float,
def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane, def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
obj: ObjectModel, place: Placement, *, obj: ObjectModel, place: Placement, *,
rng: np.random.Generator | None = None, 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() rng = rng or np.random.default_rng()
n_rings, n_az, n_echo = layout.n_rings, layout.n_az, layout.n_echo 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) r_exist = np.minimum(r_exist, r_e)
closer = new_r < r_exist 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] ring_i, col_i, base, new_r = ring_i[closer], col_i[closer], base[closer], new_r[closer]
n_written = int(base.size) n_written = int(base.size)
if n_written: if n_written:
@ -293,9 +395,8 @@ def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
nx = (dir_sel[:, 0] * new_r).astype(np.float32) nx = (dir_sel[:, 0] * new_r).astype(np.float32)
ny = (dir_sel[:, 1] * new_r).astype(np.float32) ny = (dir_sel[:, 1] * new_r).astype(np.float32)
nz = (dir_sel[:, 2] * new_r).astype(np.float32) nz = (dir_sel[:, 2] * new_r).astype(np.float32)
# ламбертова интенсивность: ρ·cosθ/r², приведена к шкале прибора 0…255 inten = local_intensity(prev_sel, pool, pts["intensity"], rng,
cos_inc = np.clip(np.abs(dir_sel[:, 1]), 0.05, 1.0) env=env, d=place.d)
inten = np.clip(2.2e4 * obj.reflectivity * cos_inc / (new_r ** 2), 1, 255)
for e in range(n_echo): for e in range(n_echo):
idx = base + e * n_rings idx = base + e * n_rings
fx[idx] = nx 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) 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="датасет не распакован") @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 from __future__ import annotations
@ -23,12 +23,20 @@ def main() -> None:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bag", default=str(B.DATA / "for_hackathon" / "doubleT_obstacle")) ap.add_argument("--bag", default=str(B.DATA / "for_hackathon" / "doubleT_obstacle"))
ap.add_argument("--memory") 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("--limit", type=int, default=200)
ap.add_argument("--verbose", action="store_true") ap.add_argument("--verbose", action="store_true")
args = ap.parse_args() args = ap.parse_args()
memory = MushroomBody.load(args.memory) if args.memory else None 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) bag = Bag(args.bag)
n = cand_hit = track_hit = other = 0 n = cand_hit = track_hit = other = 0

View file

@ -107,6 +107,12 @@ def main() -> None:
help="1 — только модель, 0 — только ручная формула") help="1 — только модель, 0 — только ручная формула")
ap.add_argument("--mbon-power", type=float, default=None, ap.add_argument("--mbon-power", type=float, default=None,
help="резкость вероятности модели") 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")) ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
args = ap.parse_args() args = ap.parse_args()
@ -135,6 +141,13 @@ def main() -> None:
over["mbon_blend"] = args.mbon_blend over["mbon_blend"] = args.mbon_blend
if args.mbon_power is not None: if args.mbon_power is not None:
over["mbon_power"] = args.mbon_power 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) params = Params(**over)
readout = None readout = None
folds = {} folds = {}

View file

@ -25,7 +25,7 @@ import _bootstrap as B # noqa: F401
from flyguard.bag import Bag, find_bags from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import Placement, catalogue, inject from flyguard.synth import IntensityEnv, Placement, catalogue, inject
HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект 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: if gres is None or s_track[k] is None:
continue continue
s_now = s_track[k] - have[0] s_now = s_track[k] - have[0]
env = IntensityEnv(pc) # один раз на кадр, общий для сценариев
for i, (name, lat) in enumerate(scen): for i, (name, lat) in enumerate(scen):
d_true = d_start - s_now d_true = d_start - s_now
if d_true < 6.0: 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 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], 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) res = pipes[i].process(pc2)
if res is None: if res is None:
continue continue
@ -102,24 +103,52 @@ def main() -> None:
ap.add_argument("--root", default=str(B.DATA / "for_hackathon")) ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--memory") ap.add_argument("--memory")
ap.add_argument("--mbon", default="", 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("--out", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--limit", type=int, default=250) ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--d-start", type=float, default=200.0) ap.add_argument("--d-start", type=float, default=200.0)
ap.add_argument("--laterals", default="0.0,0.9") ap.add_argument("--laterals", default="0.0,0.9")
ap.add_argument("--seed", type=int, default=12345) 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() args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True) B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
memory = MushroomBody.load(args.memory) if args.memory else None memory = MushroomBody.load(args.memory) if args.memory else None
readout = 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 from flyguard.mbon_readout import MbonReadout
readout = MbonReadout.load(args.mbon) readout = MbonReadout.load(args.mbon)
print(f"считывание MBON: {args.mbon}") print(f"считывание MBON: {args.mbon}")
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(",")) laterals = tuple(float(x) for x in args.laterals.split(","))
all_rec = [] all_rec = []
@ -127,8 +156,14 @@ def main() -> None:
if p.name == HOLDOUT: if p.name == HOLDOUT:
continue continue
t0 = time.time() 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, rec = run_bag(p, params, memory, args.limit, args.d_start, laterals,
args.seed, readout=readout) args.seed, readout=rd)
all_rec.extend(rec) all_rec.extend(rec)
n = sum(len(r["d"]) for r in rec) n = sum(len(r["d"]) for r in rec)
print(f" {p.name:42s} сценариев {len(rec):3d}, наблюдений {n:6d}, " 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.bag import Bag, find_bags
from flyguard.mushroom_body import FEATURES, describe from flyguard.mushroom_body import FEATURES, describe
from flyguard.pipeline import FlyGuard, Params from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import Placement, catalogue, inject from flyguard.synth import IntensityEnv, Placement, catalogue, inject
HOLDOUT = "doubleT_obstacle" # там реальный объект — только для проверки HOLDOUT = "doubleT_obstacle" # там реальный объект — только для проверки
MIN_OVERLAP = 0.5 # доля лучей ядра, пришедших от предмета 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: if g is None or s_track[k] is None:
continue continue
s_now = s_track[k] - s0 s_now = s_track[k] - s0
env = IntensityEnv(pc) # один раз на кадр, общий для сценариев
for i, (name, lat, d0) in enumerate(scen): for i, (name, lat, d0) in enumerate(scen):
d_true = d0 - s_now d_true = d0 - s_now
if d_true < 6.0: if d_true < 6.0:
continue continue
u = float(g.corridor.centre(np.array([d_true], np.float32))[0]) + lat u = float(g.corridor.centre(np.array([d_true], np.float32))[0]) + lat
pc2, lab = inject(pc, layout, g.plane, cat[name], 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) res = pipes[i].process(pc2)
if res is None or not res.candidates: if res is None or not res.candidates:
continue continue

View file

@ -17,10 +17,12 @@ from __future__ import annotations
import argparse import argparse
import json import json
from pathlib import Path
import matplotlib import matplotlib
matplotlib.use("Agg") matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402 import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402 import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402 import _bootstrap as B # noqa: F401,E402
@ -61,6 +63,7 @@ def main() -> None:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--in", dest="path", default=str(B.ARTIFACTS / "benchmark.json")) ap.add_argument("--in", dest="path", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--thr", type=float, default=0.5) ap.add_argument("--thr", type=float, default=0.5)
ap.add_argument("--out", default="", help="куда сохранить рисунок")
args = ap.parse_args() args = ap.parse_args()
rec = json.load(open(args.path, encoding="utf-8")) rec = json.load(open(args.path, encoding="utf-8"))
@ -112,7 +115,7 @@ def main() -> None:
ax.legend(fontsize=7, ncol=2) ax.legend(fontsize=7, ncol=2)
B.FIGURES.mkdir(parents=True, exist_ok=True) 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) fig.tight_layout(); fig.savefig(out)
print("\nсохранено:", 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: def auc(score: np.ndarray, y: np.ndarray) -> float:
pos, neg = score[y == 1], score[y == 0] """Ранговый AUC со СРЕДНИМИ рангами на совпадениях.
if pos.size == 0 or neg.size == 0:
Без усреднения признак, у которого все значения равны (за 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") return float("nan")
order = np.argsort(np.concatenate([pos, neg])) order = np.argsort(score, kind="mergesort")
ranks = np.empty(order.size, np.float64) s = score[order]
ranks[order] = np.arange(1, order.size + 1) start = np.flatnonzero(np.r_[True, s[1:] != s[:-1]])
return float((ranks[:pos.size].sum() - pos.size * (pos.size + 1) / 2) end = np.r_[start[1:], s.size]
/ (pos.size * neg.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: 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} " print(f" {n:<42}{X[n].shape[0]:7d} предметов {int(Y[n].sum()):6d} "
f"({Y[n].mean():5.1%})") 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 best_kc = args.n_kc
if args.sweep_kc.strip(): if args.sweep_kc.strip():