синтетика организаторов: облака без ring, габарит 1.2 м и верх до 3.7 м, сброс при скачке времени

This commit is contained in:
Данил Омелечко 2026-09-25 01:24:30 +03:00
parent 2051da2399
commit d1da4ec117
9 changed files with 838 additions and 38 deletions

View file

@ -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

View file

@ -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,

View file

@ -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`.

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -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
View 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()