forked from Dan4ick/Lidar_Muxa
Обученное считывание MBON: ложных тревог вдвое меньше
This commit is contained in:
parent
b8e95eccbe
commit
8778bae632
15 changed files with 597 additions and 82 deletions
22
README.md
22
README.md
|
|
@ -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
|
|
@ -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
BIN
artifacts/mbon_readout.npz
Normal file
Binary file not shown.
|
|
@ -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).
|
||||
Потолок здесь геометрический.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +167,12 @@ class FlyGuard:
|
|||
mbon_power=self.p.mbon_power,
|
||||
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,
|
||||
emergency_evidence=0.0, min_hits=1))
|
||||
self._calib: list[PointCloud2] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="датасет не распакован")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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}, "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue