forked from Dan4ick/Lidar_Muxa
синтетика организаторов: облака без ring, габарит 1.2 м и верх до 3.7 м, сброс при скачке времени
This commit is contained in:
parent
2051da2399
commit
d1da4ec117
9 changed files with 838 additions and 38 deletions
24
README.md
24
README.md
|
|
@ -158,6 +158,19 @@ python tools/eval_new_data.py --shards 110: \
|
|||
Проверка на второй половине честная: вставки идут только в первую, а
|
||||
вторую модель не видит ни в каком виде (EXPERIMENTS п. 17.3).
|
||||
|
||||
Синтетику организаторов (`cloud_with_fake_obj`, 10 предметов) сверяет с
|
||||
эталоном отдельный инструмент. Разметки к бэгу нет, но вставленные точки не
|
||||
лежат на элевациях колец, и эталон извлекается из самого бэга (п. 18.2):
|
||||
|
||||
```bash
|
||||
python tools/eval_org_synth.py # итоговые настройки
|
||||
python tools/eval_org_synth.py --set half_width=1.6 --set h_top=0
|
||||
```
|
||||
|
||||
В этом бэге нет поля `ring`, а порядок точек сбит вставками: `retina.py`
|
||||
восстанавливает кольца по элевации и раскладывает такие кадры по углам точек.
|
||||
До этой правки ядро на нём не обрабатывало ни одного кадра (п. 18.1).
|
||||
|
||||
Любое поле `Params` меняется без правки кода: `--set h_lo_core=0.28 --set
|
||||
k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
|
||||
`check_obstacle.py`. Два прогона полигона сравнивайте только парно —
|
||||
|
|
@ -183,13 +196,14 @@ k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
|
|||
| Метрика | Значение | Чем измерено |
|
||||
|---|---|---|
|
||||
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
|
||||
| Ложные тревоги, leave-one-bag-out | **5.7 трека на км**, 8.8 % кадров | `tools/evaluate.py --mbon-dir` |
|
||||
| Синтетика организаторов, 10 предметов | **8 из 10** верно | `tools/eval_org_synth.py` |
|
||||
| Ложные тревоги, leave-one-bag-out | **5.7 трека на км**, 8.4 % кадров | `tools/evaluate.py --mbon-dir` |
|
||||
| То же без обученного считывания | 11.9 на км, 20.3 % кадров | там же, без `--mbon-dir` |
|
||||
| На незнакомой линии (памяти нет) | **14.7 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
|
||||
| Вторая половина `new_data`: другой день, 3.27 км, не видена при обучении | **17.4 на км**, 31.4 % кадров | `tools/eval_new_data.py` |
|
||||
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность **100 м**, P@50 = 0.71, P@100 = 0.50, P@150 = 0.46 | `tools/plot_benchmark.py` |
|
||||
| На незнакомой линии (памяти нет) | **13.8 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
|
||||
| Вторая половина `new_data`: другой день, 3.41 км, не видена при обучении | **14.4 на км**, 20.3 % кадров | `tools/eval_new_data.py` |
|
||||
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность **100 м**, P@50 = 0.72, P@100 = 0.52, P@150 = 0.46 | `tools/plot_benchmark.py` |
|
||||
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 20 м | там же |
|
||||
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
|
||||
| Обработка кадра | медиана 31 мс из бюджета 100 мс; на ядре уровня стенда жюри 50 / 60 мс (медиана / p95) | контейнер, EXPERIMENTS п. 18.5 |
|
||||
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
|
||||
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` |
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,12 +3,12 @@
|
|||
"bag": "doubleT_obstacle",
|
||||
"frames": 190,
|
||||
"path_m": 0.0,
|
||||
"alarm_frames": 153,
|
||||
"alarm_rate": 0.8052631578947368,
|
||||
"fp_objects": 336,
|
||||
"alarm_frames": 130,
|
||||
"alarm_rate": 0.6842105263157895,
|
||||
"fp_objects": 232,
|
||||
"fp_tracks": 5,
|
||||
"fp_per_km": NaN,
|
||||
"fp_median_d": 114.4002904954952,
|
||||
"fp_median_d": 106.83262984484747,
|
||||
"obj_rate": 0.9947368421052631,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"fp_objects": 42,
|
||||
"fp_tracks": 2,
|
||||
"fp_per_km": 9.962686878640993,
|
||||
"fp_median_d": 34.00916383990402,
|
||||
"fp_median_d": 34.009163839731684,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
|
|
@ -63,12 +63,12 @@
|
|||
"bag": "roundT_squareT_pressureGate_squareT",
|
||||
"frames": 239,
|
||||
"path_m": 273.9439574444854,
|
||||
"alarm_frames": 4,
|
||||
"alarm_rate": 0.016736401673640166,
|
||||
"fp_objects": 4,
|
||||
"alarm_frames": 3,
|
||||
"alarm_rate": 0.012552301255230125,
|
||||
"fp_objects": 3,
|
||||
"fp_tracks": 1,
|
||||
"fp_per_km": 3.6503816668511457,
|
||||
"fp_median_d": 162.18072351126122,
|
||||
"fp_median_d": 163.97207106661114,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
|
|
@ -78,12 +78,12 @@
|
|||
"bag": "squareT_platform_squareT_switch",
|
||||
"frames": 239,
|
||||
"path_m": 270.98139848673645,
|
||||
"alarm_frames": 52,
|
||||
"alarm_rate": 0.2175732217573222,
|
||||
"fp_objects": 56,
|
||||
"alarm_frames": 48,
|
||||
"alarm_rate": 0.200836820083682,
|
||||
"fp_objects": 48,
|
||||
"fp_tracks": 3,
|
||||
"fp_per_km": 11.070870608658545,
|
||||
"fp_median_d": 90.95581811929335,
|
||||
"fp_median_d": 93.17164512677327,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
|
|
|
|||
|
|
@ -2707,3 +2707,304 @@ SIGTERM — узел его переживал. Узлы копились: на
|
|||
нужно смотреть глазами: по словам организаторов, препятствий в данных «не
|
||||
больше двух», и одно из них в `doubleT_obstacle` — второе может оказаться
|
||||
здесь.
|
||||
|
||||
## 18. Синтетика организаторов, стенд жюри, RViz на видеокарте (24.09)
|
||||
|
||||
24.09 организаторы выложили бэг с синтетикой «в первом приближении»
|
||||
(`cloud_with_fake_obj`, 7.4 ГБ, 151 с, 1510 кадров на `/lidar_points`): десять
|
||||
предметов примерно через 100 м, в объявленном порядке — 2×2 м посередине
|
||||
габарита, 0.3×0.3 м посередине, 0.3 на рельсе, 0.3 у края, 0.3 вне габарита
|
||||
рядом, 2×2 у края в пределах габарита, 2×2 вне габарита, 2×2 сверху, длинный
|
||||
низкий 2×0.2 м на рельсах, узкий (5 см) длинный с потолка. Это первый бэг в
|
||||
формате, близком к контрольному, и он вскрыл три вещи сразу.
|
||||
|
||||
### 18.1. Формат: нет `ring`, порядок точек нарушен
|
||||
|
||||
В облаке только `x, y, z, intensity` — ни `ring`, ни `timestamp`. Калибровка
|
||||
решётки брала число колец из поля `ring` и падала на первом же кадре, то есть
|
||||
**узел на этом бэге не обработал бы ни одного кадра** — сбой ловился в
|
||||
колбэке, и в журнал шло «сбой обработки кадра» десять раз в секунду.
|
||||
|
||||
Хуже, чем отсутствие поля: число точек в кадре гуляет от 213 988 до 352 977.
|
||||
Синтетика собрана из настоящей записи так: заслонённые предметом точки удалены,
|
||||
точки предмета вписаны в середину массива, и всё, что дальше, сдвинуто. У
|
||||
757 кадров ровно 307 200 точек, но и в них порядок сбит — удалили и вставили
|
||||
поровну. Ни в одном из первых двенадцати кадров порядок не цел.
|
||||
|
||||
Что сделано (`retina.py`):
|
||||
|
||||
* **проверка порядка в каждом кадре.** У целого кадра элевация каждой точки
|
||||
совпадает с элевацией её кольца до 0.0001° — замерено на всех шести записях
|
||||
и на чистых кадрах синтетики (азимут — тоже до 0.0001°). Ближайшие кольца
|
||||
Pandar128 разнесены на 0.086°, вставленные точки уходят от колец на
|
||||
0.02–0.1°, сдвинутые — на целое кольцо. Допуск 0.01°. Проверка идёт по
|
||||
`z` против `r·sin(элевация кольца)`, около миллисекунды;
|
||||
* **раскладка по углам точки** для кадра, не прошедшего проверку: кольцо —
|
||||
ближайшее по элевации, столбец — по азимуту с поправкой кольца, в ячейке
|
||||
ближняя точка идёт в `r_near`, дальняя — в `r_far`. На целых кадрах
|
||||
`roundT_doubleT` и `doubleT_obstacle` результат совпал с быстрым путём
|
||||
**бит в бит** (0 расхождений из 56 863 и 56 020 лучей). Цена — 13 против
|
||||
6 мс на 120° и 22 против 6 мс на 360° (Windows, NumPy 2.4);
|
||||
* **калибровка без `ring`**: кольца — по гистограмме элевации (кольцо ложится в
|
||||
один бин 0.002°, вставленные точки рассыпаны и отсекаются порогом веса); в
|
||||
калибровку по порядку идут только кадры, прошедшие проверку; если таких нет —
|
||||
решётка строится целиком по углам (шаг — самая частая разность азимутов
|
||||
внутри кольца, сдвиг кольца — круговое среднее фазы). На записях с полем
|
||||
`ring`, у которых поле вырезано, калибровка без него дала ту же решётку до
|
||||
последнего знака.
|
||||
|
||||
На синтетике сработал последний вариант: целых кадров среди первых двенадцати
|
||||
нет, решётка построена по углам, и все кадры идут медленным путём. В
|
||||
контейнере: принято 1510, обработано 1499 (11 — калибровка), потерь 0,
|
||||
кадр 43.7 / 50.2 мс (медиана / p95).
|
||||
|
||||
### 18.2. Эталон: вставленные точки не лежат на кольцах
|
||||
|
||||
Разметки к бэгу нет, но она и не нужна: у вставленных точек элевация не
|
||||
совпадает ни с одним кольцом (отклонение больше 0.002°, у настоящих — до
|
||||
0.0001°). Кластеры таких точек по кадрам, связанные от кадра к кадру по
|
||||
дальности, дают все десять предметов в объявленном порядке. Путь для этого не
|
||||
годится: между вторым и третьим предметом поезд стоит (37–40 с), одометрия
|
||||
замирает, и по положению вдоль пути они сливаются.
|
||||
|
||||
Где стоят предметы (x, z — у ближней точки, в системе лидара; до рельса
|
||||
1.32 м вниз):
|
||||
|
||||
| № | предмет | поперёк x, м | по высоте z, м | виден с |
|
||||
|---:|---|---|---|---:|
|
||||
| 1 | 2×2 посередине | −0.99…+1.00 | стоит на полотне | 99 м |
|
||||
| 2 | 0.3 посередине | −0.18…+0.17 | −0.16…+0.16 (висит на высоте лидара) | 118 м |
|
||||
| 3 | 0.3 на рельсе | +0.60…+0.90 | −1.14…−0.84 (на правом рельсе) | 81 м |
|
||||
| 4 | 0.3 у края | +0.84…+1.13 | на высоте лидара | 85 м |
|
||||
| 5 | 0.3 вне габарита | −1.47…−1.14 | на высоте лидара | 94 м |
|
||||
| 6 | 2×2 у края внутри | +0.92…+3.3 | стоит на полотне | 128 м |
|
||||
| 7 | 2×2 вне габарита | −3.7…−1.25 | стоит на полотне | 127 м |
|
||||
| 8 | 2×2 сверху | −1.15…+1.11 | +1.72…+3.8 (низ на 3.0 м над рельсом) | 130 м |
|
||||
| 9 | 2×0.2 на рельсах | −0.95…+1.02 | −1.33…−1.02 | 129 м |
|
||||
| 10 | стержень 5 см с потолка | 0.00 | +1.49…+3.4 (низ на 2.8 м над рельсом) | 95 м |
|
||||
|
||||
Отсюда же видно, что наша плоскость рельсов (1.32 м под лидаром) и есть
|
||||
головка рельса: длинный предмет «на рельсах» лежит низом на 0.05 м над ней,
|
||||
а ящик 2×2 стоит на полотне на 0.15 м ниже. Организаторы в чате назвали
|
||||
1075 мм — на этих данных это не подтверждается.
|
||||
|
||||
И ещё одно свойство синтетики: отдельные вставленные точки встречаются на
|
||||
200–280 м и на 6–17 м выше пути — предмет вставляется, похоже, без проверки,
|
||||
виден ли он через изгиб тоннеля.
|
||||
|
||||
### 18.3. Что узел видит на синтетике при нынешних настройках
|
||||
|
||||
Сверка решений с эталоном (`tools/eval_org_synth.py`; память и считывание из
|
||||
`artifacts`, как в образе):
|
||||
|
||||
| № | предмет | виден с | первое срабатывание | итог |
|
||||
|---:|---|---:|---:|---|
|
||||
| 1 | 2×2 посередине | 99 м | 95.3 м | найден |
|
||||
| 2 | 0.3 посередине (висит на высоте лидара) | 118 м | 41.7 м | найден |
|
||||
| 3 | 0.3 на рельсе | 81 м | 36.3 м | найден |
|
||||
| 4 | 0.3 у края | 85 м | 31.3 м | найден |
|
||||
| 5 | 0.3 вне габарита | 94 м | 31.1 м, p ≤ 0.68 | **ложная тревога** |
|
||||
| 6 | 2×2 у края внутри | 128 м | — | **пропущен** |
|
||||
| 7 | 2×2 вне габарита | 127 м | — | верно молчит |
|
||||
| 8 | 2×2 сверху | 130 м | — | **пропущен** |
|
||||
| 9 | 2×0.2 на рельсах | 129 м | 79.0 м | найден |
|
||||
| 10 | стержень 5 см с потолка | 95 м | — | **пропущен** |
|
||||
|
||||
Шесть из десяти, и три ошибки из четырёх — о границах габарита, а не о
|
||||
чувствительности:
|
||||
|
||||
* **ширина.** Расстановка организаторов задаёт полуширину около 1.15 м:
|
||||
«у края» (№ 4) кончается на 1.13 м от оси, «вне габарита, но близко» (№ 5)
|
||||
начинается с 1.14–1.15 м, «2×2 вне» (№ 7) — с 1.25 м. У нас 1.6 м, и № 5
|
||||
целиком наш. В `doubleT_obstacle` то же видно по настоящей обстановке:
|
||||
колонны между путями на 17, 34 и 47 м заходят в полосу ±1.6 м (u от 0.83 м);
|
||||
* **высота.** Верх габарита у нас 2.3 м над головкой рельса, а № 8 висит низом
|
||||
на 3.0 м и № 10 — на 2.8 м. Вагон метро выше 3.5 м, и оба в его путь попадают.
|
||||
2.3 м когда-то выбиралось, чтобы не цеплять кабели и светильники свода;
|
||||
* **№ 6** заходит в габарит на 0.3 м, а остальные 2 м его — снаружи. Для
|
||||
конвейера это ровно то же, что шкаф или кромка платформы: доля лучей внутри
|
||||
габарита (`containment`) мала, и вес улики гасится. Отличить предмет,
|
||||
который въехал в габарит, от конструкции, которая всегда там стояла, по
|
||||
одному кадру нельзя; это работа памяти тоннеля, а синтетика встала на чужом
|
||||
для неё участке;
|
||||
* **№ 10** — 5 см при шаге развёртки 0.1°: до 30 м в стержень попадает один
|
||||
столбец лучей, дальше — через раз. В эталоне у него 3–13 точек на кадр до
|
||||
20 м. Ниже разрешения прибора, честный предел.
|
||||
|
||||
Вне эталона — четыре коротких ложных трека (39 кадров за 151 с), все с
|
||||
уверенностью не выше 0.47.
|
||||
|
||||
### 18.5. Стенд жюри на своём процессоре
|
||||
|
||||
У жюри i7-9700E (8 ядер без гиперпотоков, 2.6 ГГц, турбо 4.4), у нас Ryzen 5
|
||||
7600X. Узел однопоточный (п. 18.7), поэтому решает производительность одного
|
||||
ядра: по PassMark single thread 2511 против 4129, отношение 0.61.
|
||||
`docker/jury_cpu_test.sh` запускает узел в отдельном контейнере с квотой 0.61
|
||||
ядра (периоды по 10 мс, чтобы не было рывков по 61/39 мс), а проигрыватель и
|
||||
подсчёт — во втором контейнере без квоты, как на стенде, где у проигрывателя
|
||||
свои ядра.
|
||||
|
||||
| запись | квота | принято | кадр, медиана / p95 / макс, мс |
|
||||
|---|---:|---:|---:|
|
||||
| `doubleT_obstacle` | 1.0 | 201 из 201 | 35.8 / 40.7 / 50.0 |
|
||||
| `doubleT_obstacle` | 0.61 | 201 из 201 | 56.7 / 63.3 / 97.9 |
|
||||
| `cloud_with_fake_obj` | 1.0 | 1510 из 1510 | 48.0 / 56.8 / 124.5 |
|
||||
| `cloud_with_fake_obj` | 0.61 | 1498 из 1510 | 72.5 / 106.8 / 200.4 |
|
||||
|
||||
Обнаружение не меняется: 189 кадров с тревогой из 190 при обеих квотах. На
|
||||
реальной записи запас до 100 мс остаётся и на слабом ядре. На синтетике —
|
||||
нет: p95 106.8 мс, и 12 кадров из 1510 (0.8 %) не дошли, пока узел доедал
|
||||
очередь. Синтетика дороже по двум причинам: каждый её кадр идёт раскладкой
|
||||
по углам (п. 18.1, +5 мс), и лобула на ней тяжелее — сцена сложнее. Квота —
|
||||
грубое подобие: у 9700E другие кэш и память, а в одном потоке он уходит в
|
||||
турбо, так что оценка скорее с запасом.
|
||||
|
||||
Таблица выше снята до смены габарита (п. 18.4). С итоговыми настройками лучей
|
||||
в габарите меньше, и кадр дешевле — повтор на итоговом образе:
|
||||
|
||||
| запись | квота | принято | кадр, медиана / p95 / макс, мс |
|
||||
|---|---:|---:|---:|
|
||||
| `doubleT_obstacle` | 1.0 | 201 из 201 | 30.7 / 33.2 / 36.0 |
|
||||
| `doubleT_obstacle` | 0.61 | 201 из 201 | 49.7 / 60.2 / 67.8 |
|
||||
| `cloud_with_fake_obj` | 1.0 | 1510 из 1510 | 41.8 / 45.9 / 53.7 |
|
||||
| `cloud_with_fake_obj` | 0.61 | **1510 из 1510** | 62.7 / **71.2** / 99.9 |
|
||||
|
||||
На слабом ядре укладываются обе записи, кадры больше не теряются.
|
||||
|
||||
### 18.6. RViz: 30 кадров в настройках, 2–5 на деле
|
||||
|
||||
Жалоба: при вращении сцены мышью изображение идёт рывками, хотя в конфиге
|
||||
`Frame Rate: 30`. Причина — в контейнере нет видеокарты, и OpenGL рисуется
|
||||
программно (Mesa `llvmpipe` на процессоре); 30 — потолок, а не факт.
|
||||
`docker/gl_probe.py` показывает, чем рисует контейнер, без окна (EGL на
|
||||
pbuffer 1×1):
|
||||
|
||||
| запуск | renderer | процессор у RViz при проигрывании |
|
||||
|---|---|---:|
|
||||
| как было | `llvmpipe (LLVM 15.0.7, 256 bits)` | 188 % |
|
||||
| `--device /dev/dxg -v /usr/lib/wsl:/usr/lib/wsl:ro -e LD_LIBRARY_PATH=/usr/lib/wsl/lib` | `D3D12 (NVIDIA GeForce RTX 5070 Ti)` | 21 % |
|
||||
|
||||
Это Docker в WSL 2; на Linux то же дают `--gpus all` (NVIDIA) или
|
||||
`--device /dev/dri` (Intel/AMD), в README — таблица. Детектор обработал в обоих
|
||||
случаях одни и те же 190 кадров из 190.
|
||||
|
||||
### 18.7. Потоки BLAS: три ядра впустую
|
||||
|
||||
Замер процессора по `/proc/<pid>/stat` за 10 с проигрывания показал, что узел
|
||||
занимает **297 %** ядра при кадре 33 мс. Причина — `OPENBLAS_NUM_THREADS=4`
|
||||
в образе: матрицы конвейера мелкие, и потоки OpenBLAS не ускоряют кадр, а
|
||||
крутятся в ожидании работы. С одним потоком:
|
||||
|
||||
| | процессор узла | `doubleT_obstacle`, мс | синтетика, мс |
|
||||
|---|---:|---:|---:|
|
||||
| 4 потока BLAS | 297 % | 33.2 / 40.5 | 45.9 / 54.7 |
|
||||
| 1 поток | 41 % | 33.9 / 39.3 | 43.7 / 50.2 |
|
||||
|
||||
Кадр не медленнее, а по p95 даже быстрее: потоки BLAS отбирали ядра у самого
|
||||
узла и у проигрывателя. На стенде с 8 ядрами это освобождает три ядра под
|
||||
`ros2 bag play` и RViz. Один поток теперь и в образе (`ENV`), и в launch
|
||||
(`additional_env` узла) — на случай запуска без нашего образа.
|
||||
|
||||
### 18.8. Пауза и перемотка, скачок времени
|
||||
|
||||
Пользователь не успевал разглядеть срабатывания: проигрыватель, запущенный
|
||||
из launch, клавиатуры не слышит — у него нет терминала. В образе теперь
|
||||
пульт `flyguard-keys` (`flyguard/player_keys.py`): из второго терминала,
|
||||
`docker exec -it <контейнер> flyguard-keys /data/<запись>`, через сервисы
|
||||
rosbag2 — пауза, ±3 с, кадр вперёд, скорость ×0.1…×4, в начало. Время записи
|
||||
пульт берёт из `/clock` (launch запускает проигрыватель с `--clock 20`);
|
||||
подписка на `/clock` обязана быть best effort — с надёжной QoS не сходится, и
|
||||
время не приходит вовсе (поймано на первой проверке).
|
||||
|
||||
Перемотка назад вскрыла дыру в конвейере: треки, одометрия, ось пути и
|
||||
веерное тело живут в координатах пути, и после скачка назад предмет с
|
||||
прошлого места всплыл бы там, где его нет. То же — на каждом круге
|
||||
`ros2 bag play --loop` и при выпадении данных на живом поезде. Теперь
|
||||
скачок времени записи больше 2 с или назад сбрасывает всё, что копится от
|
||||
кадра к кадру (`FlyGuard._init_temporal`; решётка лучей остаётся — она от
|
||||
сенсора). Узел пишет об этом в журнал. Проверено: после перемотки на 3 с
|
||||
назад узел сбросил состояние и заново выдал `ПРЕПЯТСТВИЕ: 56.0 м` за 0.2 с;
|
||||
в офлайне второй круг по тем же 40 кадрам — 39 тревог из 40.
|
||||
|
||||
Ещё две мелочи оттуда же. `demo_test.sh` гасил узел `kill -INT`, а фоновые
|
||||
задания неинтерактивного bash к SIGINT глухи — узел висел 10 с и уходил по
|
||||
`kill -9`, без итоговой строки. Теперь `set -m`, узел выходит за 0.5 с. И
|
||||
подпись рамки в RViz была «55 м» с пустым местом вместо «м»: в шрифте RViz нет
|
||||
кириллицы — теперь «55 m».
|
||||
|
||||
### 18.4. Габарит 1.2 м и верхняя секция до 3.7 м — принято
|
||||
|
||||
Правка по п. 18.3: полуширина 1.6 → 1.2 м, и над основной частью (до 2.3 м)
|
||||
добавлена верхняя секция до 3.7 м над рельсом — уже основной, |u| < 1.0 м, и
|
||||
не дальше 90 м (`h_top`, `half_width_top`, `top_d_max`). Узкая она потому,
|
||||
что у стен на этой высоте кабели и светильники, короткая — потому, что дальше
|
||||
ошибка наклона плоскости и оси поднимает конструкции свода в габарит: на пустых
|
||||
записях без ограничения по дальности в секцию лезли сотни кандидатов.
|
||||
|
||||
Висящему в верхней секции не нужна опора снизу, и обученное считывание его не
|
||||
судит: модель учили на стоящих предметах, и она гасила висящее
|
||||
(`central_complex._quality`, `top_from`). Без этого «2×2 сверху» не находился
|
||||
и с секцией.
|
||||
|
||||
Сравнение с прежними настройками, все прогоны парные:
|
||||
|
||||
| проверка | 1.6 м, до 2.3 м | 1.2 м + верхняя секция |
|
||||
|---|---|---|
|
||||
| синтетика организаторов | 6 из 10 | **8 из 10**: № 5 теперь молчит, № 8 найден с 83.5 м |
|
||||
| полигон, наблюдения | — | 0→1: 43, 1→0: 33 |
|
||||
| полигон, посторонние тревоги | 793 | **742** |
|
||||
| знакомая линия (LOO, с памятью) | 5.7 на км, 8.8 % кадров | 5.7 на км, **8.4 %** |
|
||||
| незнакомая (без памяти) | 14.7 на км, 25.9 % | **13.8** на км, **24.3 %** |
|
||||
| вторая половина `new_data` (со сбросом, п. 18.9) | 13.8 на км, 29.0 % | 14.4 на км, **20.3 %** |
|
||||
| `doubleT_obstacle` | 99.5 % | 99.5 % |
|
||||
|
||||
На полигоне выиграли человек стоя (P@100 0.50 → 0.52) и чемодан (рабочая
|
||||
дальность 80 → 100 м, P@100 0.46 → 0.50), проиграли ящик на 90–130 м
|
||||
(P@100 0.50 → 0.40) и лежащий человек (0.22 → 0.19). Оба проигравших у края:
|
||||
при положении ±0.9 м их край доходит до 1.15–1.8 м, и доля лучей внутри
|
||||
габарита падает.
|
||||
|
||||
Физически половина ширины вагона метро — 1.35 м, и габарит 1.2 м уже
|
||||
вагона. Но оценивают по расстановке организаторов, а она задаёт границу
|
||||
около 1.15 м. Вернуть прежний габарит — `half_width:=1.6 h_top:=0`.
|
||||
|
||||
Не найдены по-прежнему № 6 (2×2, в габарит заходит на 0.3 м) и № 10
|
||||
(стержень 5 см) — причины в п. 18.3.
|
||||
|
||||
### 18.9. Сброс при скачке времени на `new_data`
|
||||
|
||||
Сброс из п. 18.8 задуман для перемотки, но первым сработал на данных. Во
|
||||
второй половине `new_data` 70 с выпавших кадров: по `metadata.yaml` в 17
|
||||
шардах набегает больше 2 с сверх 0.1 с на кадр (в первой половине — ни в
|
||||
одном). Парный замер (`eval_new_data.py`, `reset_gap_s=0` против умолчания):
|
||||
|
||||
| | ложных треков | путь | на км | кадров с тревогой |
|
||||
|---|---:|---:|---:|---:|
|
||||
| без сброса | 57 | 3.27 км | 17.4 | 31.4 % |
|
||||
| со сбросом | 47 | 3.41 км | **13.8** | **29.0 %** |
|
||||
|
||||
Без сброса получается ровно прежняя цифра 17.4 из README — значит, других
|
||||
изменений на реальных данных нет. После разрыва оценщик собственного
|
||||
движения сравнивал профили кадров, разнесённых на секунды, как соседние, и
|
||||
занижал путь: треки уезжали из своего места и заводились заново под новыми
|
||||
номерами. На записях `for_hackathon` разрывов нет, и там сброс ничего не
|
||||
меняет (полигон при прежних настройках — 0 переворотов из 15 560).
|
||||
|
||||
### 18.10. Итог
|
||||
|
||||
* Узел читает облака без `ring` и с нарушенным порядком точек — без этого
|
||||
синтетику (и, скорее всего, контрольный бэг) он не обработал бы вовсе.
|
||||
* Габарит 1.2 м + верхняя секция до 3.7 м: синтетика 6 → 8 из 10, остальное
|
||||
не хуже. Артефакты `benchmark.json`, `generalisation.json`, `gen_cold.json`
|
||||
и рисунок дальности пересобраны под новые настройки.
|
||||
* Сброс при скачке времени: пульт с перемоткой и `--loop` работают честно,
|
||||
`new_data` 17.4 → 13.8 ложного трека на км.
|
||||
* BLAS в один поток: узел 3 ядра → 0.4. RViz на видеокарте: 1.9 ядра → 0.2.
|
||||
* На ядре стенда жюри (квота 0.61) с итоговыми настройками укладываются обе
|
||||
записи: реальная — p95 60 мс, синтетика — p95 71 мс, все 1510 кадров (до
|
||||
смены габарита синтетика давала p95 107 мс и теряла 0.8 % кадров).
|
||||
* Три тревоги на двух записях, подозревавшиеся как второе препятствие, —
|
||||
не препятствия: плоская полоса у гермозатвора (п. 17.7), плоская деталь на полотне
|
||||
в 0.19 м над рельсом (вблизи — 1–6 см высотой, память её знает) и
|
||||
дальнее пятно на 147 м, которого при подъезде нет. Настоящий объект в
|
||||
`for_hackathon` один — в `doubleT_obstacle`.
|
||||
|
|
|
|||
|
|
@ -148,9 +148,10 @@ class CentralComplex:
|
|||
max_tracks: int = 48, use_shape: bool = True,
|
||||
mbon_power: float = 1.0, mbon_blend: float = 1.0,
|
||||
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0,
|
||||
lying_exempt: bool = False):
|
||||
lying_exempt: bool = False, top_from: float = float("inf")):
|
||||
self.use_shape = use_shape
|
||||
self.lying_exempt = lying_exempt
|
||||
self.top_from = top_from # низ выше этого — предмет в верхней секции габарита
|
||||
self.mbon_power = mbon_power
|
||||
self.mbon_blend = mbon_blend
|
||||
self.nov_fade_from = nov_fade_from
|
||||
|
|
@ -227,7 +228,7 @@ class CentralComplex:
|
|||
t.misses = 0
|
||||
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
|
||||
self.nov_fade_from, self.nov_fade_to,
|
||||
self.lying_exempt)
|
||||
self.lying_exempt, self.top_from)
|
||||
t.observe(c, self.s_world + c.d, w)
|
||||
t.evidence = min(1.0, t.evidence + self.gain * w)
|
||||
if dt > 1e-3:
|
||||
|
|
@ -255,7 +256,7 @@ class CentralComplex:
|
|||
last_n_rays=c.n_rays, novelty=c.novelty)
|
||||
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
|
||||
self.nov_fade_from, self.nov_fade_to,
|
||||
self.lying_exempt)
|
||||
self.lying_exempt, self.top_from)
|
||||
t.observe(c, self.s_world + c.d, w)
|
||||
t.evidence = self.gain * w
|
||||
t.hits = 1
|
||||
|
|
@ -299,7 +300,8 @@ def expected_rays(d: float) -> float:
|
|||
|
||||
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
||||
mbon_blend: float = 1.0, nov_fade_from: float = 0.0,
|
||||
nov_fade_to: float = 160.0, lying_exempt: bool = False) -> float:
|
||||
nov_fade_to: float = 160.0, lying_exempt: bool = False,
|
||||
top_from: float = float("inf")) -> float:
|
||||
"""Вес одного наблюдения: сколько улики оно добавляет.
|
||||
|
||||
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
|
||||
|
|
@ -336,8 +338,11 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
|||
else:
|
||||
compact = float(np.clip(1.5 - c.depth / (3.0 * span), 0.1, 1.0))
|
||||
# опора снизу: упавший предмет, человек, камень стоят на полотне, а знак,
|
||||
# лоток или кронштейн висят на стене, и под ними пусто
|
||||
grounded = float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))
|
||||
# лоток или кронштейн висят на стене, и под ними пусто. Кроме верхней
|
||||
# секции габарита (`top_from`): у оси под сводом стен нет, и висящее
|
||||
# там — это то, что свисает в путь вагона.
|
||||
grounded = (1.0 if c.h_min >= top_from
|
||||
else float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0)))
|
||||
else:
|
||||
whole = compact = grounded = 1.0
|
||||
# Знакомость на большой дальности не просто бесполезна, а вредна. Замер:
|
||||
|
|
@ -363,7 +368,9 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
|||
# модель, 0 — только руками, между ними всё промежуточное. Нужно затем,
|
||||
# чтобы размен «модель против ручной формулы» мерился, а не объявлялся.
|
||||
p = c.extra.get("mbon") if c.extra else None
|
||||
if p is not None and mbon_blend > 0.0:
|
||||
# висящее в верхней секции модель не видела ни разу (обучена на стоящих
|
||||
# предметах) и гасит его — здесь решает ручная формула
|
||||
if p is not None and mbon_blend > 0.0 and c.h_min < top_from:
|
||||
pm = float(np.clip(p, 1e-4, 1.0)) ** max(mbon_power, 1e-3)
|
||||
b = float(np.clip(mbon_blend, 0.0, 1.0))
|
||||
hand = pm ** b * max(hand, 1e-4) ** (1.0 - b)
|
||||
|
|
|
|||
|
|
@ -228,11 +228,20 @@ def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
|
|||
core_width: float = 0.85, core_from: float = 0.0,
|
||||
platform_filter: bool = False,
|
||||
platform_u: float = 1.30, platform_h_lo: float = 1.05,
|
||||
platform_h_hi: float = 1.25):
|
||||
platform_h_hi: float = 1.25,
|
||||
h_top: float = 0.0, half_width_top: float = 0.0,
|
||||
top_d_max: float = 90.0):
|
||||
"""Лучи, попавшие в габарит приближения. Считается один раз на кадр.
|
||||
|
||||
Нужны и для выделения кандидатов, и для накопителя веерного тела, поэтому
|
||||
вынесены отдельно: пересчитывать один и тот же набор масок дважды незачем.
|
||||
|
||||
Верхняя секция (`h_top` > `h_hi`) — от `h_hi` до `h_top`, уже основной:
|
||||
|u| < `half_width_top`. Она ловит то, что свисает со свода в путь вагона,
|
||||
и не цепляет кабели и светильники на стенах, которые у круглого свода на
|
||||
этой высоте подходят к оси ближе полуширины габарита. Дальше `top_d_max`
|
||||
её нет: там ошибка наклона плоскости и оси пути поднимает и сдвигает к
|
||||
оси конструкции свода, и на пустых записях они шли в кандидаты сотнями.
|
||||
"""
|
||||
lat = tf.lateral(corridor)
|
||||
abs_lat = np.abs(lat)
|
||||
|
|
@ -252,6 +261,9 @@ def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
|
|||
min(h_lo_core, h_lo), h_lo)
|
||||
|
||||
inside = in_range & (abs_lat < w_eff) & (tf.h > floor_h) & (tf.h < h_hi)
|
||||
if h_top > h_hi and half_width_top > 0.0:
|
||||
inside |= (in_range & (tf.d < top_d_max) & (abs_lat < half_width_top)
|
||||
& (tf.h >= h_hi) & (tf.h < h_top))
|
||||
|
||||
if platform_filter:
|
||||
platform = (abs_lat >= platform_u) & (tf.h >= platform_h_lo) & (tf.h <= platform_h_hi)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ class Params:
|
|||
"""Все настройки конвейера в одном месте."""
|
||||
|
||||
fov_deg: float = 30.0 # полусектор обработки по азимуту
|
||||
half_width: float = 1.6 # полуширина габарита, м
|
||||
# Полуширина габарита. 1.2, а не 1.6: так габарит задают организаторы в
|
||||
# синтетике 24.09 — «у края» кончается на 1.13 м от оси, «вне габарита,
|
||||
# но близко» начинается с 1.14. При 1.6 второй давал ложную тревогу, а
|
||||
# в `doubleT_obstacle` в полосу заходили колонны между путями. Полигон
|
||||
# парно +43 / −33 наблюдения, посторонних 793 → 742 (EXPERIMENTS п. 18.4).
|
||||
half_width: float = 1.2
|
||||
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
|
||||
# Три правки габарита (Zhirik1337, 22.09), каждая мерилась отдельно на
|
||||
# двух сценах и полигоне (EXPERIMENTS п. 16):
|
||||
|
|
@ -54,6 +59,14 @@ class Params:
|
|||
k_sigma: float = 0.0
|
||||
platform_filter: bool = False
|
||||
h_hi: float = 2.3 # верхняя граница, м
|
||||
# Верхняя секция габарита: от h_hi до h_top при |u| < half_width_top —
|
||||
# то, что свисает со свода в путь вагона (см. lobula.gauge_mask). Вагон
|
||||
# выше 3.5 м, а предмет «2×2 сверху» у организаторов висит низом на 3.0 м
|
||||
# над рельсом — при верхе 2.3 м он был не виден вовсе. h_top <= h_hi —
|
||||
# секции нет. EXPERIMENTS п. 18.4.
|
||||
h_top: float = 3.7
|
||||
half_width_top: float = 1.0
|
||||
top_d_max: float = 90.0 # дальше верхней секции нет, м
|
||||
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
|
||||
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
|
||||
# порог скорости сближения вдоль луча, в долях пройденного пути.
|
||||
|
|
@ -184,6 +197,10 @@ class Params:
|
|||
min_rays_far: int = 3 # 0 — не различать
|
||||
min_rays_far_from: float = 90.0
|
||||
calib_frames: int = 12
|
||||
# Скачок времени записи больше этого (или назад) — перемотка, повтор или
|
||||
# выпадение данных: треки, одометрия и ось пути начинаются заново. 0 — не
|
||||
# сбрасывать никогда.
|
||||
reset_gap_s: float = 2.0
|
||||
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
|
||||
# Где считать ламину: 'cpu', 'cuda' или 'auto' (видеокарта, если есть).
|
||||
# По умолчанию процессор, и это решение, а не осторожность: GPU ускоряет
|
||||
|
|
@ -240,7 +257,19 @@ class FlyGuard:
|
|||
self.layout_full = layout
|
||||
self.layout: ScanLayout | None = None
|
||||
self.cols: slice | None = None
|
||||
self._track_readout = track_readout
|
||||
self._init_temporal()
|
||||
self._calib: list[PointCloud2] = []
|
||||
self.frames_seen = 0
|
||||
self._last_stamp: float | None = None
|
||||
self.time_jumps = 0 # сколько раз время записи скакало
|
||||
|
||||
def _init_temporal(self) -> None:
|
||||
"""Всё, что копится от кадра к кадру: треки, одометрия, ось, улики.
|
||||
|
||||
Решётка лучей сюда не входит — она от сенсора, а не от места на пути.
|
||||
"""
|
||||
track_readout = self._track_readout
|
||||
self.plane: RailPlane | None = None
|
||||
self._prev_r: np.ndarray | None = None
|
||||
self.corridor: Corridor | None = None
|
||||
|
|
@ -261,7 +290,9 @@ class FlyGuard:
|
|||
leak_far_from=self.p.leak_far_from,
|
||||
nov_fade_from=self.p.nov_fade_from,
|
||||
nov_fade_to=self.p.nov_fade_to,
|
||||
lying_exempt=self.p.lying_exempt)
|
||||
lying_exempt=self.p.lying_exempt,
|
||||
top_from=(self.p.h_hi if self.p.h_top > self.p.h_hi
|
||||
else float("inf")))
|
||||
# без накопления улик трек подтверждается первым же наблюдением
|
||||
self.dn = (DescendingNeurons(
|
||||
warn_evidence=self.p.warn_evidence,
|
||||
|
|
@ -278,8 +309,6 @@ class FlyGuard:
|
|||
far_channel=self.p.far_channel) if self.p.use_tracking
|
||||
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
|
||||
emergency_evidence=0.0, min_hits=1))
|
||||
self._calib: list[PointCloud2] = []
|
||||
self.frames_seen = 0
|
||||
|
||||
# ------------------------------------------------------------------ калибровка
|
||||
|
||||
|
|
@ -349,6 +378,14 @@ class FlyGuard:
|
|||
t = _Timer()
|
||||
if not self._ensure_layout(pc):
|
||||
return None
|
||||
# Скачок времени записи — перемотка, `ros2 bag play --loop`, разрыв в
|
||||
# записи. Треки, одометрия и ось пути относятся к другому месту, и
|
||||
# с ними предмет с прошлого круга всплыл бы там, где его нет.
|
||||
if (self._last_stamp is not None and self.p.reset_gap_s > 0
|
||||
and not (-0.05 < pc.stamp - self._last_stamp < self.p.reset_gap_s)):
|
||||
self._init_temporal()
|
||||
self.time_jumps += 1
|
||||
self._last_stamp = pc.stamp
|
||||
self.frames_seen += 1
|
||||
|
||||
with t("retina"):
|
||||
|
|
@ -391,7 +428,10 @@ class FlyGuard:
|
|||
h_lo_core=self.p.h_lo_core,
|
||||
core_width=self.p.core_width,
|
||||
core_from=self.p.core_from,
|
||||
platform_filter=self.p.platform_filter)
|
||||
platform_filter=self.p.platform_filter,
|
||||
h_top=self.p.h_top,
|
||||
half_width_top=self.p.half_width_top,
|
||||
top_d_max=self.p.top_d_max)
|
||||
cands = find_candidates(tf, lam, self.corridor, masks=masks,
|
||||
half_width=self.p.half_width, h_lo=self.p.h_lo,
|
||||
h_hi=self.p.h_hi, ctx_up=self.p.ctx_up,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@
|
|||
направлением: соседние кольца одного столбца смотрят в стороны, разнесённые на
|
||||
градусы. Поэтому образ **выпрямляется** целочисленным сдвигом строк; остаточная
|
||||
ошибка < половины шага азимута и учитывается в таблице направлений.
|
||||
|
||||
Быстрый путь опирается на порядок точек (столбец · эхо · кольцо). В синтетическом
|
||||
бэге организаторов он соблюдается не везде: у облака нет поля `ring`, а там, где
|
||||
вставлен предмет, заслонённые им точки удалены, а точки предмета вписаны в
|
||||
середину массива — всё, что дальше, сдвинуто (так в половине кадров). Поэтому
|
||||
порядок проверяется в каждом кадре: у кадра с целым порядком элевация каждой
|
||||
точки совпадает с элевацией её кольца до 0.0001°. Кадр, где это не так,
|
||||
раскладывается в ту же решётку **по углам каждой точки** — медленнее, зато без
|
||||
допущений о порядке. Калибровка без поля `ring` находит кольца по гистограмме
|
||||
элевации и берёт только кадры с целым порядком, а если таких нет — строит
|
||||
решётку целиком по углам.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -32,6 +43,12 @@ from .cdr import PointCloud2
|
|||
|
||||
DEG = np.pi / 180.0
|
||||
|
||||
# Допуск проверки порядка точек: в целом кадре элевация точки совпадает с
|
||||
# элевацией её кольца до 0.0001° (замерено на всех бэгах и на синтетике), а
|
||||
# ближайшие кольца Pandar128 разнесены на 0.086°. Вставленные точки предмета
|
||||
# уходят от колец на 0.03° и больше, сдвинутые — на целое кольцо.
|
||||
ORDER_TOL_DEG = 0.01
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeImage:
|
||||
|
|
@ -65,7 +82,7 @@ class ScanLayout:
|
|||
|
||||
def __init__(self, el_deg: np.ndarray, az_step_deg: float, az0_deg: float,
|
||||
col_shift: np.ndarray, az_resid_deg: np.ndarray,
|
||||
n_az: int, n_echo: int, wrap: bool = False):
|
||||
n_az: int, n_echo: int, wrap: bool = False, indexed: bool = True):
|
||||
self.el_deg = np.asarray(el_deg, np.float64) # (H,)
|
||||
self.az_step_deg = float(az_step_deg) # шаг на азимутальный индекс
|
||||
self.az0_deg = float(az0_deg)
|
||||
|
|
@ -74,7 +91,13 @@ class ScanLayout:
|
|||
self.n_az = int(n_az)
|
||||
self.n_echo = int(n_echo)
|
||||
self.wrap = bool(wrap) # круговой скан: края смыкаются
|
||||
# False — порядок точек неизвестен (решётка построена по углам), и
|
||||
# каждый кадр раскладывается по углам точек
|
||||
self.indexed = bool(indexed)
|
||||
self.n_rings = self.el_deg.size
|
||||
self.n_points = self.n_az * self.n_echo * self.n_rings
|
||||
self.n_geometric = 0 # сколько кадров пришлось раскладывать по углам
|
||||
self._init_lookup()
|
||||
|
||||
self.az_grid_deg = self.az0_deg + self.az_step_deg * np.arange(self.n_az)
|
||||
|
||||
|
|
@ -103,6 +126,12 @@ class ScanLayout:
|
|||
c = np.cos(el)
|
||||
return np.stack([c * np.sin(az), -c * np.cos(az), np.sin(el)], axis=-1)
|
||||
|
||||
def _init_lookup(self) -> None:
|
||||
"""Таблицы для проверки порядка точек и поиска кольца по элевации."""
|
||||
self._sin_el = np.sin(self.el_deg * DEG).astype(np.float32)
|
||||
self._el_order = np.argsort(self.el_deg)
|
||||
self._el_asc = self.el_deg[self._el_order]
|
||||
|
||||
def column_slice(self, half_fov_deg: float) -> slice:
|
||||
"""Непрерывный диапазон столбцов внутри ±half_fov по азимуту."""
|
||||
inside = np.flatnonzero(np.abs(self.az_grid_deg) <= half_fov_deg)
|
||||
|
|
@ -123,7 +152,11 @@ class ScanLayout:
|
|||
out.n_az = stop - start
|
||||
out.n_echo = self.n_echo
|
||||
out.wrap = False # вырезанный сектор больше не смыкается
|
||||
out.indexed = self.indexed
|
||||
out.n_rings = self.n_rings
|
||||
out.n_points = self.n_points
|
||||
out.n_geometric = 0
|
||||
out._init_lookup()
|
||||
out.az_grid_deg = self.az_grid_deg[start:stop]
|
||||
out.gather_ok = self.gather_ok[:, start:stop]
|
||||
out.gather = self.gather[:, start:stop]
|
||||
|
|
@ -141,11 +174,23 @@ class ScanLayout:
|
|||
арифметика (корень по 900 тыс. точек) выполняется только над теми
|
||||
сырыми столбцами, которые в этот диапазон попадут с учётом скоса
|
||||
каналов, — на круговом скане это экономит почти всё время стадии.
|
||||
|
||||
Кадр с нарушенным порядком точек (см. докстроку модуля) раскладывается
|
||||
по углам точек: результат тот же, что дал бы целый кадр.
|
||||
"""
|
||||
n, w, e = self.n_rings, self.n_az, self.n_echo
|
||||
w = self.n_az
|
||||
start = 0 if cols is None else (cols.start or 0)
|
||||
stop = w if cols is None else (cols.stop if cols.stop is not None else w)
|
||||
if self.indexed and pc.n_points == self.n_points:
|
||||
img = self._project_indexed(pc, start, stop)
|
||||
if img is not None:
|
||||
return img
|
||||
self.n_geometric += 1
|
||||
return self._project_geometric(pc, start, stop)
|
||||
|
||||
def _project_indexed(self, pc: PointCloud2, start: int, stop: int) -> RangeImage | None:
|
||||
"""Быстрый путь по порядку точек. None — порядок в кадре нарушен."""
|
||||
n, w, e = self.n_rings, self.n_az, self.n_echo
|
||||
lo = start + int(self.col_shift.min())
|
||||
hi = stop + int(self.col_shift.max())
|
||||
if self.wrap:
|
||||
|
|
@ -165,7 +210,14 @@ class ScanLayout:
|
|||
x, y, z = cube("x"), cube("y"), cube("z")
|
||||
good = (x != 0) | (y != 0) | (z != 0)
|
||||
r = np.sqrt(x * x + y * y + z * z, dtype=np.float32)
|
||||
r *= good
|
||||
good &= np.isfinite(r) # драйверы, отдающие «нет эха» как NaN
|
||||
r = np.where(good, r, np.float32(0.0))
|
||||
|
||||
# Порядок: элевация каждой точки должна совпасть с элевацией её кольца.
|
||||
# У «нет эха» x = y = z = r = 0, и отклонение тоже ноль.
|
||||
dev = np.abs(z - r * self._sin_el[:, None, None])
|
||||
if np.any(dev > r * np.float32(ORDER_TOL_DEG * DEG) + np.float32(1e-3)):
|
||||
return None
|
||||
|
||||
if e == 1:
|
||||
r_near = r[..., 0]
|
||||
|
|
@ -202,6 +254,69 @@ class ScanLayout:
|
|||
np.ascontiguousarray(r_far), np.ascontiguousarray(it),
|
||||
np.ascontiguousarray(valid))
|
||||
|
||||
def _project_geometric(self, pc: PointCloud2, start: int, stop: int) -> RangeImage:
|
||||
"""Раскладка по углам: кольцо — по элевации точки, столбец — по азимуту.
|
||||
|
||||
Не опирается на порядок точек вовсе. Если в ячейку попало несколько
|
||||
точек (двойное эхо, вставленный предмет поверх фона), ближняя идёт в
|
||||
`r_near`, дальняя — в `r_far`, как у целого кадра.
|
||||
"""
|
||||
n, wid = self.n_rings, stop - start
|
||||
pts = pc.points
|
||||
x = np.asarray(pts["x"], np.float32)
|
||||
y = np.asarray(pts["y"], np.float32)
|
||||
z = np.asarray(pts["z"], np.float32)
|
||||
good = ((x != 0) | (y != 0) | (z != 0)) & np.isfinite(x) & np.isfinite(y) \
|
||||
& np.isfinite(z)
|
||||
idx = np.flatnonzero(good)
|
||||
x, y, z = x[idx], y[idx], z[idx]
|
||||
|
||||
# грубый отбор сектора по азимуту, без поправки кольца
|
||||
step = self.az_step_deg
|
||||
az = np.degrees(np.arctan2(x, -y))
|
||||
jf = (az - np.float32(self.az0_deg)) / np.float32(step)
|
||||
if self.wrap:
|
||||
jf = np.mod(jf, self.n_az)
|
||||
margin = float(np.abs(self.az_resid_deg).max()) / abs(step) + 1.0
|
||||
sel = np.flatnonzero((jf > start - margin) & (jf < stop - 1 + margin))
|
||||
idx, x, y, z, az = idx[sel], x[sel], y[sel], z[sel], az[sel]
|
||||
|
||||
r = np.sqrt(x * x + y * y + z * z)
|
||||
el = np.degrees(np.arcsin(np.clip(z / np.maximum(r, np.float32(1e-6)), -1.0, 1.0)))
|
||||
# ближайшее кольцо по элевации
|
||||
k = np.clip(np.searchsorted(self._el_asc, el), 1, n - 1)
|
||||
k -= (el - self._el_asc[k - 1]) < (self._el_asc[k] - el)
|
||||
h = self._el_order[k]
|
||||
ok = np.abs(el - self.el_deg[h]) <= np.maximum(self.el_step_deg[h], 0.2)
|
||||
j = np.rint((az - self.az_resid_deg[h] - self.az0_deg) / step).astype(np.int64)
|
||||
if self.wrap:
|
||||
j %= self.n_az
|
||||
ok &= (j >= start) & (j < stop)
|
||||
sel = np.flatnonzero(ok)
|
||||
|
||||
r_near = np.zeros(n * wid, np.float32)
|
||||
r_far = np.zeros(n * wid, np.float32)
|
||||
it = np.zeros(n * wid, np.float32)
|
||||
valid = np.zeros(n * wid, bool)
|
||||
if sel.size:
|
||||
cell = h[sel] * wid + (j[sel] - start)
|
||||
rr = r[sel]
|
||||
# сортировка по (ячейка, дальность): первая в ячейке — ближняя
|
||||
mm = np.minimum(rr * 1000.0, (1 << 20) - 1).astype(np.int64)
|
||||
order = np.argsort(cell * (1 << 20) + mm)
|
||||
cs, rs = cell[order], rr[order]
|
||||
brk = np.flatnonzero(cs[1:] != cs[:-1])
|
||||
first = np.concatenate(([0], brk + 1))
|
||||
last = np.concatenate((brk, [cs.size - 1]))
|
||||
r_near[cs[first]] = rs[first]
|
||||
r_far[cs[last]] = rs[last]
|
||||
valid[cs[first]] = True
|
||||
if "intensity" in pts.dtype.names:
|
||||
src = np.asarray(pts["intensity"], np.float32)[idx[sel]][order]
|
||||
it[cs[first]] = src[first]
|
||||
return RangeImage(pc.stamp, r_near.reshape(n, wid), r_far.reshape(n, wid),
|
||||
it.reshape(n, wid), valid.reshape(n, wid))
|
||||
|
||||
# ------------------------------------------------------------------ калибровка
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -214,6 +329,18 @@ class ScanLayout:
|
|||
if not clouds:
|
||||
raise ValueError("нужен хотя бы один кадр для калибровки")
|
||||
pc0 = clouds[0]
|
||||
if "ring" not in pc0.points.dtype.names:
|
||||
# Без поля `ring` (синтетика организаторов): кольца — по гистограмме
|
||||
# элевации, а в калибровку по порядку точек идут только кадры, где
|
||||
# этот порядок цел. Нет таких — решётка строится целиком по углам.
|
||||
el_ring = _ring_elevations(clouds)
|
||||
if n_rings is None:
|
||||
n_rings = el_ring.size
|
||||
whole = [pc for pc in clouds if _is_organized(pc, el_ring)]
|
||||
whole = [pc for pc in whole if pc.n_points == whole[0].n_points] if whole else []
|
||||
if not whole:
|
||||
return ScanLayout._calibrate_geometric(clouds, el_ring)
|
||||
clouds, pc0 = whole, whole[0]
|
||||
if n_rings is None:
|
||||
n_rings = int(pc0.points["ring"].max()) + 1
|
||||
n_cols, rem = divmod(pc0.n_points, n_rings)
|
||||
|
|
@ -290,27 +417,87 @@ class ScanLayout:
|
|||
el_ring = _fill_linear(np.nanmedian(el, axis=1))
|
||||
return ScanLayout(el_ring, step, float(c0), shift, resid, n_az, n_echo, wrap)
|
||||
|
||||
@staticmethod
|
||||
def _calibrate_geometric(clouds: list[PointCloud2], el_ring: np.ndarray) -> "ScanLayout":
|
||||
"""Решётка только по углам точек, когда ни в одном кадре нет целого порядка.
|
||||
|
||||
Кольцо — ближайшая элевация из `el_ring`. Шаг развёртки — самая частая
|
||||
разность соседних азимутов внутри кольца, сдвиг кольца внутри шага —
|
||||
круговое среднее фазы его азимутов. Столбец растёт, азимут убывает — как
|
||||
у Pandar128 в наших бэгах, чтобы образ не отразился зеркально.
|
||||
"""
|
||||
n = el_ring.size
|
||||
el_desc = np.sort(el_ring)[::-1]
|
||||
order = np.argsort(el_desc)
|
||||
asc = el_desc[order]
|
||||
azs, hs = [], []
|
||||
for pc in clouds[:6]:
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
x, y, z = x[ok], y[ok], z[ok]
|
||||
el = np.degrees(np.arctan2(z, np.hypot(x, y)))
|
||||
k = np.clip(np.searchsorted(asc, el), 1, n - 1)
|
||||
k -= (el - asc[k - 1]) < (asc[k] - el)
|
||||
h = order[k]
|
||||
on = np.abs(el - el_desc[h]) < ORDER_TOL_DEG
|
||||
azs.append(np.degrees(np.arctan2(x[on], -y[on])))
|
||||
hs.append(h[on])
|
||||
az = np.concatenate(azs)
|
||||
h = np.concatenate(hs)
|
||||
|
||||
diffs = []
|
||||
for ring in range(n):
|
||||
a = np.unique(np.round(az[h == ring], 4))
|
||||
if a.size > 20:
|
||||
diffs.append(np.diff(a))
|
||||
if not diffs:
|
||||
raise ValueError("недостаточно валидных лучей для калибровки развёртки")
|
||||
d = np.concatenate(diffs)
|
||||
d = d[d > 1e-3]
|
||||
vals, cnt = np.unique(np.round(d, 3), return_counts=True)
|
||||
step = float(vals[np.argmax(cnt)])
|
||||
step = float(np.median(d[np.abs(d - step) < 0.1 * step]))
|
||||
|
||||
ph = np.exp(2j * np.pi * az / step)
|
||||
s = (np.bincount(h, weights=ph.real, minlength=n)
|
||||
+ 1j * np.bincount(h, weights=ph.imag, minlength=n))
|
||||
ref = float(np.angle(s.sum()) / (2 * np.pi) * step)
|
||||
frac = np.where(np.abs(s) > 0, np.angle(s) / (2 * np.pi) * step, ref)
|
||||
resid = (frac - ref + step / 2) % step - step / 2
|
||||
|
||||
lo, hi = np.percentile(az, [0.05, 99.95])
|
||||
wrap = bool(hi - lo > 350.0)
|
||||
if wrap:
|
||||
n_az = int(round(360.0 / step))
|
||||
az0 = ref + step * round((180.0 - ref) / step)
|
||||
else:
|
||||
az0 = ref + step * round((hi - ref) / step)
|
||||
n_az = int(round((az0 - lo) / step)) + 1
|
||||
return ScanLayout(el_desc, -step, float(az0), np.zeros(n, np.int64), resid,
|
||||
n_az, 1, wrap, indexed=False)
|
||||
|
||||
# ------------------------------------------------------------------ сериализация
|
||||
|
||||
def save(self, path: str | Path) -> None:
|
||||
np.savez_compressed(path, el_deg=self.el_deg, az_step_deg=self.az_step_deg,
|
||||
az0_deg=self.az0_deg, col_shift=self.col_shift,
|
||||
az_resid_deg=self.az_resid_deg, n_az=self.n_az,
|
||||
n_echo=self.n_echo, wrap=self.wrap)
|
||||
n_echo=self.n_echo, wrap=self.wrap, indexed=self.indexed)
|
||||
|
||||
@staticmethod
|
||||
def load(path: str | Path) -> "ScanLayout":
|
||||
d = np.load(path)
|
||||
return ScanLayout(d["el_deg"], float(d["az_step_deg"]), float(d["az0_deg"]),
|
||||
d["col_shift"], d["az_resid_deg"], int(d["n_az"]),
|
||||
int(d["n_echo"]), bool(d["wrap"]) if "wrap" in d else False)
|
||||
int(d["n_echo"]), bool(d["wrap"]) if "wrap" in d else False,
|
||||
bool(d["indexed"]) if "indexed" in d else True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
kind = "" if self.indexed else ", по углам точек"
|
||||
return (f"ScanLayout(колец={self.n_rings}, азимутов={self.n_az}, эх={self.n_echo}, "
|
||||
f"сектор {self.az_grid_deg.min():.1f}°…{self.az_grid_deg.max():.1f}°, "
|
||||
f"шаг {abs(self.az_step_deg):.3f}°, "
|
||||
f"элевация {self.el_deg.min():.1f}°…{self.el_deg.max():.1f}°, "
|
||||
f"скос каналов {np.ptp(self.col_shift)} стлб)")
|
||||
f"скос каналов {np.ptp(self.col_shift)} стлб{kind})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- вспомогательное
|
||||
|
|
@ -328,6 +515,58 @@ def _wrap180(a):
|
|||
return -((-np.asarray(a, np.float64) + 180.0) % 360.0 - 180.0)
|
||||
|
||||
|
||||
def _xyz64(pc: PointCloud2):
|
||||
"""Координаты в float64 и маска точек с эхом (не ноль и не NaN)."""
|
||||
p = pc.points
|
||||
x = p["x"].astype(np.float64)
|
||||
y = p["y"].astype(np.float64)
|
||||
z = p["z"].astype(np.float64)
|
||||
ok = ((x != 0) | (y != 0) | (z != 0)) & np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
|
||||
return x, y, z, ok
|
||||
|
||||
|
||||
def _ring_elevations(clouds: list[PointCloud2], max_frames: int = 6) -> np.ndarray:
|
||||
"""Элевации колец по гистограмме, без поля `ring` и без опоры на порядок.
|
||||
|
||||
У лазерного канала элевация постоянна до 0.0001°, поэтому точки кольца
|
||||
ложатся в один бин в 0.002°; соседние кольца Pandar128 разнесены на 0.086°
|
||||
и больше. Разброс float32 может расщепить кольцо на соседние бины — они
|
||||
сливаются. Вставленные точки (синтетика) рассыпаны по элевации и дают
|
||||
мелкие группы, которые отсекает порог по весу. Порядок — сверху вниз,
|
||||
как нумерует каналы Hesai.
|
||||
"""
|
||||
parts = []
|
||||
for pc in clouds[:max_frames]:
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
parts.append(np.degrees(np.arctan2(z[ok], np.hypot(x[ok], y[ok]))))
|
||||
el = np.concatenate(parts)
|
||||
if el.size == 0:
|
||||
raise ValueError("нет ни одной точки с эхом для калибровки колец")
|
||||
q, cnt = np.unique(np.rint(el / 0.002).astype(np.int64), return_counts=True)
|
||||
grp = np.concatenate(([0], np.cumsum(np.diff(q) > 5)))
|
||||
w = np.bincount(grp, weights=cnt)
|
||||
c = np.bincount(grp, weights=cnt * q * 0.002) / w
|
||||
top = np.sort(w)[-min(64, w.size):]
|
||||
keep = w >= 0.25 * np.median(top)
|
||||
if keep.sum() < 2:
|
||||
raise ValueError("не удалось выделить кольца по элевации")
|
||||
return np.sort(c[keep])[::-1]
|
||||
|
||||
|
||||
def _is_organized(pc: PointCloud2, el_ring: np.ndarray) -> bool:
|
||||
"""Цел ли порядок точек: элевация каждой точки = элевация её кольца."""
|
||||
n = el_ring.size
|
||||
if pc.n_points == 0 or pc.n_points % n:
|
||||
return False
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
el = np.degrees(np.arctan2(z, np.hypot(x, y))).reshape(-1, n)
|
||||
ok = ok.reshape(-1, n)
|
||||
for table in (el_ring, el_ring[::-1]):
|
||||
if np.all(np.abs(el - table[None, :])[ok] < ORDER_TOL_DEG):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_echoes(az: np.ndarray) -> int:
|
||||
"""Двойное эхо: соседние столбцы делят азимут (проверяется по каждому кольцу)."""
|
||||
if az.shape[1] < 4:
|
||||
|
|
|
|||
187
tools/eval_org_synth.py
Normal file
187
tools/eval_org_synth.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Оценка на синтетике организаторов: какие из вставленных предметов найдены.
|
||||
|
||||
Разметки к `cloud_with_fake_obj` нет, но она и не нужна: вставленные точки не
|
||||
лежат на элевациях колец (отклонение больше 0.002°, у настоящих — до 0.0001°).
|
||||
Кластеры таких точек, связанные от кадра к кадру по дальности, и есть эталон:
|
||||
предметы нумеруются в порядке появления, то есть в порядке из объявления
|
||||
организаторов. Положение вдоль пути для связывания не годится — на остановке
|
||||
одометрия замирает и соседние предметы сливаются. Методика — EXPERIMENTS п. 18.2.
|
||||
|
||||
python tools/eval_org_synth.py
|
||||
python tools/eval_org_synth.py --set half_width=1.6 --set h_top=0
|
||||
python tools/eval_org_synth.py --no-memory --out run.json
|
||||
|
||||
Эталон кэшируется в `data/cache/<бэг>_truth.json`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
import _bootstrap as B
|
||||
import _params as PS
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.mbon_readout import MbonReadout
|
||||
from flyguard.mushroom_body import MushroomBody
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
from flyguard.retina import _ring_elevations, _xyz64
|
||||
|
||||
# объявление организаторов, 24.09; False — предмет вне габарита, тревоги быть не должно
|
||||
OBJECTS = [("2×2 посередине", True), ("0.3 посередине", True), ("0.3 на рельсе", True),
|
||||
("0.3 у края", True), ("0.3 вне габарита", False), ("2×2 у края внутри", True),
|
||||
("2×2 вне габарита", False), ("2×2 сверху", True), ("2×0.2 на рельсах", True),
|
||||
("стержень с потолка", True)]
|
||||
OFF_RING_DEG = 0.002
|
||||
MATCH_D = 4.0
|
||||
|
||||
|
||||
def truth(bag_path: Path) -> list[dict]:
|
||||
"""Кластеры вставленных точек по кадрам."""
|
||||
ref = [pc for _, pc in Bag(bag_path).frames(start=1100, stop=1106)]
|
||||
asc = np.sort(_ring_elevations(ref))
|
||||
rows, t0 = [], None
|
||||
for k, (_, pc) in enumerate(Bag(bag_path).frames()):
|
||||
t0 = pc.stamp if t0 is None else t0
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
x, y, z = x[ok], y[ok], z[ok]
|
||||
el = np.degrees(np.arctan2(z, np.hypot(x, y)))
|
||||
i = np.clip(np.searchsorted(asc, el), 1, asc.size - 1)
|
||||
off = np.minimum(np.abs(el - asc[i - 1]), np.abs(asc[i] - el)) > OFF_RING_DEG
|
||||
objs = []
|
||||
if off.any():
|
||||
fx, fd, fz = x[off], -y[off], z[off]
|
||||
o = np.argsort(fd)
|
||||
fx, fd, fz = fx[o], fd[o], fz[o]
|
||||
cut = np.flatnonzero(np.diff(fd) > 3.0) + 1
|
||||
for a, b in zip(np.r_[0, cut], np.r_[cut, fd.size]):
|
||||
objs.append(dict(d=float(np.median(fd[a:b])), x0=float(fx[a:b].min()),
|
||||
x1=float(fx[a:b].max()), z0=float(fz[a:b].min()),
|
||||
z1=float(fz[a:b].max()), n=int(b - a)))
|
||||
rows.append(dict(k=k, t=pc.stamp - t0, objs=objs))
|
||||
return rows
|
||||
|
||||
|
||||
def link(rows: list[dict]) -> list[dict]:
|
||||
"""Кластеры → предметы: связь с треком из последних кадров по дальности."""
|
||||
tracks = []
|
||||
for r in rows:
|
||||
for o in r["objs"]:
|
||||
if o["n"] < 3 or o["d"] > 130:
|
||||
continue
|
||||
best = None
|
||||
for t in tracks:
|
||||
if 0 < r["k"] - t["last_k"] <= 15 and -3.0 < t["last_d"] - o["d"] < 25.0:
|
||||
if best is None or abs(t["last_d"] - o["d"]) < abs(best["last_d"] - o["d"]):
|
||||
best = t
|
||||
if best is None:
|
||||
best = {"frames": {}}
|
||||
tracks.append(best)
|
||||
best["frames"][r["k"]] = o
|
||||
best["last_k"], best["last_d"] = r["k"], o["d"]
|
||||
tracks = [t for t in tracks if len(t["frames"]) >= 8]
|
||||
tracks.sort(key=lambda t: min(t["frames"]))
|
||||
return tracks
|
||||
|
||||
|
||||
def run(bag_path: Path, params: Params, memory, readout) -> list[dict]:
|
||||
fg = FlyGuard(params, memory=memory, readout=readout)
|
||||
out, t0 = [], None
|
||||
for k, (_, pc) in enumerate(Bag(bag_path).frames()):
|
||||
t0 = pc.stamp if t0 is None else t0
|
||||
res = fg.process(pc)
|
||||
if res is None:
|
||||
continue
|
||||
out.append(dict(k=k, t=pc.stamp - t0, ms=res.total_ms,
|
||||
objs=[dict(d=o.distance, u=o.lateral, h=o.height, w=o.width,
|
||||
sv=o.size_v, p=o.confidence, tid=o.track_id)
|
||||
for o in res.decision.objects]))
|
||||
return out
|
||||
|
||||
|
||||
def score(tracks: list[dict], det: list[dict]) -> tuple[int, list, list]:
|
||||
by_k = {r["k"]: r for r in det}
|
||||
hits = {i: [] for i in range(len(tracks))}
|
||||
used, tid_obj = set(), {}
|
||||
for i, t in enumerate(tracks):
|
||||
for k, o in t["frames"].items():
|
||||
for j, ob in enumerate(by_k.get(k, {}).get("objs", [])):
|
||||
if abs(ob["d"] - o["d"]) < MATCH_D:
|
||||
hits[i].append((k, by_k[k]["t"], ob))
|
||||
used.add((k, j))
|
||||
tid_obj.setdefault(ob["tid"], i)
|
||||
false = []
|
||||
for r in det:
|
||||
for j, ob in enumerate(r["objs"]):
|
||||
if (r["k"], j) in used:
|
||||
continue
|
||||
if ob["tid"] in tid_obj: # тот же трек вплотную, где эталон уже кончился
|
||||
hits[tid_obj[ob["tid"]]].append((r["k"], r["t"], ob))
|
||||
else:
|
||||
false.append((r["k"], r["t"], ob))
|
||||
lines, ok = [], 0
|
||||
for i, t in enumerate(tracks):
|
||||
name, obst = OBJECTS[i] if i < len(OBJECTS) else ("?", True)
|
||||
hs = hits[i]
|
||||
found = bool(hs)
|
||||
ok += found == obst
|
||||
verdict = ("найден" if found else "ПРОПУЩЕН") if obst else \
|
||||
("ЛОЖНАЯ тревога" if found else "верно молчит")
|
||||
far = max(o["d"] for o in t["frames"].values())
|
||||
first = max((h[2]["d"] for h in hs), default=float("nan"))
|
||||
pmax = max((h[2]["p"] for h in hs), default=0.0)
|
||||
lines.append(f"{i + 1:>2} {name:20s} виден с {far:5.0f} м | кадров {len(hs):4d}, "
|
||||
f"первое на {first:6.1f} м, p ≤ {pmax:.2f} | {verdict}")
|
||||
return ok, lines, false
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--bag", default=str(B.DATA / "cloud_with_fake_obj"))
|
||||
ap.add_argument("--memory", default=str(B.ARTIFACTS / "mushroom_body.npz"))
|
||||
ap.add_argument("--mbon", default=str(B.ARTIFACTS / "mbon_readout.npz"))
|
||||
ap.add_argument("--no-memory", action="store_true")
|
||||
ap.add_argument("--out", default="", help="сохранить покадровые решения (json)")
|
||||
PS.add_argument(ap)
|
||||
args = ap.parse_args()
|
||||
|
||||
bag = Path(args.bag)
|
||||
cache = B.CACHE / f"{bag.name}_truth.json"
|
||||
if cache.exists():
|
||||
rows = json.loads(cache.read_text(encoding="utf-8"))
|
||||
else:
|
||||
t = time.time()
|
||||
rows = truth(bag)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(rows), encoding="utf-8")
|
||||
print(f"эталон собран за {time.time() - t:.0f} с → {cache}")
|
||||
tracks = link(rows)
|
||||
|
||||
params = Params(**PS.apply({}, args.set, Params))
|
||||
memory = None if args.no_memory else MushroomBody.load(args.memory)
|
||||
readout = MbonReadout.load(args.mbon) if args.mbon else None
|
||||
det = run(bag, params, memory, readout)
|
||||
if args.out:
|
||||
Path(args.out).write_text(json.dumps(det, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
ok, lines, false = score(tracks, det)
|
||||
print("\n".join(lines))
|
||||
print(f"верно по {ok} из {len(tracks)}")
|
||||
ftr = {}
|
||||
for k, t, ob in false:
|
||||
ftr.setdefault(ob["tid"], []).append((t, ob))
|
||||
print(f"ложных треков вне эталона: {len(ftr)} (кадров {len(false)})")
|
||||
for lst in sorted(ftr.values(), key=lambda v: v[0][0]):
|
||||
ds = [ob["d"] for _, ob in lst]
|
||||
ob = lst[len(lst) // 2][1]
|
||||
print(f" {lst[0][0]:5.1f}…{lst[-1][0]:5.1f} с, {max(ds):.0f}→{min(ds):.0f} м, "
|
||||
f"u {ob['u']:+.2f} h {ob['h']:.2f}, p ≤ {max(o['p'] for _, o in lst):.2f}")
|
||||
ms = np.array([r["ms"] for r in det])
|
||||
print(f"кадр {np.median(ms):.1f} / {np.percentile(ms, 95):.1f} мс (медиана / p95)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue