Lidar_Muxa/docs/EXPERIMENTS.md

2423 lines
185 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Эксперименты
Все числа в документе получены скриптами из `tools/` на предоставленных данных и
воспроизводятся командами, указанными в каждом разделе. Там, где результат оказался
хуже ожидаемого, он приведён как есть.
Машина разработки: Ryzen 5 7600X, 32 ГБ, RTX 5070 Ti. Стенд жюри слабее по CPU
(i7-9700E, 8 ядер, 2.6 ГГц), поэтому замеры задержки приведены с запасом и обсуждаются
отдельно в разделе 7.
---
## 1. Что на самом деле в данных
```bash
python tools/inspect_bags.py --root data/for_hackathon
```
| Бэг | Кадров | Топик | Точек в кадре | Валидных лучей |
|---|---|---|---|---|
| `doubleT_obstacle` | 201 | `/sensing/lidar/hesai128/pointcloud` | 921 600 | 37.6 % |
| `doubleT_platform` | 345 | `/lidar_points` | 307 200 | 60.5 % |
| `roundT_doubleT` | 252 | `/lidar_points` | 307 200 | 61.7 % |
| `roundT_pressureGate_roundT` | 268 | `/lidar_points` | 307 200 | 60.6 % |
| `roundT_squareT_pressureGate_squareT` | 545 | `/lidar_points` | 307 200 | 61.6 % |
| `squareT_platform_squareT_switch` | 877 | `/lidar_points` | 307 200 | 59.4 % |
| `new_data` (221 шард) | 11 271 | `/lidar_points` | 307 200 | — |
Три вещи, о которых README датасета молчит и которые ломают наивную обработку:
1. **Архивы — несжатый tar**, а не `.zst`.
2. **Раскладка скана различается**: 3600 азимутов на 360° против 1200 на 120°.
Ничего нельзя захардкодить.
3. **У каналов постоянный азимутальный сдвиг до 15.6°** (±78 столбцов при шаге 0.1°).
Без выпрямления «столбец» не является направлением, и все пространственные фильтры
считают мусор. Руководство Pandar128E3X это подтверждает прямо: «Each laser channel
has an intrinsic azimuth offset».
Различаются и условия съёмки: высота установки сенсора над головкой рельса составляет
1.31–1.33 м в пяти бэгах и 1.70 м в `doubleT_obstacle`. Решение калибруется по данным
и работает с обоими без правок.
---
## 2. Проверка калибровки по паспорту
```bash
python tools/extract_channel_table.py --pdf <руководство> --out .../pandar128_channels.csv
python tools/validate_calibration.py
```
Решётка лучей восстанавливается **из самих облаков точек**, паспортные углы нигде не
используются. Поэтому таблица каналов из Приложения A руководства служит независимой
проверкой:
| Величина | Измерено по данным | Паспорт | Расхождение |
|---|---|---|---|
| Разброс азимутального сдвига каналов | ±7.80° (размах 15.60°) | −7.811°…+7.804° (15.615°) | 0.015° |
| Диапазон углов места | −25.1°…+14.4° | −25.016°…+14.436° | < 0.1° |
| Угол места, поканально | — | — | медиана 0.065°, макс 0.123° |
| Азимутальный сдвиг, поканально | — | — | медиана 0.044°, макс 0.152° |
Расхождение одинаково на всех шести бэгах. Остаток объясняется тем, что в руководстве
приведены **проектные** значения, а каждый экземпляр прибора поставляется с собственным
файлом угловой коррекции — именно её и восстанавливает калибровка по данным.
Остаточная угловая ошибка после выпрямления образа: **p50 = 0.0000°, p99 = 0.028°,
макс 0.040°** — меньше половины шага решётки (0.05°).
---
## 3. Сколько тоннель вообще позволяет увидеть
```bash
python tools/analyze_corridor.py --frames 50
```
| Бэг | Радиус кривой | Прямая видимость (p99.9) |
|---|---|---|
| `doubleT_obstacle` | 1690 м | 167 м |
| `doubleT_platform` | 7310 м | 143 м |
| `roundT_doubleT` | 1310 м | 126 м |
| `roundT_pressureGate_roundT` | 2970 м | 121 м |
| `roundT_squareT_pressureGate_squareT` | 8660 м | 127 м |
| `squareT_platform_squareT_switch` | 2300 м | 132 м |
Максимальное эхо во всём датасете — **208.8 м**, что совпадает с паспортными
0.3…200 м. Но **реальная прямая видимость 121–167 м**: тоннели кривые, и линия взгляда
упирается в стену раньше, чем кончается дальнобойность прибора.
Это измерение, а не оправдание: заявленные в ТЗ «300 м → отлично» на предоставленных
участках недостижимы **никаким** алгоритмом, потому что от объекта за поворотом
до лидара не доходит ни одного фотона. Проверяется прямо: при вставке синтетического
предмета на 84 м в кадр, где фон в том же направлении стоит на 76 м, ни один луч
до предмета не доходит — он физически закрыт стеной.
---
## 4. Реальное препятствие
Единственный бэг с настоящим посторонним объектом — `doubleT_obstacle`. Поезд стоит,
объект **0.67 × 1.35 м на 54.7…56.9 м**, 47–69 лучей, медленно смещается поперёк пути
(с +1.16 м до −1.98 м и обратно за 20 с, примерно 0.2 м/с).
```bash
python tools/check_obstacle.py --memory artifacts/mushroom_body.npz
```
| Метрика | Значение |
|---|---|
| Попал в кандидаты | 100 % кадров |
| Подтверждён треком | 98.9 % кадров |
| Новизна (ответ MBON) | 0.62 при фоне 0.13 |
---
## 5. Обобщаемость: leave-one-bag-out
```bash
python tools/evaluate.py --device cuda
```
Память тоннеля обучается на всех данных **кроме проверяемого бэга** — иначе цифры лгут:
подавлять конструкции, которые сам же и запомнил, умеет кто угодно, а на приватном тесте
будет новый участок.
| Бэг | Путь | Кадров с тревогой | Разных ложных треков | На километр |
|---|---|---|---|---|
| `doubleT_platform` | 201 м | 18.8 % | 2 | 10.0 |
| `roundT_doubleT` | 201 м | 1.3 % | 1 | 5.0 |
| `roundT_pressureGate_roundT` | 247 м | 0.0 % | 0 | 0.0 |
| `roundT_squareT_pressureGate_squareT` | 274 м | 13.4 % | 1 | 3.7 |
| `squareT_platform_squareT_switch` | 271 м | 13.4 % | 2 | 7.4 |
| **Итого** | **1193 м** | **9.4 %** | **6** | **5.2** (медиана 5.0) |
Реальный объект в `doubleT_obstacle` при этом обнаруживается в **98.9 %** кадров.
Две метрики отличаются принципиально. «Кадров с тревогой» завышает картину: одна и та же
конструкция, попавшая в треки, видна сотню кадров подряд. Для эксплуатации важно другое —
сколько **разных** ложных объектов возникло, потому что именно столько раз поезд
затормозил бы напрасно.
### 5.1. Что дал разбор худшего бэга
`roundT_doubleT` давал 39.8 ложных трека на километр — втрое хуже любого другого. Разбор
(`tools/diagnose_fp.py`) показал, что пять из восьми треков — один и тот же тип объекта:
полоса шириной 0.6 м, ростом ровно с габарит, тёмная (интенсивность 9 из 255), с разрывом
дальности до фона 18–25 м, повторяющаяся вдоль тоннеля каждые 13–20 м.
Снятие ограничений по высоте показало, что это **колонна, идущая от полотна до свода**:
кластер тянется от −0.33 м до 4.45 м, и в габарит 0.28…2.30 попадает лишь **15 %** его
лучей. Признак «наполненность» этого не видел: контекст кластеризации обрывался на
`h_hi + 1.2 = 3.5` м — ровно в талии между колонной и сводом, — поэтому срез конструкции
выглядел целым предметом.
Контекст поднят до `h_hi + 4.0 = 6.3` м (параметр `ctx_up`). Результат:
| | контекст до 3.5 м | контекст до 6.3 м |
|---|---|---|
| `roundT_doubleT`, кадров с тревогой | 39.7 % | **1.3 %** |
| `roundT_doubleT`, ложных треков | 8 | **1** |
| Всего ложных треков на км | 12.4 | **5.2** |
| Реальный объект на 55 м | 98.9 % | **98.9 %** |
Значения 2.5, 4.0 и 12.0 дают одинаковый результат: контекст просто дотягивается до свода
и дальше упирается в пустоту. Взято 4.0 — с запасом на более высокий тоннель.
---
## 6. Абляция: что именно работает
```bash
python tools/ablation.py --device cuda
```
Каждый вариант отличается от полного ровно одним отключённым механизмом; память во всех
вариантах обучена без проверяемого бэга.
| Вариант | Кадров с ложной тревогой | Разных ложных треков | Объект на 55 м |
|---|---|---|---|
| полная система | 10.7 % | 6 | 98.9 % |
| − память тоннеля | 24.1 % | 16 | 98.9 % |
| − ось пути (прямой коридор) | 4.4 % | 3 | 98.9 % |
| − признаки формы | 30.5 % | 21 | 98.9 % |
| − накопление улик | 37.4 % | **81** | 100.0 % |
Что из этого следует.
**Накопление улик в центральном комплексе — самый весомый механизм.** Без него число
разных ложных объектов растёт в 13.5 раза (6 → 81): каждое случайное пятно немедленно
становится «обнаружением». Это прямое подтверждение того, что подтверждение по
нескольким кадрам должно быть не эвристическим фильтром, а накопителем.
**Грибовидное тело снижает ложные тревоги вдвое** (24.1 % → 10.7 %) и при этом **никак
не влияет на обнаружение реального объекта** (98.9 % в обоих случаях). Именно этого
от памяти и ждали: она гасит знакомое, не трогая незнакомое.
**Признаки формы** (целостность, компактность вдоль пути, опора снизу) дают почти такой
же вклад, как память, — втрое меньше ложных треков (21 → 6).
**Ось пути — единственный механизм, который сейчас стоит дороже, чем даёт.** Её
отключение снижает ложные тревоги вдвое (10.7 % → 4.4 %, 6 → 3 трека) и не трогает
обнаружение реального объекта. Так вышло потому, что в двухпутном тоннеле центр свода
смещён относительно пути: ось оценивается со сдвигом, и кривой габарит заводит в зону
поиска куски стены. Держим её ради кривых участков, где без неё объект уезжает из
габарита, — но это осознанная плата, а не выигрыш.
Из этого сделан вывод и изменено решение: габарит теперь **объединение** прямого и
кривого коридоров, а не замена одного другим. Система безопасности не имеет права
сужать зону поиска по неуверенной оценке. Итог замены на объединение:
| | ось заменяет прямой коридор | ось **дополняет** прямой |
|---|---|---|
| Реальный объект на 55 м | 75.3 % | **98.9 %** |
| Кадров с ложной тревогой | 10.6 % | 17.5 % |
| Разных ложных треков на км | 7.5 | 12.4 |
Размен сознательный: +23.6 п.п. обнаружения за +6.9 п.п. ложных тревог. Пропустить
человека на пути существенно хуже, чем лишний раз затормозить.
Таблица выше пересчитана уже на объединённом коридоре, поэтому «полная система»
показывает 98.9 % обнаружения.
---
## 7. Скорость
```bash
python tools/run_pipeline.py --all --memory artifacts/mushroom_body.npz --verbose
```
Медиана по стадиям на кадре 128 × 600 (сектор ±30°), машина разработки:
| Стадия | мс |
|---|---|
| retina (оконная проекция) | 7.0 |
| ламина | 6.4 |
| оценка движения (LPTC) | 5.5 |
| ось пути | 4.7 |
| лобула (кандидаты) | 4.5 |
| стабилизация | 3.5 |
| грибовидное тело | 1.2 |
| центральный комплекс | 0.2 |
| решение | 0.02 |
| **итого** | **p50 ≈ 35, p95 ≈ 45** |
Бюджет по ТЗ — 100 мс на кадр. Запас примерно двукратный, что важно: стенд жюри по CPU
слабее машины разработки. Если запаса не хватит, первыми кандидатами на перенос в
numba являются ламина и оценка движения — вместе это 12 мс почти чистой арифметики.
Отдельно измерена оптимизация ретины: на круговом скане (921 600 точек) оконная
проекция сократила стадию с **29 до 7 мс**, причём результат совпадает с полной
проекцией **побитово** — проверено сравнением массивов.
## 7.3. Почему далёкий предмет теряется — и что нужно для 200 м
ТЗ просит 300 м как «отлично» и 200 м как «очень хорошо». Разберём честно, чего
не хватает, потому что причина не та, которая кажется.
**Фотоны есть.** Вставленный человек на оси пути, по замерам полигона:
| Полоса | Лучей на кадр | Есть эхо | Кадров в полосе | Накоплено лучей | Обнаружено сейчас |
|---|---|---|---|---|---|
| 160–190 м | 5 | 100 % | 17 | ~84 | **0 %** |
| 135–160 м | 7 | 100 % | 17 | ~120 | **0 %** |
| 110–135 м | 10 | 94 % | 18 | ~176 | **0 %** |
| 90–110 м | 16 | 92 % | 14 | ~227 | 25 % |
| 70–90 м | 26 | 87 % | 15 | ~400 | 58 % |
На 170 м предмет освещён в **каждом** кадре и за проход набирает под сотню попаданий
в одну и ту же точку мира. Информация есть — мы её выбрасываем, решая покадрово.
**Кандидат при этом формируется.** Покадровый разбор (`doubleT_platform`, человек
от 200 м) показывает кандидата в большинстве кадров на 140–185 м: 4–9 лучей,
наполненность до 1.00, размер 0.4 × 1.5 м — верный. Но улика трека остаётся 0.00,
и виноват один множитель: **`gap` = 0.0 во всех кадрах без исключения**.
**Почему.** Кольцо окружения ламины берётся ±6 столбцов, то есть ±0.6°. На 170 м этот
угол отвечает боковому смещению 1.8 м, а стена тоннеля на таком смещении находится
на 172 м — там же, где предмет. Центр-окружение перестаёт работать, когда собственный
градиент тоннеля по глубине сравним с шагом от предмета: предмет не «ближе окружения»,
он «на той же дальности, что окружение». В `_quality` это даёт
`contrast = clip(0/3, 0.2, 1) = 0.2`, и улика не набирается ни за 17 кадров, ни за сто.
Это не настройка порога. Локальный контраст на больших дальностях в тоннеле физически
не несёт сигнала, и никакая подстройка ламины этого не изменит.
### Что сделано: накопление в координатах пути
Предмет неподвижен в мире, а тоннель проплывает мимо. Собственное движение мы уже
оцениваем, поэтому лучи из габарита складываются не в кадре, а в сетке, привязанной
к пройденному пути (`fan_body.py`, шаг 2 м вдоль пути × 0.2 м поперёк × 0.25 м по
высоте, забывание с полураспадом 23 кадра). Так устроено веерное тело центрального
комплекса мухи: оно копит вектор к цели в координатах мира, а не текущего кадра.
**Первая версия порождала собственных кандидатов — и это оказалось тупиком.** Ложных
треков стало 39 на километр вместо 5.2. Разбор показал, почему: по геометрии
накопленное скопление предмета и накопленный кусок конструкции тоннеля **неразличимы**.
Медианы (предмет / ложные), 145 против 761 скопления:
| признак | дальность | \|u\| | высота | h_min | ширина | протяжённость | опора | кадров |
|---|---|---|---|---|---|---|---|---|
| предмет | 88 | 1.16 | 1.28 | 0.28 | 0.80 | 4.0 | 1.31 | 22.1 |
| ложные | 100 | 1.10 | 1.27 | 0.28 | 0.80 | 4.0 | 1.44 | 18.5 |
Совпадает всё. Разделяет их только память тоннеля, а ей нужны признаки кадра —
контраст, интенсивность, тень, — которых у скопления нет по построению.
**Рабочая версия.** Накопитель не порождает кандидатов вовсе. Он отвечает на один
вопрос про **уже найденного покадрового кандидата** — возвращались ли лучи из этой
точки мира кадр за кадром — и эта опора подставляется в вес улики вместо недоступного
контраста (`contrast = max(по gap, по накоплению)`). Кандидат при этом остаётся под
судом грибовидного тела со всеми своими признаками, и штатные конструкции по-прежнему
подавляются.
Измеренный результат на вставленном человеке (доля кадров с обнаружением):
| Бэг | 55–75 м | 75–100 | 100–130 | 130–170 |
|---|---|---|---|---|
| `roundT_squareT_pressureGate_squareT` | 1.00 | 0.38 → **1.00** | 0.00 → **1.00** | 0.00 → **0.55** |
| `squareT_platform_squareT_switch` | 1.00 | 1.00 | 0.33 → **1.00** | 0.00 → 0.14 |
| `doubleT_platform` | 1.00 | 0.82 → **0.95** | 0.00 → 0.09 | 0.00 |
| `roundT_doubleT` | 0.00 | 0.00 | 0.00 | 0.00 |
| `roundT_pressureGate_roundT` | 0.00 | 0.00 | 0.00 | 0.00 |
Рабочая дальность там, где кандидат вообще формируется, **выросла вдвое**: с 75–100
до 130–170 м. Два круглых тоннеля накопление не спасает — там предмет слипается со
стеной в одну связную компоненту, кандидата нет, и поддерживать нечего (см. п. 9.3).
На полном полигоне (90 сценариев, 14 004 наблюдения):
| | без накопления | с накоплением |
|---|---|---|
| Рабочая дальность, человек стоя | 62 м | **100 м** |
| P@100 м, человек стоя | 0.24 | **0.57** |
| P@150 м, человек стоя | 0.00 | **0.10** |
| P@100 м, человек сидя | 0.19 | **0.33** |
| Ложных треков на км (leave-one-bag-out) | **5.2** | 9.1 |
| Кадров с тревогой | **9.4 %** | 17.2 % |
| Посторонних тревог на кадр (полигон) | **0.055** | 0.171 |
| Реальный объект на 55 м | 98.9 % | 98.9 % |
| Задержка, медиана | 32 мс | 33 мс |
**Включено по умолчанию.** Размен здесь принципиально лучше, чем у разделения фигуры
и фона (п. 9.4): там было вчетверо больше ложных за +29 % дальности, здесь — в 1.75
раза больше за +61 % рабочей дальности и рост обнаружения на 100 м в 2.4 раза. ТЗ
прямо оценивает дальность (100 м → «хорошо»), а «важно найти баланс между дальностью,
надёжностью и количеством ложных тревог» — этот баланс мы и выбираем осознанно.
Выключается одним параметром: `enable_accumulator: false` возвращает 5.2 ложных
трека на километр при рабочей дальности 62 м.
### Что ещё нужно
**Геометрическая карта линии.** Метро — неизменная среда: за несколько проездов
строится ожидаемый дальностный образ, привязанный к положению вдоль линии. Тогда
«препятствие» = «луч вернулся ближе, чем говорит карта», и это единственный способ
получить **и** дальность, **и** околонулевые ложные тревоги: всё постоянное в карте,
всё остальное — предмет. Грибовидное тело делает то же самое в пространстве
признаков; карта делает это в пространстве геометрии, где на 170 м ещё есть сигнал.
### Чего не будет никогда
* **На предоставленных участках 200 м недостижимы геометрически**: прямая видимость
121–167 м, дальше линия взгляда упирается в стену кривой. Это не свойство алгоритма.
* **Мелкие предметы на 200 м невозможны с этим сенсором**: ведро (0.1 м²) на 160–190 м
даёт 1 луч при видимости 2 %, каска и бутылка — ноль. Накопление не поможет там,
где фотонов нет.
* Реалистичная планка для предмета размером с человека на прямом участке — **около
200 м**, и путь к ней измерен выше: накопление плюс карта.
### 7.4. Стоит ли переносить вывод на GPU
На машине проверки RTX 4070 Ti Super, драйвер 580.173.02 (CUDA до 13.0), toolkit 12.9.
Рассогласование toolkit колёсам PyTorch не мешает — они несут свой CUDA runtime, — так
что технически перенос возможен. Вопрос в том, что он даёт. Время кадра по стадиям, один
процесс, свободная машина, 390 кадров трёх записей:
| стадия | мс | доля |
|---|---:|---:|
| lobula — кластеризация с учётом глубины | 15.3 | 35 % |
| ego — собственное движение | 6.4 | 15 % |
| lamina — центр-окружение на трёх масштабах | 6.0 | 14 % |
| retina — проекция облака на решётку | 5.3 | 12 % |
| corridor — ось пути | 4.5 | 10 % |
| stabilize | 4.0 | 9 % |
| память тоннеля, веерное тело, считывание MBON, треки, решение | 1.9 | 4 % |
| **всего, медиана / p95** | **43.1 / 49.0** | из 100 |
Под видеокарту годится только ламина — это фильтры по сетке 128 × 600. Остальные 80 %
времени — связные компоненты и подгонка геометрии к сотням точек: нерегулярная работа с
ветвлениями, которую GPU не ускоряет, а копирование туда-обратно только замедлит.
Ламину на CUDA проверили на 49 реальных кадрах (реализация сокомандника,
`F.avg_pool2d` вместо `uniform_filter`): 5.8 мс на процессоре, **2.9 мс** на RTX 5070 Ti
с копированием образа на карту и результата обратно. Совпадение не побитовое: ON-канал
расходится на 1·10⁻⁵, а в 60 пикселях из 3.76 млн (0.0016 %) выбирается другой масштаб —
при равных откликах решает порядок суммирования.
Итог: перенос сэкономил бы около 3 мс из 43 при бюджете 100, а взамен потребовал бы
другого контейнера (наш собран на `ros:humble-ros-base-jammy`, без CUDA), установленного
у жюри nvidia-container-toolkit и запуска с `--gpus all`. Отказ любого из трёх звеньев —
это не медленный кадр, а не запустившееся решение. **Вывод остаётся на CPU**, видеокарта
— для обучения.
---
## 8. Что не сработало
Раздел намеренно подробный: ТЗ п. 8.7 просит именно этого.
**Поиск рельсов по интенсивности.** Идея была привязать ось пути к колее 1520 мм.
Не вышло: медианная интенсивность на уровне головок рельсов равна 8 из 255, рельсы
ничем не выделяются на фоне полотна, и пара пиков на расстоянии 1.52 м находится
где попало — оценки прыгали от −1.18 до +1.37 м в соседних срезах одного кадра.
Отказались, ось пути оценивается по дрейфу центра свода.
**Обычная связность при кластеризации.** Соседние лучи объединялись без учёта глубины,
и предмет на 55 м слипался со стеной на 150 м в одно пятно размером 5 × 4 м. Реальный
объект обнаруживался в 16 кадрах из 20. После введения допуска по глубине, растущего
с расстоянием, — 20 из 20 и правильные габариты 0.67 × 1.35 м.
**Грибовидное тело с мушиными параметрами.** 2000 клеток Кеньона и 5 % активных
насыщаются после нескольких тысяч примеров: подавлено 98 % синапсов, новизна реального
препятствия падает до 0.001, и оно перестаёт обнаруживаться совсем. Потребовалось
увеличить популяцию до 50 000 и снизить разрежённость до 0.1 %, а темп депрессии
согласовать с размером обучающей выборки.
**Корреляция продольного профиля «в лоб».** Первая версия оценки скорости залипала
на нулевом сдвиге: в профиль входила ближняя зона, где на метр пути приходятся тысячи
лучей, и её вклад подавлял всё остальное. Помогло исключение ближней зоны и вычитание
скользящего среднего. Отдельно обнаружилась ошибка в перепроекции — использовалась
высота над рельсом вместо z сенсора, из-за чего согласие держалось на 0.12 вместо 0.9.
**Взвешивание срезов по числу точек при оценке оси пути.** У ближних срезов точек
в сотни раз больше, и подгонка полностью игнорировала дальние, где как раз содержится
кривизна. Кривые расходились от +10 до −10 м на 200 м в соседних кадрах. Равные веса
по срезам и линейная (а не квадратичная) экстраполяция за горизонт видимости решили
проблему.
**Оценка оси пути на станции.** Платформа делает сечение резко несимметричным, центр
свода «уезжает», и габарит заезжает прямо на платформу: радиус кривой падал с 999 до
225 м за 4.5 с. Это давало 54 % кадров с ложной тревогой на бэге с платформой и
стрелкой. Помогли два физических ограничения — минимальный радиус 300 м и предел
скорости изменения оси. Стало 5.4 %.
**Срыв оценки скорости на смене типа тоннеля.** На переходе круглого тоннеля
в двухпутный сопоставление кадров теряло опору и выдавало попеременно 0 и 70 км/ч.
Помог фильтр с физическим пределом ускорения 3 м/с²: поезд за 0.1 с так не разгоняется.
**Полигон без учёта кривизны.** Первая версия синтетических сценариев ставила предмет
в поперечных координатах сенсора, а не на ось пути. В кривой это уносило его в стену,
и «рабочая дальность» выходила 32 м вместо реальных 55+. Исправлено привязкой к оси.
---
## 9. Размеченный полигон: дальность обнаружения
```bash
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz
python tools/plot_benchmark.py
```
Разметки в датасете нет, а организаторы предупредили, что приватный тест собран
добавлением синтезированных препятствий. Полигон строится тем же способом: в реальные
кадры пустого тоннеля трассировкой лучей вставляется предмет, стоящий **на оси пути**
в фиксированной точке тоннеля, поезд к нему подъезжает, и на каждом кадре известна
истинная дистанция.
Модель сенсора опирается на руководство: поканальная дальность из Приложения A
(каналы 34–65 берут 200 м, каналы 98–128 смотрят в землю и рассчитаны только на
ближнее поле), вероятность обнаружения на паспортной дальности PoD = 70 %, шум
дальности ±2 см, заполнение пятна луча для мелких предметов.
Проверка модели: настоящий объект 0.67 × 1.35 м на 55 м даёт 47–69 лучей; синтетический
человек 0.44 × 1.71 м на 60 м даёт 50 лучей. Совпадает.
Результаты приводятся в трёх разрезах, потому что смешивать их нельзя:
1. **видимость** — доля кадров, в которых до предмета дошёл хотя бы один луч;
за поворотом она падает до нуля независимо от алгоритма;
2. **обнаружение при условии видимости** — собственно качество алгоритма;
3. **обнаружение как есть** — произведение первых двух, эксплуатационная величина.
### 9.1. Результат
90 сценариев: 9 предметов × 2 поперечных смещения × 5 бэгов, 14 004 наблюдения
с известной истинной дистанцией.
| Предмет | Площадь | Рабочая дальность | P@50 м | P@100 м | P@150 м | Видимость |
|---|---|---|---|---|---|---|
| человек стоя | 0.75 м² | **100 м** | **0.70** | 0.53 | 0.12 | 92.5 % |
| человек сидя | 0.42 м² | 20 м | 0.62 | 0.34 | 0.00 | 89.3 % |
| ящик | 0.36 м² | 20 м | 0.49 | 0.00 | 0.00 | 88.1 % |
| чемодан | 0.25 м² | 62 м | 0.53 | 0.00 | 0.00 | 81.9 % |
| ведро | 0.10 м² | 8 м | 0.00 | 0.00 | 0.00 | 61.2 % |
| каска | 0.07 м² | — | 0.00 | 0.00 | 0.00 | 51.1 % |
| камень | 0.05 м² | — | 0.00 | 0.00 | 0.00 | 45.1 % |
| бутылка | 0.03 м² | — | 0.00 | 0.00 | 0.00 | 41.2 % |
| кабель | ~0 м² | — | 0.00 | 0.00 | 0.00 | 25.7 % |
Предыдущие замеры для сравнения. Без накопления в координатах пути (п. 7.3):
человек — рабочая дальность 62 м, P@50 = 0.56, P@100 = 0.24. С накоплением, но без
разреза по контрасту (п. 9.5): 100 м, P@50 = 0.56, P@100 = 0.57.
**«Рабочая дальность» у предметов около порога неустойчива** и её не надо читать как
физическую величину: метрика идёт от ближнего пояса и обрывается на первом, где доля
падает ниже 0.5, усредняя при этом два поперечных положения — на оси и со смещением
0.9 м к краю габарита. У сидящего человека и ящика пояс 25–40 м даёт 0.48 против
порога 0.50, и число падает с 62 до 20 м, хотя P@50 при этом не ухудшилось.
Содержательны таблицы по поясам (п. 9.2 и 9.5), а не это одно число.
Граница проходит по числу лучей: на 40–55 м человек даёт 68 лучей, чемодан 25, ведро 11,
каска 6, бутылка 3. Ниже примерно **десяти лучей предмет перестаёт отличаться от шума**
решётки, и никакая обработка этого не исправит — нужен либо более плотный сенсор, либо
подъезд ближе.
### 9.2. Почему рабочая дальность 62 м, а реальный объект виден на 98.9 %
Разброс по бэгам огромный, и он объясняет расхождение:
| Бэг (человек стоя, на оси) | 25–40 м | 40–55 м | 55–70 м | 70–90 м | лучей на 50 м |
|---|---|---|---|---|---|
| `doubleT_platform` | 1.00 | 1.00 | 1.00 | 1.00 | 68 |
| `roundT_squareT_pressureGate_squareT` | 1.00 | 1.00 | 1.00 | 0.67 | 68 |
| `squareT_platform_squareT_switch` | 0.00 | 0.18 | 1.00 | 1.00 | 56 |
| `roundT_pressureGate_roundT` | 0.73 | 0.00 | 0.00 | 0.00 | 65 |
| `roundT_doubleT` | 0.27 | 0.00 | 0.00 | 0.00 | 32 |
Не «плохо везде понемногу», а **идеально на одних участках и слепо на других**, причём
при 65 лучах на предмете. То есть дело не в видимости и не в размере.
> Таблица снята до накопления в координатах пути (п. 7.3) и до разреза по контрасту
> (п. 9.5). Актуальные цифры по тем же бэгам — в п. 9.5: `roundT_pressureGate_roundT`
> из полностью слепого за 40 м стал 0.45 / 1.00 / 0.57 на 40–90 м,
> `roundT_doubleT` за 40 м слепым остался.
### 9.3. Найденная причина слепоты: связная компонента течёт вдоль стены
Покадровый разбор провала (`roundT_pressureGate_roundT`, человек на 50 м, 65 лучей
вставлено) показал: кандидата нет вообще. В кадре всего 5 компонент, и одна из них —
**42 059 лучей, протянувшиеся по дальности от 4 до 99 м**, наполненность 0.05.
Кластеризация с разрывом по глубине объединяет соседние лучи, если их дальности
отличаются меньше чем на `0.06·R + 0.35` м. Вдоль гладкой стены тоннеля соседние лучи
отличаются на сантиметры, поэтому стена связна от ближнего поля до горизонта. Предмет,
стоящий у такой стены, попадает в ту же компоненту и **вместе с ней отбрасывается**
правилом «ни один предмет не тянется на 15 м вдоль пути».
Это не регрессия: мерж одинаков при любой верхней границе контекста, включая исходную.
**Попытка первая: разрезать по дальности.** Переглубокая компонента не выбрасывается,
а пересобирается с более строгим допуском. Предмет при этом действительно выделяется —
57 лучей, наполненность 1.00. Измеренный размен на `roundT_pressureGate_roundT`
(человек на 25…90 м) и глобально:
| Допуск разреза | Обнаружение | Ложных кадров (бэг) | Ложных треков (бэг) |
|---|---|---|---|
| выключен | 28.2 % | 0.0 % | 0 |
| 0.045 | 28.2 % | 27.2 % | 1 |
| 0.040 | 61.5 % | 21.5 % | 2 |
| **0.030** | **94.9 %** | 57.5 % | 5 |
| 0.015 | 94.9 % | 73.7 % | 16 |
| 0.006 | 94.9 % | 96.5 % | 47 |
Глобально при 0.030: ложных треков **25.8 на км против 5.2**, кадров с тревогой
**52 % против 9.4 %**. Половина кадров с тревогой — это непрерывное торможение, поэтому
разрез по умолчанию **выключен**.
Проверялось и то, можно ли отделить осколок стены от предмета по признакам: ни один
не разделяет их. Медианы (предмет / стена): ширина 0.21 / 0.20 м, наполненность
1.00 / 1.00, лучей 12 / 8, новизна 0.50 / 0.41. Строгий разрез делает стену
геометрически неотличимой от предметов — потому и цена такая. Этот вариант убран.
### 9.4. Разделение фигуры и фона по движению
Разрезать по дальности нельзя: предмет и стена рядом с ним стоят на одной дальности.
Зато они по-разному **приближаются**, и это чистая геометрия. Вдоль фиксированного луча
стена, параллельная движению, не приближается вовсе: поезд едет, точка пересечения
скользит по стене, дальность не меняется. Предмет, обращённый к поезду, приближается
ровно на пройденный путь.
Отсюда признак: `advance = (r_прошлый − r_текущий) / ds`. Ноль у фона, единица у фигуры.
Ничего перепроецировать не нужно — столбец решётки отвечает фиксированному азимуту, а
рысканье в кривой за кадр (0.06° при радиусе 1300 м) меньше шага решётки. Это тот самый
канал T4/T5 → LPTC: широкопольный поток задаёт ожидание, а что движется иначе — фигура.
Замер на вставленном человеке подтверждает физику: у предмета `advance` = 0.99…1.01,
у стены на той же дальности — 0.39…0.82 и падает по мере приближения.
Разрез по этому признаку (`Params.split_adv`, `lobula.split_by_figure`) не крошит стену:
из склеенной компоненты в 42 000 лучей остаётся 2.6–5.5 тысяч и **6–15 кандидатов**
вместо 1259 у разреза по дальности.
**Важно для честности замера.** Первая проверка дала 70 % ложных кадров, но она была
некорректной: память тоннеля обучена на кандидатах **старого** генератора, а разрез
порождает формы, которых она никогда не видела, — всё выглядит новым. После пересбора
кэша и переобучения памяти на тех же настройках (`tune_candidates_adv.npz`,
`new_data_candidates_adv.npz`, 70 273 кандидата) картина такая:
**Что это даёт — полигон:**
| | без разделения | с разделением |
|---|---|---|
| Рабочая дальность, человек стоя | 62 м | **80 м** |
| P@50 м | 0.56 | **0.71** |
| P@100 м | 0.24 | **0.36** |
| Чемодан, P@50 м | 0.57 | 0.67 |
По бэгам (человек на оси, доля кадров с обнаружением):
| Бэг | 25–40 м | 40–55 | 55–70 | 70–90 | 90–110 |
|---|---|---|---|---|---|
| `squareT_platform_squareT_switch` | 0.00 → **1.00** | 0.18 → **1.00** | 1.00 → 1.00 | 1.00 | 1.00 |
| `roundT_doubleT` | 0.27 → **0.64** | 0.00 | 0.00 | 0.00 | 0.00 |
| `roundT_pressureGate_roundT` | 0.73 → **0.80** | 0.00 | 0.00 | 0.00 | 0.00 |
| `roundT_squareT_pressureGate_squareT` | 1.00 | 1.00 | 1.00 | 0.67 → **0.89** | 0.00 |
| `doubleT_platform` | 1.00 | 1.00 | 1.00 | 1.00 | 0.25 |
**Что это стоит:**
| | без разделения | с разделением |
|---|---|---|
| Кадров с ложной тревогой | 9.4 % | 30.3 % |
| Разных ложных треков на км | **5.2** | **21.5** |
| Ложных тревог на полигоне, на кадр | 0.055 | 0.348 |
| Задержка, медиана | 31 мс | 39 мс |
| Реальный объект на 55 м | 98.9 % | 98.9 % |
**Порогом это не лечится.** Проверены значения 0.6 / 0.8 / 0.9: ложных треков
21.5 / 21.6 / 21.6 на километр, а доля кадров с тревогой только растёт (30 → 35 → 40 %).
Причина в том, что ложные срабатывания здесь — **не шум, а настоящие поверхности,
обращённые к поезду**: рамы гермозатворов, торцы, порталы. По одному признаку движения
они от предмета неотличимы, потому что физически ведут себя так же.
**Решение: разделение по умолчанию выключено** (`split_adv: 0.0`). 21.5 ложного трека
на километр — это напрасное торможение каждые 47 метров, система в таком виде
неработоспособна, и +18 м рабочей дальности этого не окупают. Два бэга из пяти
разделение к тому же не спасает: за 40 м они остаются слепыми.
**Чем это лечится по-настоящему.** Не порогом, а памятью: приближающиеся конструкции
тоннеля постоянны и повторяются от проезда к проезду. Здесь память обучена на пяти
бэгах и 20 минутах записи; на реальной линии с многими проездами грибовидное тело
подавило бы их так же, как подавляет всё остальное штатное. Это проверяемое
предсказание, а не надежда: переобучение памяти уже снизило ложные кадры с 70 % до
30 % — просто за счёт того, что она увидела эти формы один раз.
---
## 9.5. Разделение фигуры и фона по контрасту — третья попытка, и она работает
Разрез по допуску (п. 9.3) и разрез по движению (п. 9.4) вернули зрение и оба
оказались слишком дороги. Оставался третий признак фигуры, и он всё это время
уже вычислялся — просто не участвовал в сегментации.
**Физика.** Гладкая стена даёт **нулевой центр-окружение по построению**. Как бы
сильно дальность ни менялась вдоль стены, она меняется плавно: центр равен своему
окружению, и контраст равен нулю. Предмет на стене — это ступенька, и она видна.
Именно этим занята ламина, и её выход `gap` («насколько ближе окружения», м) наш
конвейер считал с самого начала — но использовал только как **признак кандидата**.
Сегментация же шла по связности с допуском по глубине, и переглубокая компонента
отбрасывалась целиком ещё до того, как признак кому-то пригождался.
**Замер разделимости** (вставленный человек, два круглых тоннеля, 55 кадров):
| Порог `gap` | Лучей предмета | Лучей фона | Компонент фона в кадре |
|---|---|---|---|
| 2 м | 90.8 % / 87.5 % | 4.15 % / 3.10 % | 24.6 / 16.3 |
| 4 м | 89.2 % / 83.4 % | 0.68 % / 0.62 % | 13.7 / 13.5 |
| **6 м** | **85.7 % / 75.1 %** | **0.13 % / 0.22 %** | **5.0 / 8.9** |
| 9 м | 80.7 % / 61.7 % | 0.02 % / 0.05 % | 0.7 / 2.6 |
Для сравнения: разрез по допуску давал 1259 кандидатов в кадре. Здесь фон
распадается на 5–9 компонент — на два порядка меньше, и это **настоящие выступы**,
которые память способна выучить, а не произвольные осколки гладкой стены.
Разделение держится до 75 м и разваливается к 90 м: доля лучей предмета, прошедших
порог 6 м, по полосам — 88 / 96 / 97 / 89 / 61 / 14 / 0 % на 15 / 30 / 45 / 60 / 75 /
90 / 105 м. Дальше 90 м кольцо окружения снова упирается в стену на той же
дальности (п. 7.3), и контраста нет.
### Почему первая версия всё равно была дорогой
Без ограничений разрез дал **9.4 ложных трека на км против 7.4** — и причина
нашлась замером, а не рассуждением. Медианы кандидатов на `roundT_doubleT`:
| признак | без разреза | с разрезом |
|---|---|---|
| протяжённость вдоль пути | 2.82 м | **1.00 м** |
| то же, дальше 55 м | 6.87 м | **0.81 м** |
| наполненность | 0.47 | 0.57 |
| контраст `gap` | 1.24 м | 3.29 м |
Разрез **отрезает фон**, и вместе с фоном исчезает протяжённость. В весе улики
(`central_complex._quality`) за неё отвечает множитель компактности
`clip(1.5 − depth/(3·span))`: при depth 6.9 м и размере 0.65 м он равен 0.1, при
depth 0.8 м — 1.0. То есть каждое наблюдение вырезанной фигуры стало весить
**вдесятеро больше**, и подавление протяжённых конструкций, работавшее до разреза,
выключилось.
Порог этого не лечит — проверено сквозным замером:
| Порог разреза | Ложных треков на км |
|---|---|
| выключен | 7.4 |
| 6 м | 9.4 |
| 9 м | 10.4 |
| 12 м | 9.4 |
### Что решило: одна фигура на компоненту
Раз каждая лишняя фигура стоит вдесятеро дороже прежнего, их число надо
ограничить. Из переглубокой компоненты выносится только **сильнейшая** фигура по
сумме превышения порога. Это тот же приём глобального торможения, которым APL
оставляет активными считанные проценты клеток Кеньона, а широкопольное торможение —
считанные колонки LC11.
| Фигур на компоненту | Ложных треков на км | Кадров с тревогой |
|---|---|---|
| без ограничения | 9.4 | 16.9 % |
| 2 | 7.4 | 15.5 % |
| **1** | **6.4** | **15.0 %** |
Дальность обнаружения при этом **не меняется вовсе**: и при одной фигуре, и при
двух, и без ограничения все три конфигурации дают одинаковые доли по полосам.
Лишние фигуры не добавляли зрения — только ложные тревоги.
Разрез вдобавок не применяется ближе 55 м (`split_near`): там покадровый тракт
видит предмет и без него (100 % на всех пяти бэгах до 70 м), а обстановки,
дающей контраст, в ближнем поле на порядок больше. Без этого ограничения 75 %
добавленных ложных треков приходили именно оттуда.
### Итог
Цифры ниже — leave-one-bag-out с пересобранным кэшем кандидатов и переобученной
памятью: без этого замер лжёт (п. 9.4).
| Конфигурация | Ложных треков на км | Кадров с тревогой | Объект 55 м |
|---|---|---|---|
| как было | 9.1 | 17.2 % | 98.9 % |
| **с разрезом по контрасту** | **7.5** | **16.7 %** | **98.9 %** |
То есть разрез не только вернул зрение там, где его не было, но и **снизил** ложные
тревоги — за счёт того, что переглубокая компонента перестала выбрасываться целиком
и вместо неё в память попадает одна осмысленная фигура, которую есть чему выучить.
Обнаружение вставленного человека на оси, доля кадров. Протокол полигона: предмет
стоит в точке тоннеля, до которой от начала записи 200 м, поезд подъезжает. Память
обучена и на этом бэге — как и во всём полигоне.
| Бэг | 15–25 м | 25–40 | 40–55 | 55–70 | 70–90 |
|---|---|---|---|---|---|
| `roundT_pressureGate_roundT` | 1.00 | 0.73 | 0.00 → **0.45** | 0.00 → **1.00** | 0.00 → **0.57** |
| `squareT_platform_squareT_switch` | 0.00 | 0.00 | 0.18 → **0.45** | 1.00 | 1.00 |
| `roundT_doubleT` | 1.00 | 0.27 | 0.00 | 0.00 | 0.00 |
**Это ровно тот бэг, который разбирался в п. 9.3.** `roundT_pressureGate_roundT` —
запись, где компонента из 42 000 лучей течёт вдоль стены от 4 до 99 м и уносит
предмет с собой. Разрез по контрасту превращает полностью слепую полосу 40–90 м в
0.45 / 1.00 / 0.57. Разрез по допуску (п. 9.3) и по движению (п. 9.4) добивались
там же 0.94 и 0.80 ценой 25.8 и 21.5 ложного трека на километр; здесь ложных треков
стало **меньше**, чем было до разреза.
**Второй слепой бэг разрез не спасает**, и причины там две, обе разобраны
покадрово в п. 9.6: за 50 м до предмета не доходит линия взгляда (98 % лучей
упираются в преграду ближе него), а на 35–52 м мешает уже наш собственный порог
`split_near`, снижать который оказалось слишком дорого.
**Осторожно с «рабочей дальностью».** Метрика в `plot_benchmark.py` идёт от ближнего
пояса и останавливается на первом, где доля падает ниже 0.5, а усредняет она два
поперечных положения сразу — на оси и со смещением 0.9 м к краю габарита. У предметов,
стоящих около порога, она поэтому скачет: у сидящего человека пояс 25–40 м даёт 0.48
против 0.50, и «рабочая дальность» падает с 62 до 20 м при том, что P@50 м
выросло с 0.58 до 0.62. Смотреть надо на таблицу по поясам, а не на одно число.
**Где разрез стоит денег — 1: холодный старт.** Всё сказанное верно, когда память
обучена. На совершенно новой линии, где памяти нет вовсе, разрез, наоборот, дорог:
21.8 → **31.8** ложных трека на километр. Это логично — он порождает кандидатов из
настоящих выступов тоннеля, и без памяти отличить их не от чего. Реальный сценарий —
именно с обученной памятью, она поставляется в образе; но если участок настолько
новый, что память к нему неприменима, `split_gap: 0.0` возвращает прежнее поведение.
**Где разрез стоит денег — 2: стоянка.** На записи со **стоящим** поездом (`doubleT_obstacle`)
он добавляет один устойчивый трек на 76.6 м, и доля кадров с посторонней тревогой
растёт с 63 % до 96 %. Это не случайность: при нулевом пройденном пути не работают
ни накопитель, ни привыкание — оба живут в координатах пути. Число разных ложных
треков на этом бэге растёт всего с 4 до 5; раздувается именно доля кадров, потому
что на стоянке ничто не уходит из поля зрения.
---
---
## 9.6. Почему `roundT_doubleT` остаётся слепым за 40 м
Разрез по контрасту (п. 9.5) открыл `roundT_pressureGate_roundT`, но второй слепой
бэг не тронул. Покадровый разбор цепочки «лучи → кандидат → улика → тревога» на
штатной постановке полигона (предмет в точке, до которой от начала записи 200 м)
показал **две разные причины в двух разных полосах дальности**. Одна физическая,
вторая — наша.
### Дальше 50 м: смотреть не на что
Считаем, сколько лучей доходит до предмета, по этапам:
| Дальность до предмета | Геометрически попали | Пережили модель сенсора | Не заслонены |
|---|---|---|---|
| 55–105 м | 29 | **29** | **1** |
| 30–55 м | 109 | 109 | 86 |
Модель сенсора не теряет **ни одного** луча: предмет крупный, дальность паспортная.
Но за 55 м **98 % лучей упираются в эхо, которое ближе предмета**. Дело не в
отражательной способности и не в числе каналов — до предмета просто не доходит
линия взгляда.
Это не артефакт постановки. Предмет пробовали ставить четырьмя способами — на
оценённую ось коридора, прямо по оси сенсора, на половину смещения и со сдвигом
+0.8 м; заслонение одинаково во всех четырёх, и дальность преграды тоже одна и та же:
| Дальность до предмета | 104 | 94 | 84 | 74 | 64 | 54 | 45 |
|---|---|---|---|---|---|---|---|
| Преграда, м | 90 | 84 | 76 | 66 | 58 | 52 | **49** |
| Лучей видно (ось коридора) | 3/17 | 1/19 | 0/25 | 0/37 | 1/41 | 8/61 | **63/89** |
Преграда приближается вместе с поездом и в какой-то момент **обгоняет** предмет:
на 45 м обзор доходит до 49 м, предмет оказывается ближе преграды — и 63 луча из 89
приходят разом. Ровно с этого места и начинается обнаружение.
Для сравнения, на том же замере `roundT_pressureGate_roundT` преграда всегда **за**
предметом (предмет на 104 м — преграда на 113 м; предмет на 56 м — преграда на 70 м),
поэтому там предмет виден, и разрез по контрасту может ему помочь.
**Почему видимость на этом перегоне такая короткая.** По бэгу в целом вдоль оси пути
видно на 106 м (медиана), но полигон ставит предмет в точку за 200 м от начала
записи, а это самое начало проезда — как раз худший его участок. Плюс общий для всех
бэгов эффект: луч, идущий вдоль пути, **скользит по полотну**, и с какой-то дальности
прирельсовая зона перестаёт наблюдаться вовсе. Нижняя наблюдаемая точка у оси пути
(5-й процентиль высоты над головкой рельса):
| Дальность | 20 м | 40 | 60 | 80 | 90 | 100 |
|---|---|---|---|---|---|---|
| `roundT_doubleT` | −0.35 | −0.32 | −0.21 | −0.07 | **+0.36** | **+0.64** |
| `roundT_pressureGate_roundT` | −0.32 | −0.32 | −0.06 | +0.18 | +0.10 | −0.11 |
| `doubleT_platform` | −0.35 | −0.36 | −0.34 | −0.06 | +0.08 | +0.35 |
За 80–90 м самое низкое, что видно у оси, находится уже **выше рельса** на 0.1–0.6 м.
Это и есть предел скользящего луча, и он объясняет, почему у мелких лежащих предметов
видимость в полигоне 25–61 %: их просто нечем осветить.
Никакой обработкой это не лечится. Лечится геометрической картой линии (п. 7.3) —
или вторым сенсором, поднятым выше.
### 35–52 м: лучи есть, кандидата нет — и это наш порог
Здесь картина обратная: лучей 83…146, а кандидата нет.
| Дальность | 41.6 | 38.7 | 35.9 | 33.3 | 30.7 | 28.3 |
|---|---|---|---|---|---|---|
| Лучей на предмете | 83 | 101 | 126 | 146 | 168 | 200 |
| Кандидат при `split_near = 55` | нет | нет | нет | нет | есть | есть |
| Кандидат при `split_near = 20` | есть | есть | есть | есть | есть | есть |
Причина — тот же мерж со стеной, что в п. 9.3, и наш собственный порог: разрез по
контрасту по умолчанию не применяется ближе 55 м. Порог был введён из соображения
«ближе покадровый тракт видит предмет и сам» — на этом бэге это неверно.
Со сниженным порогом первая тревога наступает на **37.3 м вместо 28.3 м**, а доля
кадров без кандидата в окне 20–42 м падает с 44 % до 6 %.
**Порог всё равно оставлен 55 м.** Цена снижения измерена честно — с пересбором кэша
кандидатов при `split_near = 20` и переобучением памяти на нём:
| `split_near` | Ложных треков на км | Кадров с тревогой |
|---|---|---|
| **55 м** | **7.4** | **16.7 %** |
| 30 м | 14.1 | 25.6 % |
| 20 м (память не пересобрана) | 18.1 | 28.2 % |
| 20 м (**честно**, память переобучена) | 17.1 | 27.9 % |
Переобучение на этот раз почти ничего не вернуло (18.1 → 17.1): ближние вырезанные
фигуры от препятствий действительно неотличимы. А выигрыш — девять метров на
дальности, где он ничего не меняет: ТЗ оценивает 100 / 200 / 300 м, и поезд на
50 км/ч не останавливается ни за 28, ни за 37 м. Платить за это 2.3-кратным ростом
ложных тревог нельзя.
`split_near: 20.0` в конфиге доступен для линии, где обстановка беднее и ложные
тревоги дешевле.
---
## 10. Новый участок: привыкание внутри проезда — отрицательный результат
Обученная память отвечает на вопрос «этот тоннель я уже видел». На новом участке
она бесполезна по определению, и это измеренная величина, а не абстрактный риск:
| | Ложных треков на км | Кадров с тревогой |
|---|---|---|
| память обучена на других бэгах (leave-one-bag-out) | 9.1 | 17.2 % |
| **памяти нет вовсе (новая линия)** | **21.8** | **33.1 %** |
Приватный тест — это как раз новый участок, поэтому вопрос важный. Механизм был
сделан, доведён до работы и **отвергнут по результатам замера**. Ниже — почему,
потому что отрицательный результат здесь содержательнее положительного.
### Замысел
**Тоннельная обстановка повторяется вдоль пути, а посторонний предмет — нет.**
Кабельный кронштейн, рама крепи, стык тюбингов встречаются каждые несколько метров
в одном и том же виде; разбор худшего бэга (п. 5.1) прямо это показал — пять из
восьми ложных треков были колоннами, повторяющимися каждые 13–20 м. Упавший же
предмет лежит в одном месте.
Отсюда привыкание, считающее не по времени и не по числу кадров, а по **числу
разных точек пути**, где встретилась эта форма. Настоящий предмет виден сто кадров
подряд, но всё это время стоит в одной точке мира: его код депрессируется один раз
и остаётся новым до конца подъезда. Биологически это familiarity suppression
MBON-α′3, а момент депрессии разрешает координата пути из центрального комплекса —
роль, которую у мухи играют дофаминергические PPL1/PAM.
### Что пришлось починить, чтобы механизм вообще заработал
Обе ошибки найдены замером и сами по себе поучительны.
**Полный набор признаков не годится.** Первая версия кодировала кандидата тем же
дескриптором, что и долговременная память. За двенадцать разных мест новизна упала
с 1.000 до 0.981 — то есть ни на что. В дескрипторе есть дальность, число лучей,
угловой размер, интенсивность: код одного и того же кронштейна с 90 и с 40 м
разъезжается, повторы не узнаются, а подъезжающий предмет каждый кадр выглядит
новой формой и привыкает сам к себе. Переведено на десять признаков формы, не
зависящих от дальности.
**Нормировка обязана замирать.** Пока среднее и разброс пересчитываются, вместе с
ними плывёт код. У неподвижного предмета, чьи признаки формы не меняются вовсе,
набор активных клеток обновлялся настолько, что предмет засчитывался как
**двадцать шесть разных мест** и гасил сам себя. При пороге заморозки 4000
кандидатов эффект оставался живуч: в `roundT_doubleT` (около 570 кандидатов за
проезд) нормировка не замирала никогда, привыкание стояло на 0.20, медианная
новизна кандидата 1.00, и ложных треков было ровно столько же, сколько без него.
Порог снижен до 300.
### Почему всё равно отвергнуто
После починок механизм начал давать цифры: leave-one-bag-out 7.5 → **5.9** ложных
трека на км, новая линия 31.8 → **26.8**. Выглядело как успех — до проверки
обратной стороны.
**Привыкание глушило сам предмет.** Замер на вставленных предметах
(`doubleT_platform`, подъезд с 200 м):
| | Новизна кандидата, 20–60 м | Обнаружение, 70–90 м |
|---|---|---|
| привыкание выключено | 0.75 | 95 % |
| привыкание включено | **0.24** | **74 %** |
И это не «предмет заодно с обстановкой»: медианная новизна обычного кандидата в тех
же прогонах — 0.43…0.71, то есть **предмет подавлялся сильнее, чем тоннельная
обстановка**. Избирательность не просто мала, она отрицательна.
**Причина — насыщение популяции, а не узнавание повторов.** За проезд набирается
около 270 событий депрессии по 80 клеток каждое; при популяции 4000 это 5.4 попадания
на клетку, то есть подавлено всё. Ровно та же ошибка, что описана в п. 8 для
долговременной памяти с мушиными параметрами, — и проверяется она так же, поднятием
ёмкости:
| Ёмкость короткой памяти | Ложных треков на км (LOO) | Новизна предмета | Обнаружение 70–90 м |
|---|---|---|---|
| привыкание выключено | 7.5 | 0.75 | 95 % |
| 4 000 клеток | **5.9** | **0.24** | **74 %** |
| 20 000 клеток | 7.5 | 0.47 | 95 % |
| 50 000 клеток | 7.5 | 0.64 | 95 % |
При ёмкости, достаточной чтобы не насыщаться, привыкание не меняет **ничего**: 7.5
против 7.5 на обученной памяти и 31.8 против 31.8 на новой линии. Весь видимый
выигрыш был глобальным глушением — а его и так даёт порог решения, который у нас
уже есть отдельным параметром.
Пробовались два способа сделать отсчёт избирательным, оба измерены и оба ничего не
дали:
* **резкий отсчёт MBON**: вместо среднего по активным клеткам — квантиль 0.75 или
0.90, то есть «знакомо, только если подавлено не меньше трёх четвертей кода».
Новизну предмета это вернуло (0.75), но и весь эффект тоже (7.5 → 7.5);
* **огрубление признаков** (coarse coding) до половины и до целого разброса, чтобы
два похожих кронштейна давали буквально один и тот же код: 7.5 / 7.5 / 7.5.
### Что это на самом деле означает
Коды двух разных экземпляров одной и той же конструкции не перекрываются настолько,
чтобы второй узнавал первого, даже после огрубления до целого стандартного
отклонения. Проще говоря: **в этих данных штатная обстановка повторяется не так
буквально, как предполагалось**. Разброс между экземплярами одной конструкции —
по ракурсу, по числу лучей, по тому, какая часть попала в габарит — больше, чем
разрешение любого разумного кода формы.
Поэтому на новом участке работает то, что и работало: долговременная память,
обученная на других участках, снижает ложные тревоги с 21.8 до 9.1 трека на
километр. Признаки в дескрипторе намеренно смешаны — форма и угловой размер
переносятся на любой тоннель, положение в сечении запоминает конкретную обстановку,
и именно первая половина даёт этот перенос.
**Механизм оставлен в коде и выключен** (`enable_habituation: false`) со всеми
параметрами: ёмкость, квантиль отсчёта, огрубление, шаг «разных мест», путь
полузабывания. На линии, где обстановка стандартизована сильнее, чем в этих записях,
он может заработать — но проверять это надо замером на той линии, а не надеждой.
---
## 11. Обученное считывание MBON: метки из физики
До сих пор грибовидное тело училось **без учителя**: оно запоминало частоту
обстановки и отвечало «знакомо/ново». Вставка предметов трассировкой лучей даёт
то, чего в датасете нет, — **метку**. `synth.inject` возвращает индексы лучей,
которые он переписал, поэтому кандидат размечается точным пересечением: предмет,
если не меньше половины его лучей пришли от вставки (`tools/make_training_set.py`,
`MIN_OVERLAP = 0.5`). Слои остаются те же — разрежённый код клеток Кеньона,
торможение APL, один выход MBON, — меняется только учитель: вместо частоты
обстановки различение «предмет / тоннель» (`flyguard/mbon_readout.py`).
### 11.1. Сначала — найденный артефакт: интенсивность выдавала вставку
Первое же обучение дало подозрительно красивые цифры: leave-one-bag-out AUC
**0.9935**, по полосам дальности от 0.998 вблизи до 0.941 на 160–230 м. Разбор
важности перестановкой поставил наверх не форму, а **интенсивность**:
```
inten +0.0187 ← втрое больше следующего
elongation +0.0051
fill +0.0048
log_rays +0.0030
```
Проверка показала, откуда это взялось. Интенсивность вставки считалась по
ламбертовой модели ρ·cosθ/r², и произведение `intensity · r²` у вставленных
предметов держалось в пределах **1.8×** между p10 и p90, тогда как у настоящих
кандидатов разброс **64×**. Иначе говоря, вставка — и только она — точно
подчинялась учебниковому закону обратных квадратов, и это распознаётся надёжнее
любой формы.
Хуже того, у нижнего среза шкалы признак переворачивается. Разделяющая
способность **одной интенсивности** по полосам:
| полоса | intensity вставки | intensity обстановки | AUC по одной интенсивности |
|---|---:|---:|---:|
| 0–30 м | 37.2 | 5.0 | 0.956 |
| 30–55 м | 4.6 | 4.5 | 0.513 |
| 55–80 м | 1.7 | 4.0 | 0.287 |
| 80–110 м | 1.0 | 3.0 | 0.197 |
| 110–160 м | 1.0 | 5.0 | **0.049** |
| 160–230 м | 1.0 | 7.5 | **0.010** |
За 110 м расчётная яркость вставки падала ниже единицы, упиралась в срез шкалы —
и «тускло» становилось почти безошибочным признаком предмета. AUC 0.010 означает
почти идеальное разделение с обратным знаком.
**В реальных данных всё наоборот.** Медиана интенсивности по всему облаку,
20 кадров на бэг:
| бэг | 5–15 м | 15–30 | 30–50 | 50–80 | 80–120 | 120–170 | 170–230 |
|---|---:|---:|---:|---:|---:|---:|---:|
| `doubleT_obstacle` | 11 | 9 | 8 | 9 | 6 | 6 | 6 |
| `roundT_doubleT` | 10 | 8 | 8 | 4 | 2 | 3 | 4 |
| `squareT_platform_squareT_switch` | 8 | 8 | 7 | 3 | 2 | 2 | 4 |
Никакого 1/r². Прибор отдаёт не принятую энергию, а **отражательную способность
с компенсацией дальности** — то, что в руководстве Hesai называется
reflectivity. Ламбертова модель описывала физику фотона, а не то число, которое
лежит в поле `intensity`.
Настоящий предмет даёт точку привязки. В `doubleT_obstacle` он стоит на 55.9 м,
57 лучей, и его медианная интенсивность — **34.5** (p10…p90 = 31…39) при
медиане окружающих кандидатов 23.5. Старая формула на этой дальности дала бы
**1.45**, то есть в 24 раза тусклее настоящего.
**Первая попытка починки была неправильной.** Раз прибор компенсирует
дальность, яркость вставки сделали постоянной: `100 · ρ · √cosθ` со шкалой,
привязанной к настоящему предмету (ρ = 0.35 → около 34). Выборка пересобрана,
модель переобучена — и артефакт просто перевернулся:
| полоса | вставка | обстановка | AUC по одной интенсивности |
|---|---:|---:|---:|
| 0–30 м | 35.0 | 5.0 | 0.966 |
| 30–55 м | 35.2 | 4.5 | 0.960 |
| 55–80 м | 34.9 | 4.0 | 0.959 |
| 80–110 м | 35.1 | 3.0 | 0.970 |
| 110–160 м | 34.8 | 5.0 | 0.832 |
| 160–230 м | 34.2 | 7.5 | 0.798 |
Важность интенсивности при этом выросла втрое (+0.0533 против +0.0187), то есть
модель стала опираться на подсказку ещё сильнее.
**Настоящая причина: абсолютной шкалы в этих данных нет.** Медиана яркости
кандидатов обстановки по бэгам — 3.0, 4.0, 4.0, 4.0, 7.0, а в
`doubleT_obstacle`, где лежит настоящий предмет, 23.5 при 34.5 у самого
предмета. Разброс между записями впятеро больше, чем контраст предмета к фону
внутри записи. Любое абсолютное число, назначенное вставке, оказывается
подарком детектору — в ту или в другую сторону.
**Как сделано в итоге** (`synth.local_intensity` и `synth.IntensityEnv`):
вставка берёт яркость **реальных возвратов с тех же самых лучей** — того, что
предмет заслонил. Лучи, ушедшие в пустоту (за 100 м таких большинство),
получают образец из реальных возвратов **той же полосы дальности** в этом же
кадре; разложение кадра по дальности считается один раз и переиспользуется
всеми 135 сценариями. Ни отражательной способности, ни закона яркости от
дальности в функции нет; ρ осталась там, где ей место, — в вероятности, что
эхо вообще вернётся. Проверка вставками в реальные бэги:
| дальность | 30 м | 60 м | 100 м | 150 м | 200 м |
|---|---:|---:|---:|---:|---:|
| вставка | 8.8 | 6.9 | 4.3 | 4.3 | 10.1 |
| реальные возвраты рядом | 10.0 | 4.0 | 2.0 | 3.0 | 3.0 |
Это **заниженная** оценка: настоящий предмет в записи был в 1.47 раза ярче
окружения, и этот запас мы себе не засчитываем. На линии с откалиброванной
яркостью его можно вернуть — замером, а не верой. Тест
`test_injected_intensity_copies_the_surroundings` держит свойство: среди
аргументов нет отражения, а без разложения окружения дальность ничего не меняет.
Замечание, важное для оценки ущерба: складка, не видевшая `doubleT_obstacle`,
и с артефактом ставила настоящему предмету **1.000** (p10 = 0.998) против 0.393
у обстановки — на ближней дальности модель опиралась не только на подсказку.
Но полигонная дальность, измеренная на таких вставках, мерила бы признак,
которого в реальности нет. Поэтому все цифры ниже получены на выборке, где
яркость взята из самой записи.
Отдельный урок про метрику: таблицы выше потребовали починить подсчёт AUC.
Ранги раздавались по порядку в массиве, а не усреднялись на совпадениях, и
признак, у которого все значения равны, получал 0 или 1 вместо 0.5. Первым
ложным сигналом такого рода оказался контраст к фону за 160 м — там он
структурно равен нулю у всех кандидатов сразу. Теперь ранги средние, а сторож
«что делит выборку само по себе» встроен в `tools/train_mbon.py` и печатается
перед каждым обучением.
### 11.2. Что даёт обученное считывание
Обучение — полнопакетный логистический спуск по разрежённому коду: 4 000
клеток Кеньона, 100 активных после торможения APL, 23 признака кандидата,
185 252 примера, 19 445 из них предметы. Ёмкость выбрана развёрткой: 8 000 и
20 000 клеток дают ту же AUC (0.9859 и 0.9850 против 0.9860), а в худшем кадре
стоят 2.8 и 7.3 мс вместо 1.5 — платить не за что.
Позднее развёртку продолжили до 100 000 клеток на полной выборке (214 958
кандидатов), leave-one-bag-out:
| клеток | AUC | обстановки при 95 % предметов |
|---|---:|---:|
| **4 000** | **0.9829** | **9.89 %** |
| 20 000 | 0.9815 | 10.85 % |
| 50 000 | 0.9794 | 11.73 % |
| 100 000 | 0.9791 | 11.79 % |
Больше клеток — **хуже**, и монотонно. Активных после торможения APL всегда сто,
поэтому с ростом популяции каждая клетка становится специфичнее, и код превращается
в справочник по обучающим записям, который на невиданной записи обобщает хуже. Узкое
место не в ёмкости, а во входе: бустинг по тем же 23 признакам даёт 0.9927, то есть
запас есть, но лежит в признаках. Для сравнения, у мухи около двух тысяч клеток
Кеньона на полушарие.
Проверка — leave-one-bag-out, отдельная модель на каждую складку. Без этого
сквозные цифры были бы ложными: считывание увидело бы проверяемый бэг ровно
так же, как память тоннеля, обученная на всём подряд.
| бэг | AUC | обстановки при 95 % предметов | бустинг по сырым признакам |
|---|---:|---:|---:|
| `doubleT_platform` | 0.9923 | 3.54 % | 0.9951 |
| `roundT_doubleT` | 0.9939 | 2.79 % | 0.9987 |
| `roundT_pressureGate_roundT` | 0.9759 | 16.30 % | 0.9866 |
| `roundT_squareT_pressureGate_squareT` | 0.9828 | 11.65 % | 0.9930 |
| `squareT_platform_squareT_switch` | 0.9851 | 5.87 % | 0.9902 |
| **среднее** | **0.9860** | **8.03 %** | 0.9927 |
AUC по полосам дальности: 0.995 / 0.996 / 0.992 / 0.973 / 0.955 / 0.738 —
качество честно падает с дальностью, как и должно быть, когда от предмета
остаётся четыре луча.
Градиентный бустинг по тем же сырым признакам стабильно лучше на 0.005…0.01
AUC. Это цена разрежённого кода, и мы её знаем: взамен получаем схему, которая
считается за 1.5 мс на худшем кадре и держит непрерывное дообучение.
Наверху важности — форма, а не положение и не яркость:
```
fill +0.0079 abs_lat +0.0051
elongation +0.0076 acc_support +0.0046
h +0.0055 lat +0.0044
```
Положение в сечении (`abs_lat`, `lat`) стоит на 4–6 месте. Формально это
законный признак — опасны именно предметы в габарите, — но границу
«положительной» области задали наши же расстановки (0, ±0.6, ±1.2 м от оси),
и на защите это надо называть вслух, а не прятать.
**Сквозной результат, leave-one-bag-out:**
| | без считывания | со считыванием |
|---|---:|---:|
| разных ложных треков на километр | 7.5 | **3.3** |
| кадров с ложной тревогой | 16.7 % | **4.4 %** |
| реальный объект на 55 м | 98.9 % | **98.9 %** |
| задержка кадра, медиана | 33 мс | 35–41 мс |
**На размеченном полигоне** (модель по складкам, то есть для каждого бэга та,
что его не видела):
| человек стоя | без считывания | со считыванием |
|---|---:|---:|
| рабочая дальность «как есть» | 62 м | **80 м** |
| P@50 м | 0.70 | 0.70 |
| P@100 м | 0.19 | **0.31** |
| посторонних тревог на кадр | 0.169 | **0.030** |
Прибавка не только на человеке: ящик P@100 0.02 → 0.24, человек сидя
0.25 → 0.39, чемодан P@50 0.55 → 0.60, ведро впервые поднялось над нулём
(рабочая дальность 8 → 20 м).
Отдельно: прежние заявленные 100 м рабочей дальности и P@100 = 0.53 получены
на полигоне с артефактом яркости и **завышены**. Честные числа без считывания —
80 м и 0.19, со считыванием — 80 м и 0.31.
### 11.3. Сколько дальности покупается за ложные тревоги
Смысл обученного считывания не в том, чтобы просто показать меньшее число
ложных. Оно даёт **запас**, а запас надо тратить: поезд движется, и дальность
обнаружения — это время на торможение. Поэтому пороги решения вынесены в
`Params` и развёрнуты замером. Все строки — leave-one-bag-out по ложным и
полигон по дальности, модель по складкам:
| конфигурация | ложных треков/км | кадров с тревогой | реальный объект | P@50 | P@100 | тревог на кадр, полигон |
|---|---:|---:|---:|---:|---:|---:|
| без считывания | 7.4 | 16.65 % | 98.9 % | 0.70 | 0.19 | 0.169 |
| считывание, три подтверждения | 3.3 | 4.44 % | 98.9 % | 0.70 | 0.31 | 0.030 |
| **по умолчанию: считывание, два подтверждения** | **3.3** | **4.52 %** | **99.5 %** | **0.70** | **0.31** | **0.030** |
| тише: смешивание 0.7 | 2.5 | 4.85 % | 98.9 % | 0.70 | 0.23 | 0.016 |
| дальше: порог улики 0.40 | 5.3 | 6.19 % | 98.9 % | 0.71 | 0.34 | 0.039 |
| смешивание 0.7 + порог 0.32 | 4.3 | 7.28 % | 98.9 % | 0.71 | 0.31 | 0.055 |
| порог 0.30 | 7.8 | 8.37 % | — | — | — | — |
| порог 0.22 | 9.8 | 10.04 % | — | — | — | — |
Три вывода, каждый замером.
**Смешивание с ручной формулой — не способ купить дальность.** Строка
«смешивание 0.7 + порог 0.32» даёт ровно ту же P@100 = 0.31, что и чистая
модель при штатном пороге, но стоит 4.3 ложных трека вместо 3.3 и вдвое
больше посторонних тревог на полигоне. Смешивание полезно в другом качестве —
как **тихий режим**: при штатном пороге оно опускает ложные до 2.5 на км и
0.016 на кадр, отдавая дальность (P@100 0.31 → 0.23). Оно же — страховка на
случай, когда предмет не похож ни на что из каталога: ручная формула
продолжает работать по геометрии.
**Понижение порога работает, но дорого.** 0.50 → 0.40 добавляет 0.03 к P@100
человека и 0.07 ящику, а платит 3.3 → 5.3 ложных трека. Дальше кривая только
круче: 0.30 стоит 7.8, 0.22 — 9.8, то есть хуже, чем было без считывания
вообще. Оставлено пресетом, а не умолчанием.
**Два подтверждения вместо трёх оказались бесплатными.** Ложные не изменились
(3.3 на км, 4.44 → 4.52 % кадров), посторонние тревоги на полигоне не
изменились тоже (0.030 на кадр), а обнаружение выросло: реальный объект
98.9 → **99.5 %**, рабочая дальность человека сидя 20 → **80 м**, чемодана
«как есть» 20 → **62 м**. Причина видна из данных: на 60…100 м предмет
среднего размера виден через кадр, и третьего подтверждения приходится ждать
дольше, чем он успевает приблизиться. Это стало умолчанием.
Строки пресетов измерены при трёх подтверждениях — прежнем умолчании. В
базовой точке переход на два ложных не изменил, но для пресетов это отдельно
не перемерялось.
**Чего порогами не изменить.** P@150 равна нулю в любой конфигурации. Это не
алгоритм: прямая видимость в этих тоннелях 121–167 м, а прирельсовая зона за
80–90 м не наблюдается вовсе — луч скользит по полотну (п. 3 и п. 7.3).
Потолок здесь геометрический.
---
## 12. Воронка потерь: где именно пропадает видимый предмет
Кривая дальности говорит, сколько мы теряем, но не где. Поэтому полигон
инструментирован тремя ступенями: попали ли лучи в предмет (видимость),
появился ли кандидат, собрался ли трек, прошло ли решение. Считается доля
от **видимых** наблюдений, то есть от тех, где предмет физически освещён
лучами и претензии к алгоритму осмысленны.
Человек стоя, конфигурация по умолчанию:
| полоса | видимых | кандидат | трек | решение | нет кандидата | трек без решения |
|---|---:|---:|---:|---:|---:|---:|
| 6–30 м | 396 | 0.82 | 0.89 | 0.82 | 0.18 | 0.06 |
| 30–50 м | 184 | 0.55 | 0.64 | 0.59 | **0.45** | 0.05 |
| 50–70 м | 147 | 0.75 | 0.82 | 0.81 | 0.25 | 0.01 |
| 70–90 м | 131 | 0.73 | 0.68 | 0.62 | 0.27 | 0.06 |
| 90–120 м | 177 | 0.66 | 0.54 | 0.24 | 0.34 | **0.31** |
| 120–160 м | 251 | 0.62 | 0.37 | 0.00 | 0.38 | **0.37** |
Столбец «трек» бывает выше «кандидата»: трек живёт между кадрами и
переживает кадр без кандидата — это не ошибка таблицы, а работа накопителя.
Видны **две разные** болезни, и лечатся они по-разному.
### 12.1. Ближняя: 30–50 м, кандидат не рождается
Худшая полоса во всём диапазоне — хуже, чем 120–160 м. Причина известна:
разрез компоненты по контрасту запрещён ближе 55 м (`split_near`), и предмет,
слипшийся со стеной, уходит вместе с ней. Запрет стоял потому, что снятие
стоило слишком дорого по ложным. Обученное считывание удвоило бюджет, и
проверку повторили:
| не резать ближе | ложных треков/км | кадров с тревогой |
|---|---:|---:|
| **55 м (принято)** | **3.3** | **4.5 %** |
| 40 м | 6.3 | 7.5 % |
| 30 м | 7.3 | 9.6 % |
| 20 м | 8.3 | 12.1 % |
| резать везде | 8.3 | 12.7 % |
Весь выигрыш считывания съедается уже к 30 м, а на 20 м мы хуже, чем были
без модели вообще. Запрет оставлен. Дополнительный довод: при служебном
торможении с 60 км/ч тормозной путь с реакцией около 160 м, то есть полоса
30–50 м для полной остановки поздняя в любом случае — платить за неё
ложными тревогами на всей дистанции невыгодно.
### 12.2. Дальняя: за 90 м трек есть, решения нет
Здесь теряется 0.31 и 0.37 видимых наблюдений уже **после** того, как трек
собран. Первая догадка — порог. Проверена и отвергнута: порог тревоги,
линейно опущенный с 0.5 до 0.3 начиная с 90 м, не изменил ни ложных
(3.3 на км), ни обнаружения (P@100 = 0.31, решения за 120 м по-прежнему 0).
Порог новизны тоже ни при чём: с нулём вместо 0.10 всё то же самое.
Настоящая причина считается на бумаге. Улика растёт на `gain · качество` за
попадание и падает на `leak` за промах, при `gain` 0.34 и `leak` 0.12. При
доле попаданий p улика не убывает только если `p·gain·w > (1-p)·leak`. На
120–160 м кандидат появляется в 62 % наблюдений, откуда порог по качеству
**w > 0.22** — столько далёкое наблюдение из четырёх лучей не даёт никогда.
Поэтому улика далёкого трека не «чуть ниже порога», а колеблется около нуля,
и снижать порог бессмысленно.
Промах на 150 м, однако, не свидетельство отсутствия предмета, а свойство
решётки: там предмет виден через кадр. Ровно по этой причине число лучей уже
нормируется на ожидаемое для этой дальности (`_quality`), и промах надо
нормировать так же. Утечка сделана зависящей от дальности (`leak_far`).
Цена замерена сразу на двух сценах:
| утечка на дальнем краю | ложных/км, память обучена | то же на новой линии |
|---|---:|---:|
| 0.12 (было) | 3.3 | 20.6 |
| 0.06 | 3.3 | 21.6 |
| 0.03 | 3.3 | 21.6 |
| 0.01 | 3.3 | 21.6 |
Цена плоская: появление долгоживущих далёких треков стоит примерно одного
лишнего трека на километр на незнакомой линии и ничего — на знакомой.
### 12.3. Из чего на самом деле состоит улика далёкого предмета
Три ручки подряд ничего не дали, и это был знак, что ограничитель другой.
Поэтому вместо четвёртой развёртки — прямая трассировка: человек стоя
вставлен на 150 м, 178 кадров, и на каждом выписаны все множители улики.
| величина | медиана | p90 |
|---|---:|---:|
| лучей в предмете | 6 | 8 |
| кандидат найден | 164 из 178 кадров | |
| оценка MBON | **0.078** | 0.493 |
| новизна | **0.153** | 0.221 |
| качество улики (произведение) | **0.005** | 0.034 |
Новый трек выживает, только если `gain · качество > 0.02`, то есть при
качестве выше 0.059. Таких наблюдений **5.5 %**. Материал, стало быть, есть
— кандидат находится в 92 % кадров, — а улику копить нечем. Ни порогом, ни
утечкой это не лечится, потому что ноль на любой множитель остаётся нулём.
Дальше видно, какой из двух множителей виноват. На 120–185 м, 299
кандидатов, из них 231 — предмет:
| множитель | предмет | обстановка | AUC |
|---|---:|---:|---:|
| новизна | 0.150 | 0.199 | **0.293** |
| оценка MBON | 0.051 | 0.003 | 0.743 |
**Новизна на дальности перевёрнута.** Настоящий предмет выглядит более
знакомым, чем окружающая обстановка: на шести лучах дескриптор вырождается,
и память узнаёт в предмете любую далёкую конструкцию. Мы этим множителем
гасили собственный сигнал.
**Модель различает, но её ответ раздавлен редкостью.** Доля предметов среди
кандидатов падает с 42 % на 0–30 м до 3.1 % за 160 м, и калиброванная
апостериорная вероятность обязана падать вместе с ней — даже когда улика
по существу та же. Различие при этом сохраняется: 0.051 против 0.003, в
семнадцать раз.
Отсюда две правки, обе — нормировка на то, что достижимо на этой дальности,
ровно как уже сделано для числа лучей в `_quality`:
* **гашение знакомости с дальностью** (`nov_fade_from`): за 90 м вклад
новизны плавно сходит к нулю, вблизи остаётся как был;
* **поправка на распространённость** (`mbon_prior_from`): из логита модели
вычитается типичный для этой полосы, то есть апостериорная вероятность
превращается в отношение правдоподобий.
Поправка на распространённость **сама по себе не дала ничего**: ни ложных
(3.3 на км), ни обнаружения (P@100 = 0.31, за 120 м ноль), воронка не
сдвинулась ни на сотую. Это ожидаемо и поучительно: пока второй множитель
обнуляет произведение, правильная правка первого бесполезна.
Гашение знакомости дало главный результат дня:
| человек стоя | было | стало |
|---|---:|---:|
| P@50 | 0.70 | 0.70 |
| P@100 | 0.31 | **0.48** |
| P@150 | 0.00 | **0.32** |
| решений в полосе 120–160 м | 0.00 | **0.40** |
| ложных треков/км, память обучена | 3.3 | 5.6 |
| то же на новой линии | 21.6 | **21.6** |
| посторонних тревог на кадр, полигон | 0.030 | 0.047 |
Впервые предмет вообще обнаруживается на 150 м. Цена — полтора лишних
ложных трека на километр **на знакомой линии**; на незнакомой правка
бесплатна, потому что отказываться там не от чего. Приватный тест — это
именно незнакомая линия.
Отдельно про характер цены: лишние далёкие треки дают **предупреждения, а не
торможение**. Экстренный уровень требует `d ≤ max(тормозной путь, 25 м)`
(`descending.py`), поэтому далёкий ложный трек физически не может вызвать
экстренное торможение. Мы платим предупреждениями за дальность обнаружения —
для движущегося поезда это правильная сторона размена.
### 12.4. Что из этого принято
Обе правки и зависящая от дальности утечка проверены порознь и вместе,
каждая на двух сценах — линия с обученной памятью и первый проезд по
незнакомой:
| конфигурация | ложных/км, память | то же, новая линия | P@100 | P@150 | тревог на кадр |
|---|---:|---:|---:|---:|---:|
| как было | 3.3 | 20.6 | 0.31 | 0.00 | 0.030 |
| утечка 0.03 | 3.3 | 21.6 | 0.33 | 0.00 | 0.030 |
| поправка на распространённость | 3.3 | 20.6 | 0.31 | 0.00 | 0.030 |
| **гашение знакомости с 90 м** | **5.6** | **20.6** | **0.48** | **0.32** | 0.046 |
| гашение + утечка | 5.6 | 21.6 | 0.48 | 0.32 | 0.047 |
| гашение + утечка + поправка | 5.6 | 21.6 | 0.48 | 0.32 | 0.048 |
**Принято одно гашение знакомости.** Утечка добавляет к нему сотые доли
(человек сидя P@100 0.44 → 0.48, решений в полосе 120–160 м 0.37 → 0.40) и
стоит одного лишнего ложного трека на километр ровно на той сцене, которая
соответствует приватному тесту. Поправка на распространённость не даёт
ничего ни отдельно, ни в связке — таблицы обнаружения совпадают до сотой.
Обе отвергнутые правки **оставлены в коде выключенными** (`leak_far`,
`mbon_prior_from`, `warn_far`): они верны по сути, измеримы одним ключом и
могут пригодиться на линии с другой статистикой. Значения по умолчанию
выключают их полностью.
*Позднее:* `warn_far` перемерен на конфигурации после п. 12.3 и 12.5 и
**принят** — P@150 0.33 → 0.37 без потерь ближе. Почему отказ устарел —
п. 15.9.
Итоговая конфигурация и её цена:
| | без гашения | **принято** |
|---|---:|---:|
| реальный объект на 55 м | 99.5 % | **99.5 %** |
| P@50, человек стоя | 0.70 | **0.70** |
| P@100 | 0.31 | **0.48** |
| P@150 | 0.00 | **0.32** |
| ложных треков/км, знакомая линия | 3.3 | 5.6 |
| кадров с тревогой, знакомая линия | 4.5 % | 9.4 % |
| ложных треков/км, новая линия | 20.6 | **20.6** |
| задержка кадра, медиана | 33 мс | **32 мс** |
Размен честный и его надо называть вслух: на **знакомой** линии доля кадров
с тревогой удваивается. Взамен появляется обнаружение на 150 м, которого не
было вовсе, а на **незнакомой** линии — там, где будет приватный тест, —
плата нулевая. Кому дороже тишина, чем дальность, ставит `nov_fade_from: 0`
и возвращается к 3.3 трека на километр.
### 12.5. Порог по числу лучей не должен быть одинаковым на всех дальностях
После того как знакомость перестала гасить далёкую улику, в воронке осталась
вторая дыра: за 120 м **0.38 видимых наблюдений не дают кандидата вовсе**.
Причина прямая — кандидатом считается компонента из четырёх лучей и больше,
а на 150 м у человека их медианно шесть, и кадры с тремя выпадают целиком.
Разрешить тройки везде — плохая замена. Вблизи обрывок из трёх лучей это
кусок чего-то большего, он дробит настоящий предмет и растаскивает
сопоставление между треками:
| человек стоя | 4 луча | 3 луча везде | **4 вблизи, 3 за 90 м** |
|---|---:|---:|---:|
| P@50 | **0.70** | 0.64 | **0.70** |
| P@100 | 0.48 | 0.48 | 0.48 |
| P@150 | 0.32 | 0.37 | **0.37** |
| человек сидя, рабочая дальность | 80 м | 100 м | **100 м** |
| человек сидя, P@100 | 0.44 | 0.53 | **0.53** |
| ящик, P@100 | 0.31 | 0.36 | **0.36** |
| ложных треков/км, знакомая линия | 5.6 | 6.3 | **5.6** |
| то же, незнакомая линия | 20.6 | 21.3 | **20.6** |
| кадров с тревогой, знакомая линия | 9.4 % | 11.4 % | 10.7 % |
Порог, зависящий от дальности, забирает **весь** дальний выигрыш тройки и не
стоит ни одного лишнего ложного трека ни на одной из двух сцен. Это третий
случай подряд, когда правильным оказалось не «подобрать значение», а
**нормировать требование на то, что физически достижимо на этой дальности**
— как уже сделано для числа лучей в весе улики и для знакомости.
Чего порог не забирает: ведро на 50 м (0.02 против 0.20 при тройке везде) и
рабочую дальность чемодана (62 м против 80 м). Это ближние эффекты, и
приходят они в комплекте с потерей 0.06 у человека стоя на 50 м — размен,
который мы не берём.
Оговорка к цифрам выше: они получены моделью, обученной на выборке с порогом
в четыре луча, то есть далёких кандидатов из трёх лучей считывание в
обучении не видело ни одного. Это **нижняя** оценка; после пересборки
выборки на итоговой конфигурации цифры в п. 11.2 и в README обновлены.
### 12.6. Пересборка выборки и итоговая конфигурация
Порог по лучам, зависящий от дальности, меняет **состав кандидатов**, а
считывание обучалось на прежнем: далёких компонент из трёх лучей в выборке
не было ни одной. Поэтому выборка пересобрана на итоговой конфигурации —
214 958 кандидатов против 185 252, предметов 20 150 (9.4 %). Прибавка почти
в 30 тысяч — это и есть далёкая мелочь, которую модель раньше не видела.
Переобучение дало AUC **0.9829** против 0.9860: задача стала труднее, потому
что в неё добавили самые бедные наблюдения. По полосам дальности
0.995 / 0.996 / 0.992 / 0.968 / 0.950 / 0.758.
Результат пересборки — **размен, а не чистый выигрыш**:
| человек стоя | модель на прежней выборке | модель на новой |
|---|---:|---:|
| рабочая дальность | 80 м | **100 м** |
| P@100 | 0.48 | **0.53** |
| P@150 | 0.37 | 0.33 |
| чемодан, P@100 | 0.13 | **0.21** |
| ложных треков/км, знакомая линия | 5.6 | 8.0 |
| то же, незнакомая линия | 20.6 | **20.3** |
Ложные на знакомой линии выросли до 8.0, и это сначала выглядело как
поражение: опора без считывания была 7.4. Но опора мерилась при **других**
настройках. При итоговых та же оценка без считывания даёт **12.9 трека на
км и 25.9 % кадров**, то есть считывание по-прежнему срезает больше трети.
Сравнивать надо равное с равным — это стоило одного лишнего прогона и
спасло от ложного вывода.
Разложение цены на знакомой линии (новая модель):
| конфигурация | ложных/км | кадров |
|---|---:|---:|
| без считывания вовсе | 12.9 | 25.9 % |
| считывание, без гашения знакомости | 3.5 | 5.0 % |
| + гашение, 4 луча везде | 6.5 | 8.7 % |
| **+ гашение, 3 луча за 90 м (принято)** | **8.0** | **11.0 %** |
Промежуточный вариант (гашение, четыре луча) измерен полностью: на человеке
стоя он неотличим (рабочая дальность 100 м, P@50 0.70, P@100 0.53,
P@150 0.32), а на всём, что меньше, — заметно хуже: человек сидя 80 м против
100 м рабочей дальности и P@100 0.44 против 0.53, ящик 0.32 против 0.38,
чемодан 0.12 против 0.21. На незнакомой линии варианты неразличимы
(20.5 и 20.3).
**Принят дальнобойный.** Упавший или сидящий человек — самый тяжёлый по
наблюдаемости и самый важный по безопасности случай, и рабочая дальность по
нему растёт с 80 до 100 м. Полтора лишних ложных трека в километре платятся
только там, где линия уже знакома; на незнакомой разницы нет.
Лестница режимов, все три измерены:
| режим | как включить | ложных/км | дальняя зона |
|---|---|---:|---|
| тихий | `nov_fade_from: 0` | 3.5 | за 90 м почти нет |
| средний | `min_rays_far: 0` | 6.5 | человек стоя есть, мелочь хуже |
| **дальнобойный (умолчание)** | — | 8.0 | полная |
### 12.7. Ближняя потеря: причина найдена, лечение отвергнуто
В воронке оставалась последняя необъяснённая дыра: на 6–30 м, где предмет
даёт сотни лучей, кандидат не появлялся в 18 % видимых наблюдений. Прямая
трассировка вставки на 6.5, 8, 10, 12, 20 и 28 м ничего не воспроизвела —
кандидат находился в **100 %** случаев (616 из 616). Ответ нашёлся в самих
данных полигона:
| смещение предмета от оси | наблюдений | без кандидата |
|---|---:|---:|
| 0.0 м | 198 | 0.01 |
| +0.9 м | 198 | **0.34** |
Хуже всего там, где есть платформа: `doubleT_platform` и
`squareT_platform_squareT_switch` по 0.27 против 0.11–0.14 у остальных.
Предмет у края габарита слипается с кромкой платформы в одну связную
компоненту и отбрасывается вместе с ней — **то же самое, что и в полосе
30–50 м**. Причина общая: разрез по контрасту запрещён ближе 55 м.
Комментарий в коде объяснял этот запрет тем, что вблизи предмет со стеной
не слипается. Замер показал, что это неверно, и объяснение исправлено.
Лечение пробовалось не «в лоб» (резать везде — уже отвергнуто в п. 12.1), а
избирательно: резать вблизи только компоненты, **слишком растянутые по
глубине**, чтобы быть предметом. Мерой служит разброс дальности внутри
компоненты: человек занимает десятки сантиметров, кромка платформы —
десятки метров. Две свёртки по меткам, доли миллисекунды.
| порог растянутости | ложных/км, знакомая | незнакомая |
|---|---:|---:|
| выключено | **8.0** | **20.3** |
| > 8 м | 10.0 | 25.2 |
| > 4 м | 12.0 | — |
| > 2 м | 12.0 | — |
Воронка при самом строгом пороге (решений от видимых наблюдений):
| полоса | без разреза | с разрезом |
|---|---:|---:|
| 6–30 м | 0.83 | **0.90** |
| 30–50 м | 0.59 | **0.68** |
| 50–70 м | **0.81** | 0.71 |
| 70–90 м | **0.62** | 0.54 |
**Отвергнуто.** Ближние полосы вылечились, но сломались средние: человек
стоя P@50 0.70 → 0.67, рабочая дальность «как есть» 80 → 62 м, посторонних
тревог на кадр 0.071 → 0.102. Механизм тот же, что и у тройки лучей вблизи:
разрез на 50–55 м дробит предмет на конкурирующие обрывки, и настоящий трек
проигрывает им в торможении по соседству.
Итог размена: мы вылечили бы полосу, которая при служебном торможении с
60 км/ч и так за тормозным путём, сломав ту, что внутри него, и заплатили бы
четвертью ложных треков на обеих сценах. Механизм оставлен в коде выключенным
(`near_long: 0`): на линии без платформ у самого габарита он может оказаться
дешевле — но проверять это надо замером на той линии.
## 13. Ось пути вдаль: устойчивость, горизонт и чего полигон не мерит
В общем чате организаторов участники пишут, что не могут устойчиво вести
путь с габаритом за 100 м — «облако очень разрежённое». Посылка верна:
рельсы на 100 м дают единицы точек. Мы их и не прослеживаем — ось берётся
из **дрейфа центра сечения тоннеля** с дальностью, а свод даёт тысячи точек
там, где рельсы не дают ничего.
Замер устойчивости: сдвиг оси в одной и той же точке пути между соседними
кадрами, 160 кадров на бэг.
| бэг | ось видна до | скачок @100 м, медиана/p90 | @150 м | ошибка касательной @150 |
|---|---:|---:|---:|---:|
| `doubleT_platform` | 86 м | 0.07 / 0.35 м | 0.10 / 0.67 | 0.16 м |
| `roundT_doubleT` | 107 м | 0.08 / 0.33 | 0.18 / 0.65 | 0.30 |
| `roundT_pressureGate_roundT` | 77 м | 0.07 / 0.31 | 0.11 / 0.85 | 0.51 |
| `roundT_squareT_pressureGate_squareT` | 69 м | 0.01 / 0.04 | 0.02 / 0.09 | 0.33 |
| `squareT_platform_squareT_switch` | 62 м | 0.04 / 0.28 | 0.05 / 0.57 | 0.91 |
На 100 м ось **не перескакивает**: худший p90 — 0.35 м при полуширине
габарита 1.6 м. Настоящее ограничение другое: сечение наблюдается только до
62–107 м, дальше ось продолжается касательной, и касательная расходится с
кривой до 1.19 м на 150 м.
### 13.1. Почему не продолжать кривизну
Напрашивается продолжать за горизонт не касательную, а измеренную кривизну:
железнодорожная кривая — длинная дуга постоянного радиуса. Проверено:
| бэг | разброс c₂ по кадрам (p10…p90) | расхождение на 150 м |
|---|---:|---:|
| `doubleT_obstacle` | 1.4·10⁻⁴ | 0.77 м |
| `doubleT_platform` | 3.9·10⁻⁴ | 0.70 м |
| `roundT_doubleT` | 10.3·10⁻⁴ | 1.87 м |
| `roundT_pressureGate_roundT` | 7.7·10⁻⁴ | **5.03 м** |
| `roundT_squareT_pressureGate_squareT` | 0.9·10⁻⁴ | 0.58 м |
| `squareT_platform_squareT_switch` | 8.0·10⁻⁴ | **6.25 м** |
Кривизна оценивается на коротком плече (до 62–107 м) и гуляет между кадрами;
её продолжение до 150 м даёт до 6 м расхождения против 0.16–1.19 м у
касательной. Нынешнее поведение `Corridor.centre` — линейное продолжение по
касательной — **лучшее из двух**, и теперь это подтверждено замером, а не
только рассуждением в комментарии.
### 13.2. Чего размеченный полигон не мерит
Предмет вставляется в точку `corridor.centre(d) + смещение`, то есть **на
нашу же оценку оси**. Поэтому полигон по построению не может обнаружить
ошибку оси: где бы мы ни считали путь, предмет окажется там же.
В эксплуатации это не так. Настоящий предмет стоит на настоящем пути, и на
150 м он может оказаться в 0.16–1.19 м от того места, где мы путь считаем —
при полуширине габарита 1.6 м этого хватает, чтобы предмет у края габарита
выпал за него. Дальние цифры полигона поэтому **оптимистичны по этой
причине**, и честно это назвать: не из-за алгоритма обнаружения, а из-за
того, что дальше 62–107 м мы путь не наблюдаем, а продолжаем.
Закрыть это можно только видимостью: накопление по кадрам не поможет,
потому что за горизонтом прямой видимости данных нет ни в одном кадре.
### 12.8. Где именно знакомость перестаёт работать
Граница гашения (90 м) была выбрана по одному замеру на полосе 120–185 м.
Проверка по всем полосам, два бэга, вставленный человек против окружающих
кандидатов:
| полоса | наблюдений | знакомость, AUC | модель, AUC |
|---|---:|---:|---:|
| 35–55 м | 484 | 0.953 | 0.966 |
| 55–75 м | 1068 | 0.765 | 0.783 |
| 75–95 м | 1143 | 0.741 | 0.778 |
| 95–120 м | 793 | **0.621** | 0.758 |
| 120–150 м | 569 | **0.376** | 0.734 |
| 150–200 м | 293 | **0.352** | 0.712 |
Граница подтвердилась: до 95 м знакомость ещё различает (0.74), дальше
проваливается и за 120 м переворачивается. Обученное считывание держит
0.71–0.78 на всей дистанции — оно и остаётся единственным работающим
признаком в дальней зоне.
Отсюда напрашивалось гасить знакомость **быстрее**: сейчас её вклад
обнуляется только к 160 м, то есть на 120 м остаётся 57 % влияния признака,
который там уже перевёрнут. Проверено — и отвергнуто:
| вклад обнуляется к | ложных/км, знакомая | кадров | незнакомая |
|---|---:|---:|---:|
| **160 м (принято)** | **8.0** | **11.0 %** | 20.3 |
| 140 м | 8.0 | 13.8 % | 20.3 |
| 120 м | 9.5 | 16.4 % | 20.3 |
| 105 м | 9.5 | 17.2 % | 20.3 |
Урок тут методический. AUC мерит **ранжирование**: 0.376 означает, что среди
далёких кандидатов предмет получает новизну ниже, чем обстановка. Но в весе
улики знакомость стоит **множителем**, и, ранжируя неверно, она продолжает
давить обстановку с высокой новизной. Для решения важно второе, а не первое —
поэтому выбирать параметр надо по сквозному замеру, а не по AUC признака,
каким бы убедительным он ни выглядел.
На незнакомой линии скорость гашения не влияет вовсе (20.3 во всех четырёх
случаях): там памяти нет, и гасить нечего.
### 12.9. Оговорка о подборе порогов
Память тоннеля и считывание MBON проверяются leave-one-bag-out. Для **порогов**
(с какой дальности гасить знакомость и как быстро, сколько лучей образуют
кандидата вблизи и вдали, где разрешён разрез по контрасту) отдельной
отложенной выборки нет: они выбраны на тех же пяти бэгах, на которых и
измерены. Это надо называть вслух.
Что смягчает: каждый порог выбран по **независимому** замеру, а не перебором
ради лучшей итоговой цифры. Граница гашения — по разделяющей способности
знакомости по полосам (п. 12.8). Порог по лучам — по тому, сколько лучей
предмет физически даёт на этой дальности (п. 12.5). Запрет резать вблизи —
по тому, где предмет слипается с фоном (п. 12.7). Порогов немного, каждый
имеет физический смысл, и отвергнутых значений больше, чем принятых.
Что не смягчает ничего: на приватном тесте будет другая линия, и сами пороги
там могут оказаться не оптимальными. Поэтому все они вынесены в `Params`
одним местом и меняются без правки кода.
---
## 14. Обобщение на форму, которой не было в обучении
На приватном тесте почти наверняка окажется предмет, которого нет в нашем
каталоге из девяти форм. Вопрос, выучило ли считывание **геометрию предмета**
или шесть конкретных силуэтов, решается замером: обучить без одного класса и
проверить на нём.
Из обучающей выборки выбрасываются только **предметы** этого вида; вся
обстановка остаётся. Проверка — на предметах этого вида против всей
обстановки.
| предмет | кандидатов | AUC, класс в обучении | AUC, класс исключён | потеря |
|---|---:|---:|---:|---:|
| бутылка | 312 | 0.999 | 0.984 | −0.016 |
| ведро | 2 091 | 0.999 | 0.997 | −0.002 |
| человек сидя | 4 479 | 0.996 | 0.993 | −0.003 |
| человек стоя | 5 668 | 0.993 | **0.965** | −0.028 |
| чемодан | 3 529 | 0.995 | 0.993 | −0.002 |
| ящик | 4 071 | 0.997 | 0.994 | −0.002 |
Потеря не превышает **0.028 AUC**, а обычно составляет 0.002–0.003.
Считывание опирается не на каталог, а на общую геометрию: предмет целиком
помещается в габарит, компактен вдоль пути, стоит на полотне и не тянется
дальше, как лоток или стена.
**Что это НЕ доказывает.** Все шесть форм собраны из одних примитивов
(параллелепипед, цилиндр, шар) и ставятся одинаково — на путь, вертикально,
без наклона. Это устойчивость внутри семейства компактных предметов на пути,
а не к чему угодно. Каска, камень и кабель в таблицу не попали: они дают
меньше двух лучей и кандидатов не образуют вовсе — об этом п. 9.
### 14.1. Почему не стали добавлять случайные формы
В [SynRailObs](https://arxiv.org/abs/2505.10784) — синтетическом датасете
препятствий для железной дороги — ровно эта проблема решается иначе: авторы
генерируют случайные полигоны, заливают их случайными текстурами и учат
отдельный класс «неизвестное препятствие». Приём напрашивался и у нас:
добавить в каталог составные тела случайных пропорций.
Замер выше показал, что это не нужно: привязки к каталогу нет, и добавлять
формы было бы решением несуществующей задачи. Полтора часа работы сэкономлены
одним прогоном на двадцать минут.
Попутно та же статья даёт точку сравнения по дальности. Их детекторы
(YOLOv5-m, nanodet, Faster-RCNN, RT-DETR), обученные с учителем на специально
сделанном датасете, на **открытом пути** дают mAP 72/69/62/52/44 на полосах
0–15, 15–30, 30–45, 45–60 и 60–75 м, и собственный вывод авторов — надёжная
работа в диапазоне **0–45 м**; на 70 м препятствие занимает меньше 20
пикселей. Сравнивать напрямую нельзя (mAP — не наша вероятность обнаружения в
кадре, открытый путь — не тоннель), но масштаб заявлений в поле понятен.
И отдельно: о ложных тревогах в той работе не сказано **ничего** — ни одного
упоминания false positive или false alarm. У нас они измерены на двух сценах
и выражены в треках на километр пути.
---
## 15. Решение по треку, а не по кадру
Воронка потерь (п. 12) разделила потери на две болезни, и вторая до сих пор не
лечилась: на 90…160 м трек **заводится, а решения не даёт**. В базовой настройке
это 0.20 и 0.24 от видимых наблюдений — больше, чем теряется на всех остальных
ступенях вместе.
Порогом это не чинится, и это уже измерено (п. 12.4): 0.5 → 0.3 не меняет
ничего, потому что улика у далёкого трека не «чуть ниже порога», а около нуля.
Значит, дело не в пороге, а в том, **чем** мы меряем трек.
### 15.1. Улика суммируется и упирается в потолок
Улика — это `min(1, Σ gain·w)` с утечкой за промахи. Два её свойства работают
против нас:
* она **суммируется**, то есть отвечает на вопрос «давно ли я на это смотрю», а
не «на что именно я смотрю»;
* она **обрезана единицей**, и у всего, что наблюдается дольше пары секунд, она
просто равна 1.
Второе проверяется не на синтетике, а на единственной записи с настоящим
объектом — `doubleT_obstacle`, 753 наблюдения трека, из них 188 приходится на
реальный объект 0.67 × 1.35 м на 55 м
(`tools/check_obstacle.py --tracks --memory ... --mbon artifacts/mbon_folds/mbon_roundT_doubleT.npz`):
| признак | объект | ложные треки | AUC |
|---|---|---|---|
| **улика** | **1.000** | **1.000** | **0.624** |
| средний вес наблюдения `w_mean` | 0.998 | 0.269 | 1.000 |
| средний отсчёт считывания `p_mean` | 0.998 | 0.582 | 1.000 |
| максимум отсчёта `p_max` | 1.000 | 0.782 | 1.000 |
| лучей к ожидаемым, в среднем | 6.483 | 1.384 | 1.000 |
| возраст трека, кадров | 94.5 | 52.0 | 0.679 |
Улика различает предмет и ложные треки с AUC 0.624 — чуть лучше монетки, — и
не потому, что сигнала нет, а потому что и у того, и у другого она упёрлась в
потолок. **Тот самый вес, из которого улика складывается, различает их
идеально.** Сумма потеряла то, что было в слагаемых.
**Где именно это бьёт — оговорка.** `doubleT_obstacle` записан со **стоящего**
поезда, и там наблюдений у каждого трека сотни, так что потолка достигают и
предмет, и обстановка. На полигоне поезд едет, ложный трек столько не живёт, и
картина другая: улика на потолке у 79 % наблюдений предмета в полосе 0…30 м и
лишь у 1.5 % ложных, на 80…110 м — 31 % против 3.4 %.
То есть насыщение само по себе ложных тревог не создаёт. Оно **обесценивает
улику как меру**: у предмета она перестаёт расти, и весь запас, который он
заработал качеством наблюдений, пропадает — вместо «этот трек вчетверо
убедительнее» остаётся «оба по единице». Хуже всего это там, где поезд стоит
или ползёт, то есть ровно в ситуации, когда препятствие опаснее всего.
Отсюда и решение: сравнивать с порогом не улику, а её геометрическую смесь со
средним весом наблюдения, `w_mean^b · улика^(1−b)`. При `b = 0` всё ровно как
раньше, так что размен меряется, а не объявляется.
Что смешивание делает с отсчётом, посчитано прямо по выборке. При `b = 0.3`
через порог 0.5 уходят вниз 180 предметных наблюдений и 668 ложных — на одно
потерянное обнаружение три и семь десятых убранных ложных. При `b = 0.2`
соотношение лучше: 73 против 348, то есть 4.8. При `b = 0.5` оно рушится до
1.3, и это то же колено, что видно в таблице п. 15.6.
Смешивание при этом **не только давит**: у 21 % наблюдений отсчёт растёт — это
молодые треки с хорошими наблюдениями, у которых улика ещё мала. Среди ложных
таких даже больше (25.5 % против 14.6 % среди предметных), и на незнакомой
линии, где память ничего не подавляет, это выходит боком (п. 15.8).
### 15.2. Выборка по трекам
Полигон научили попутно выкладывать описание каждого живого трека на каждом
кадре с меткой «это вставленный предмет» (`make_benchmark.py --tracks-out`).
Собирается это внутри полигона нарочно: та же память тоннеля, то же покадровое
считывание, те же пороги — обученное на другой обстановке считывание нечего и
мерить. Вышло 10 819 наблюдений трека, после отбора «не меньше двух
подтверждений» (столько же требует `descending.py`) — 9 299, предметных 42.1 %.
Что даёт каждый признак сам по себе, по полосам дальности:
| полоса | набл. | улика | `p_mean` | `p_max` | `w_mean` | `−s_std` |
|---|---|---|---|---|---|---|
| 0–30 м | 2208 | 0.962 | 0.954 | 0.956 | 0.946 | 0.876 |
| 30–55 м | 1216 | 0.892 | **0.989** | 0.924 | 0.965 | 0.769 |
| 55–80 м | 1474 | 0.983 | 0.949 | **0.994** | 0.914 | 0.562 |
| 80–110 м | 1777 | 0.688 | 0.801 | **0.845** | 0.832 | 0.459 |
| 110–160 м | 1977 | 0.724 | 0.849 | 0.815 | **0.900** | 0.694 |
| 160–230 м | 647 | 0.306 | 0.218 | 0.218 | 0.218 | **0.869** |
| всё | 9299 | 0.865 | 0.895 | 0.890 | 0.880 | 0.670 |
Главное здесь — полосы 80…160 м, ровно те, где воронка и теряет: улика 0.688 и
0.724, а история наблюдений 0.832 и 0.900.
### 15.3. Почему за 160 м всё переворачивается
В последней полосе AUC 0.218…0.306 — то есть признаки указывают **в обратную
сторону**. Разбор показывает, что дело не в признаках:
| | наблюдений | возраст | подтверждений | улика | `p_mean` |
|---|---|---|---|---|---|
| настоящий предмет | 114 | 2.0 | 2.0 | 0.094 | 0.104 |
| ложные треки | 533 | 5.0 | 4.0 | 0.234 | 0.351 |
На самом краю дальности предмет — **всегда самое молодое, что есть в сцене**:
он только что вошёл в зону видимости, а всё, что уже ведётся, ведётся дольше.
Любая величина, растущая со временем наблюдения, там обязана ранжировать
наоборот. Это не лечится ни моделью, ни порогом; это граница метода, и её надо
называть, а не прятать.
### 15.4. Привязка к месту: признак, который врёт на синтетике
Самым заманчивым признаком казался `s_std` — разброс места в тоннеле, которое
подразумевает наблюдение (пройденный путь плюс измеренная дальность). У
неподвижного предмета оно обязано быть постоянным. На полигоне признак и
выглядел блестяще: за 160 м AUC 0.869 при том, что все остальные там
перевёрнуты, разброс 0.01 м у предмета против 1.08 м у ложных треков.
На настоящем объекте он **перевёрнут**: 0.365 м у предмета против 0.013 м у
ложных треков, AUC 0.223.
Причина в устройстве вставки. Синтетический предмет ставится на дальность
`d_start − s(t)`, где `s(t)` — тот же самый проинтегрированный путь, который
двигает мировую координату треков. Ошибка оценки собственного движения входит в
обе величины и сокращается: у вставленного предмета привязка не дрожит **по
построению**. У настоящего объекта она дрожит на реальную ошибку одометрии, а
у ложных треков, прилипших к геометрии тоннеля, — не дрожит вовсе.
Это тот же класс ошибки, что артефакт яркости (п. 11.1), и найден он тем же
способом: цифру с полигона проверили на единственной реальной метке, какая
есть. Оба разброса (`s_std`, `u_std`) выброшены из обучения; в дескрипторе они
оставлены нарочно, чтобы проверку можно было повторить.
### 15.5. Обученное считывание по треку — отрицательный результат
Сделали то же, что с покадровым считыванием: те же клетки Кеньона, то же
торможение APL, один выход, вход — 21 признак трека
(`flyguard/track_readout.py`, `tools/train_track.py`). Проверка —
leave-one-bag-out.
| ёмкость | AUC |
|---|---|
| 500 клеток | 0.8713 |
| 1000 | 0.8641 |
| 2000 | 0.8651 |
| 4000 | 0.8730 |
| 4000, без обоих разбросов | 0.8801 |
Для сравнения: улика сама по себе даёт 0.865, `w_mean` — 0.880, `p_mean` —
0.895, произведение `p_mean · улика` — 0.904.
**Модель не обгоняет ни один из признаков, которые ей же и скормили.** По
полосам она выигрывает у улики на 30…55 и 80…110 м и проигрывает на 55…80 и
за 160 м. Причина понятна и её стоит назвать: наблюдений 9 299, но они не
независимы — это сотни кадров подряд по одному и тому же треку. Независимых
треков тут сотни, а не тысячи, и разрежённому коду на такой выборке учиться
нечему.
Вывод: считывание по трекам остаётся в репозитории как инструмент замера, в
решении **не участвует** (`track_score` по умолчанию `w_mean`, модель
включается только явным `--track-score model`). Цена вопроса — один вечер и
28 секунд обучения; сэкономлено то, что модель не поехала в продукт.
### 15.6. Размен: что стоит смешивание
Полигон, `человек_стоя`, память тоннеля на месте, считывание по складкам.
«Посторонних» — тревоги, не совпавшие с вставленным предметом, на 14 004
наблюдения.
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---|
| улика одна (`track_blend = 0`) | 0.70 | 0.53 | 0.33 | 999 |
| смешивание 0.3 | 0.71 | 0.43 | 0.26 | **354** |
| смешивание 0.5 | 0.63 | 0.17 | 0.10 | 305 |
| смешивание 0.7 | 0.63 | 0.17 | 0.10 | 296 |
Колено видно сразу: за 0.3 ложные почти не падают (354 → 305 → 296), а
обнаружение рушится. Причина арифметическая, и её стоит назвать: обе величины
меньше единицы, поэтому их произведение **всегда ниже улики**, и при
неподвижном пороге смешивание работает как его повышение. Сравнивать настройки
имеет смысл только на выровненной рабочей точке — то есть опустив порог на
столько, на сколько смешивание опустило отсчёт.
Полезная доля смешивания, таким образом, мала, и весь вопрос в том, на что
потратить освободившиеся три четверти ложных тревог.
### 15.7. Жёсткий порог вместо смешивания — отрицательный результат
Смешивание `w_mean^b · улика^(1−b)` опускает отсчёт **и предмету тоже**, потому
что обе величины меньше единицы. Напрашивается вместо него порог: трек с
низким средним весом просто не рассматривать, а прошедшему не отнимать ничего.
Тем более что на реальном объекте разделение выглядит идеальным — 0.998 против
0.269.
Замер (полигон, `человек_стоя`):
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---|
| база | 0.70 | 0.53 | 0.33 | 999 |
| порог 0.40 | 0.60 | **0.03** | 0.10 | 158 |
| порог 0.55 | 0.37 | **0.00** | 0.01 | 85 |
Ложные тревоги падают в шесть и в двенадцать раз, а обнаружение исчезает.
Воронка показывает где: в полосе 90…120 м до решения доходит **0.02** от
видимых наблюдений против 0.49 в базе.
Причина — та же, что у всех остальных порогов в этой работе: **неподвижный
порог слеп к дальности**. Средний вес наблюдения сам падает с расстоянием, и
сильно: в него входит контраст к фону, который за 140 м структурно равен нулю
(п. 7.3), и вероятность считывания, которая падает вместе с числом лучей.
Порог 0.55 отсекает не «плохие треки», а «всё, что дальше семидесяти метров».
Чтобы порог тут работал, его пришлось бы нормировать на ожидаемый для этой
дальности вес — то есть сделать ровно то, что уже сделано для числа лучей
(п. 12.5), для новизны (п. 12.3) и для утечки улики. Это возможно, но
смешивание даёт тот же эффект мягко и без ещё одной калибровочной кривой,
поэтому порог оставлен выключенным (`track_gate = 0`).
### 15.8. Смешивание меняет не в ту сторону
Ложные треки на километр, обе сцены, считывание по складкам:
| настройка | знакомая линия | незнакомая линия | объект на 55 м |
|---|---:|---:|---:|
| база | 8.0 | **20.3** | 99.5 % |
| один дальний порог 0.30 | **8.0** | 21.3 | 99.5 % |
| смешивание 0.3 + дальний порог с 60 м | 6.3 | **24.8** | 99.5 % |
| смешивание 0.2 + дальний порог с 60 м | 8.0 | 24.0 | 99.5 % |
Смешивание улучшает знакомую линию и **портит незнакомую**. Это не случайность,
и механизм виден из разбора п. 15.1: смешивание не только давит — у 21 %
наблюдений оно отсчёт поднимает, и среди ложных таких больше, чем среди
предметных (25.5 % против 14.6 %). Поднимаются молодые треки с хорошими
наблюдениями и малой уликой. На знакомой линии память тоннеля такие треки уже
погасила через новизну, а на незнакомой гасить нечем — и смешивание их
вытаскивает.
Приватный тест — это незнакомая линия. Значит, смешивание отвергается:
`track_blend = 0` по умолчанию.
И это ещё не всё. Смешивание надо сравнивать не с базой, а с **тем же разменом,
полученным простым поднятием порога** — иначе мы сравниваем две разные рабочие
точки и выдаём сдвиг по кривой за улучшение кривой. Контроль:
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---:|
| смешивание 0.2 + дальний порог с 60 м | 0.71 | 0.47 | 0.37 | 817 |
| **порог 0.60 + дальний порог, без смешивания** | 0.70 | **0.49** | 0.37 | **816** |
Одинаковое число посторонних тревог, и при нём простой порог даёт обнаружение
на 100 м **лучше**. Никакой новой кривой смешивание не открывает — оно двигает
по той же, что и ключ, который у нас уже был.
Итог: `track_blend = 0`, `track_gate = 0`. Накопители на треке и считывание по
трекам остаются в коде как инструмент замера — они и дали понимание, что улика
насыщается, — но в решении не участвуют.
### 15.9. Отрицательные результаты стареют
Выигрыш в этой главе дал не считывание по трекам, ради которого всё
затевалось, а ключ, **отвергнутый тремя разделами раньше**. П. 12.2 говорит
дословно: «порог тревоги, линейно опущенный с 0.5 до 0.3 начиная с 90 м, не
изменил ни ложных (3.3 на км), ни обнаружения (P@100 = 0.31, решения за 120 м
по-прежнему 0)».
Тогда это было правдой, и причина там же, в п. 12.3: улика далёкого трека
колебалась около нуля, потому что знакомость была на той дальности перевёрнута
и входила в вес **множителем**. Опускать порог под сигналом, который равен
нулю, бессмысленно — что замер и показал.
Потом приняли гашение знакомости с 90 м (п. 12.3) и порог по лучам,
нормированный на дальность (п. 12.5). Улика далёкого трека перестала быть
нулём. Тот же самый ключ, тот же самый замер — и P@150 растёт с 0.33 до 0.37
ценой нуля ложных треков на знакомой линии и одного на незнакомой.
Отсюда правило, которое стоит дороже самой правки: **отрицательный результат
верен только для той конфигурации, в которой он получен.** Каждое принятое
изменение обесценивает часть прежних отказов, и дешёвые из них надо
перепроверять. Здесь это стоило одного прогона на четыре минуты.
Варианты той же правки, полигон, `человек_стоя`:
| настройка | P@50 | P@100 | P@150 | посторонних |
|---|---|---|---|---:|
| база | 0.70 | 0.53 | 0.33 | 999 |
| **порог 0.30 с 90 м** | 0.70 | 0.53 | **0.37** | **1091** |
| порог 0.30 с 60 м | 0.70 | 0.53 | 0.37 | 1197 |
| порог 0.20 с 90 м | 0.70 | 0.53 | 0.38 | 1181 |
| порог 0.30 + утечка 0.03 | 0.70 | **0.48** | 0.40 | 1172 |
Начинать раньше незачем: обнаружение то же, ложных больше. Опускать до 0.20 —
сотая на 150 м за восемь процентов посторонних тревог. Утечку, отвергнутую
в п. 12.4, на нынешней базе тоже перемерили: теперь она даёт P@150 0.40, но
забирает 100 м (0.53 → 0.48) и рабочую дальность 100 → 80 м — отвергнута
снова, уже по другой причине. Правило «перепроверять» не означает
«перепринимать».
Принято: `warn_far = 0.30`, `warn_far_from = 90`. Итог на всех сценах:
| | было | **стало** |
|---|---:|---:|
| P@150, человек стоя | 0.33 | **0.37** |
| P@150, человек сидя | 0.22 | **0.24** |
| P@100 / P@150, ящик | 0.38 / 0.03 | **0.40 / 0.06** |
| P@50 и P@100, человек | 0.70 / 0.53 | 0.70 / 0.53 |
| решений в полосе 90–120 м / 120–160 м | 0.49 / 0.38 | **0.51 / 0.41** |
| ложных треков на км, знакомая линия | 8.0 | **8.0** |
| то же, незнакомая линия | 20.3 | 21.3 |
| настоящий объект на 55 м | 99.5 % | **99.5 %** |
Экстренного торможения правка не касается: у него свой порог и условие
`d ≤ max(тормозной путь, 25 м)`, поэтому далёкий ложный трек физически не
может его вызвать. Мы снова платим предупреждениями за дальность — для
движущегося поезда это правильная сторона размена (как и в п. 12.3).
## 16. Работа Zhirik1337: что перенесено и что из этого принято
22.09 в командный репозиторий пришло семь коммитов Zhirik1337: вывод на
видеокарте, пять правок габарита и решения, «человек лёжа» в каталоге,
случайные сценарии полигона, выгрузка рамок, ROS-узел и Docker. Всё перенесено
в основной проект, код детектора — за флагами, выключенными по умолчанию.
Потом каждая правка мерилась отдельно и на тех же вставках. Флаги
выставляются из командной строки без правки кода:
`--set k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py`
и `check_obstacle.py`.
### 16.1. Полигон шумит сильнее, чем меняют правки
Две реализации случайности полигона при **одних и тех же** настройках (зерна
12345 и 777) расходятся на P@150 у человека стоя 0.39 против 0.32, на P@100 у
ящика 0.40 против 0.29. Большинство правок меняют цифры меньше, поэтому
сравнивать два прогона по итоговой таблице нельзя: разница окажется шумом.
Сравнение ведётся **парно**. Генератор у каждого сценария свой (п. 16.2), и от
решений конвейера он не зависит, поэтому при другой настройке на вход идут те же
кадры с теми же вставками, до луча. Разница двух прогонов — только от настройки,
и её видно по переворотам отдельных наблюдений: сколько было 0 и стало 1 и
наоборот (`tools/compare_benchmark.py`). Если перевороты идут в одну сторону,
эффект настоящий, даже когда он меньше межзернового шума.
Так перепроверен и дальний порог из п. 15.9, на обоих зёрнах: 0.50 → 0.30
переворачивает 8 и 2 наблюдения человека стоя на 130–170 м в плюс и **ни одного**
в минус. Эффект маленький, но настоящий, и правка остаётся.
### 16.2. Новый предмет сдвигал цифры всех остальных
Генератор был один на запись, и сценарии тянули из него по очереди, кадр за
кадром. Когда в каталог добавили человека лёжа, поток сдвинулся для **всех**
сценариев: цифры по ящику менялись оттого, что где-то появился новый предмет.
Это проверено прямо: со старым каталогом перенесённый код давал полигон
побайтово, с новым цифры прежних предметов разошлись.
Теперь у каждого сценария свой генератор, зерно — из имени предмета и бокового
смещения через crc32 (`scenario_rng`; встроенный `hash()` солится в каждом
процессе заново). Случайные сценарии Zhirik1337 (`--augment`) переведены на него
же. Тест `test_scenario_stream_does_not_depend_on_the_rest_of_the_catalogue`.
База с новым каталогом (порог 0.30 за 90 м, как в п. 15.9):
| предмет | рабочая дальность | P@50 | P@100 | P@150 |
|---|---:|---:|---:|---:|
| человек стоя | 80 м | 0.70 | 0.48 | 0.39 |
| человек сидя | 100 м | 0.67 | 0.52 | 0.20 |
| **человек лёжа** | 20 м | **0.27** | **0.00** | 0.00 |
| ящик | 20 м | 0.58 | 0.40 | 0.06 |
Человек лёжа виден хуже всех, кроме совсем мелочи. Воронка (п. 12) показывает
почему: на 30–50 м кандидат рождается только в 35 % наблюдений. Высота 0.30 м,
а пол габарита стоит на 0.28 м, так что в габарит попадает верхушка в два
сантиметра.
### 16.3. Пол в колее: вдали выигрыш, вблизи рельсы
Правка: между рельсами (|u| ≤ 0.85 м) нижняя граница габарита опускается с
0.28 до 0.16 м, снаружи колеи остаётся прежней. Вдали она даёт много, но
вблизи обнаружение **падает вдвое**:
| полоса | человек стоя: база → пол 0.16 | лёжа | ящик |
|---|---|---|---|
| 0–15 м | 0.93 → 0.92 | 0.70 → **0.58** | 0.75 → **0.62** |
| 15–25 м | 0.71 → **0.37** | 0.55 → **0.27** | 0.56 → **0.34** |
| 25–40 м | 0.58 → **0.42** | 0.37 → 0.35 | 0.48 → **0.35** |
| 55–70 м | 0.83 → 0.83 | 0.17 → **0.62** | 0.70 → 0.71 |
| 110–135 м | 0.43 → 0.46 | 0.00 → 0.00 | 0.30 → **0.60** |
| 160–190 м | 0.07 → **0.27** | 0.03 → 0.03 | 0.04 → **0.12** |
Рабочая дальность у всех предметов стала 8 м, то есть обнаружение
проваливается уже на втором поясе.
**Причина.** Один и тот же кадр со вставкой прогнан через оба конвейера, и там,
где обычный видит кандидата, а опущенный нет, кандидаты пересчитаны без запрета
на длину. Во всех 12 потерях на 12–45 м одной записи предмет входит в
компоненту длиной 50–140 м, которая начинается **с 4 м** — с ближней границы
габарита. У разобранных подробно её нижняя точка лежит ровно на опущенном
полу, 0.16–0.18 м: это головки рельсов. Вблизи соседние кольца ложатся на
полотно плотнее допуска по глубине, и рельс собирается в одну компоненту. Компоненту
длиннее 15 м конвейер отбрасывает как «полотно или стену под скользящим углом»
и выбрасывает вместе с ней предмет. Разделение фигуры и фона могло бы вырезать
предмет, но ближе `split_near = 55 м` оно выключено — то же слипание, что с
кромкой платформы в п. 12.7.
**Лечение** — не опускать пол ближе заданной дальности (`core_from`). Почему
дальше рельсы предмет не губят, отдельно не разбирали; замер показывает, что
с 30 м опущенный пол вреда не делает (старые складки, база → пол с 30 м):
| полоса | человек стоя | лёжа | ящик |
|---|---|---|---|
| 0–15 м | 0.93 → 0.93 | 0.70 → 0.70 | 0.75 → 0.75 |
| 15–25 м | 0.71 → 0.71 | 0.55 → 0.55 | 0.56 → 0.56 |
| 25–40 м | 0.58 → 0.58 | 0.37 → **0.50** | 0.48 → 0.49 |
| 55–70 м | 0.83 → 0.83 | 0.17 → **0.62** | 0.70 → 0.71 |
| 110–135 м | 0.43 → 0.46 | 0.00 → 0.00 | 0.30 → **0.60** |
| 160–190 м | 0.07 → **0.27** | 0.03 → 0.03 | 0.04 → **0.12** |
Где ставить порог, мерилось тремя прогонами. Ближе 25 м ни один из них ничего
не теряет: из 3 469 наблюдений всех предметов в минус не перевернулось ни одно
(в плюс 8, 6 и 0 — треки, начатые дальше порога, доживают до ближней зоны).
Различаются пороги на мелком и низком:
| порог | ведро 25–40 м | ведро 40–55 м | лёжа 25–40 м | посторонних на полигоне |
|---|---:|---:|---:|---:|
| база (пол не опущен) | 0.23 | 0.02 | 0.37 | 1196 |
| **с 30 м** | **0.49** | **0.52** | **0.50** | **1313** |
| с 40 м | 0.49 | 0.52 | 0.45 | 1333 |
| с 55 м | 0.23 | 0.29 | 0.45 | 1336 |
Порог 30 м не хуже остальных ни в одной полосе и даёт меньше посторонних.
Парно против 40 м он добавляет 7 наблюдений лежачего и 2 ящика и не теряет
ни одного.
Парно, на тех же вставках (пол 0.16 с 30 м против базы, старые складки):
| предмет | полоса | было | стало | 0→1 | 1→0 |
|---|---|---:|---:|---:|---:|
| человек стоя | 130–170 м | 0.36 | 0.50 | 37 | 0 |
| человек сидя | 130–170 м | 0.19 | 0.47 | 68 | 0 |
| человек лёжа | 50–90 м | 0.13 | 0.46 | 74 | 0 |
| ящик | 90–130 м | 0.36 | 0.56 | 46 | 0 |
| чемодан | 130–170 м | 0.01 | 0.26 | 51 | 0 |
| ведро | 50–90 м | 0.00 | 0.39 | 85 | 0 |
На всех предметах и полосах вместе +677 наблюдений и −7. Вблизи с порогом по
дальности ничего не теряется (человек стоя на 6–50 м: +1 и −0). Цена:
Со старыми моделями пол обходится в два с лишним трека на километр на
знакомой линии и в четыре на незнакомой (таблица ниже, средний столбец).
Лишние треки — не сбой, а то, что пол открыл: низкие конструкции в колее. На
записи со стрелочным переводом появились три новых трека, и один из них —
предмет высотой 0.42 м прямо между рельсами (u = −0.40 м) на 78–104 м.
Лидар его видел и раньше, но он стоял ниже пола габарита.
Считывание таких кандидатов не видело никогда: оно обучалось на выборке,
собранной при прежнем поле. Поэтому, как в п. 12.6, выборка пересобрана на
новом полу — **333 739 кандидатов против 214 958**. Прибавка почти целиком
фоновая, это и есть низкие конструкции в колее, плюс человек лёжа, которого
раньше в каталоге не было (предметов 24 651 против 20 150). Складки
переобучены с прежней ёмкостью 4 000 клеток. AUC leave-one-bag-out **0.9856**
против 0.9829 на прежней выборке (по складкам 0.980–0.992): новые фоновые
кандидаты отделяются от предметов легко.
| | база | пол 30 м, старые складки | **пол 30 м, новые складки** |
|---|---:|---:|---:|
| ложных треков/км, знакомая линия | 8.0 | 10.2 | **8.0** |
| кадров с тревогой | 13.3 % | 19.3 % | 13.8 % |
| то же, незнакомая линия | 21.3 | 25.4 | **23.5** |
| кадров с тревогой | 28.3 % | 37.4 % | 34.6 % |
| посторонних на полигоне | 1196 | 1313 | **1226** |
| настоящий объект на 55 м | 99.5 % | 99.5 % | **99.5 %** |
| задержка кадра p50 / p95, мс (один процесс) | 33–45 / 37–49 | — | 35–42 / 41–52 |
Переобученное считывание убрало всю цену на знакомой линии и половину на
незнакомой. Из выигрыша в обнаружении отдана малая часть — у лежачего P@100
0.32 → 0.22 против старых складок, остальное в пределах сотых. Против базы:
| предмет | рабочая дальность | P@50 | P@100 | P@150 |
|---|---|---|---|---|
| человек стоя | 80 → 80 м | 0.70 → 0.71 | 0.48 → 0.48 | 0.39 → **0.49** |
| человек сидя | 100 → **148 м** | 0.67 → 0.67 | 0.52 → 0.52 | 0.20 → **0.49** |
| **человек лёжа** | 20 → **32 м** | 0.27 → **0.50** | 0.00 → **0.22** | 0.00 → 0.02 |
| ящик | 20 → 20 м | 0.58 → 0.54 | 0.40 → **0.50** | 0.06 → **0.26** |
| чемодан | 62 → **80 м** | 0.56 → 0.61 | 0.25 → **0.45** | 0.00 → **0.24** |
Парно против базы, все предметы:
| полоса | наблюдений | 0→1 | 1→0 |
|---|---:|---:|---:|
| 0–15 м | 2 158 | 0 | **16** |
| 15–40 м | 2 583 | 93 | 1 |
| 40–90 м | 2 644 | 224 | 7 |
| 90–200 м | 2 746 | 368 | 9 |
Шестнадцать ближних потерь — цена именно переобучения: со старыми складками
ближе 25 м не терялось ничего. Тринадцать из них — человек стоя у края
габарита (смещение 0.9 м) на 9–15 м, на трёх записях, две из них с платформой;
ещё сидящий там же и две бутылки на оси. Кандидат и трек у человека есть,
решения нет. Отдельно причину не разбирали. По месту это тот же случай, что в
п. 12.7: предмет у края габарита вблизи, где разрез фигуры выключен.
**Принято**: `h_lo_core = 0.16`, `core_from = 30`, выборка и складки
пересобраны. Самый важный для метро случай — человек, упавший на пути, — был
виден хуже всех (P@50 = 0.27, дальше 50 м почти никогда), теперь P@50 = 0.50 и
рабочая дальность 32 м. Цена — два трека на километр только на незнакомой линии
и 16 ближних наблюдений из двух тысяч у края габарита.
### 16.4. Лежащий предмет без штрафа — при обученном считывании не действует
`lying_exempt` снимает штраф за вытянутость по пути с низкого предмета в колее
(высота до 0.40 м, длина до 2.2 м): человек лёжа вдоль пути тянется на 1.8 м и
по ручной формуле похож на кусок лотка. Замер: полигон совпал с базой до
последнего наблюдения, и с опущенным полом тоже.
Причина не в правке, а в том, куда она встроена. Множитель компактности
живёт в ручной формуле веса, а в рабочей конфигурации `mbon_blend = 1.0`: вес
наблюдения целиком берёт обученное считывание, и ручная формула входит в него
в нулевой степени (`central_complex._quality`). Правка работает только там,
где модели нет. Флаг оставлен выключенным. Лежащему человеку помогает пол
(п. 16.3). Считывание, переобученное на выборке, где лежачий уже есть, ему не
помогло: P@100 0.32 → 0.22 против старых складок (п. 16.3).
### 16.5. Габарит в кривых, вырез платформы, дальний канал
Все три мерились одинаково: полигон парно против базы (сумма переворотов по
всем полосам) и ложные треки на двух сценах со старыми моделями.
| правка | человек стоя | сидя | лёжа | ящик | чемодан | всего | ложных/км, знакомая | незнакомая |
|---|---|---|---|---|---|---|---:|---:|
| база | | | | | | | 8.0 | 21.3 |
| `k_sigma = 0.75` | +0/−1 | +19/−0 | +0/−10 | +37/−0 | +0/−5 | +56/−16 | 9.5 | 25.7 |
| `platform_filter` | +9/−5 | +17/−3 | +0/−11 | +2/−7 | +1/−0 | +35/−26 | 10.2 | 25.2 |
| `far_channel` | +24/−0 | +21/−0 | +10/−0 | +43/−0 | +15/−0 | +115/−0 | 11.0 | 28.4 |
| для сравнения: пол с 30 м | +51/−1 | +92/−0 | +135/−0 | +108/−5 | +131/−0 | **+677/−7** | 10.2 | 25.4 |
**Габарит по неопределённости оси** (`k_sigma`) расширяет коридор там, где ось
пути известна хуже, — вдали и в кривых. Ящику это помогает, лежачему и
чемодану мешает: в широком габарите к низкому предмету чаще прилипает
посторонняя компонента. Ложные треки добавляются ровно там, где габарит
расширился, — на кривых записях, с медианной дальностью около 100 м. Отвергнуто.
**Вырез платформы** (`platform_filter`) удаляет из габарита горизонтальную
полосу 1.05–1.25 м у края (|u| ≥ 1.30 м) — настил платформы. На записях с
платформой ложных треков ровно столько же (1 → 1 и 2 → 2), а весь рост
приходится на `roundT_doubleT`, где платформы нет вовсе: 1 → 4 на знакомой
линии, 8 → 12 на незнакомой. Полоса, вырезанная из стены или лотка, режет
конструкцию на верхний и нижний обрывки, и каждый выглядит компактным
предметом. На полигоне перевороты в обе стороны почти поровну, то есть это
шум. Отвергнуто.
**Дальний канал** (`far_channel`) — отдельный путь к тревоге для треков дальше
90 м с послабленными условиями. Выигрыш настоящий (+115 и ни одного минуса), но
он решает ту же задачу, что и принятый дальний порог (п. 15.9), и стоит втрое
больше: +3.0 трека на км на знакомой линии и +7.1 на незнакомой, посторонних на
полигоне 1196 → 1676. Для сравнения, пол с 30 м даёт вшестеро больше
переворотов за меньшую цену. Отвергнуто; включать его вместе с дальним
порогом — ослаблять дальний край дважды.
### 16.6. Всё вместе, как было отправлено
Все пять правок разом плюс свои пороги дальнего края (`warn_far = 0.35`,
`leak_far = 0.06`): дальние цифры самые высокие из всех прогонов (P@150 у
человека сидя 0.56, у ящика 0.37), но посторонних тревог на полигоне **2299
против 1196** — почти вдвое, — и рабочая дальность 8 м у всех предметов из-за
рельсов (п. 16.3). Парно против базы:
| полоса | наблюдений | было | стало | 0→1 | 1→0 |
|---|---:|---:|---:|---:|---:|
| 6–40 м | 2 926 | 0.63 | **0.50** | 107 | **496** |
| 40–90 м | 1 982 | 0.47 | 0.61 | 271 | 3 |
| 90–200 м | 2 684 | 0.19 | 0.38 | 531 | 0 |
Дальше 40 м всё в плюс, ближе — всё в минус. А ближняя зона — это экстренное
торможение (у него условие `d ≤ max(тормозной путь, 25 м)`, п. 15.9) и вся
работа на малой скорости, у платформы и на подходе к ней: там других
дальностей просто нет. По отдельности видно, откуда что: дальний выигрыш почти
целиком даёт пол, ближний провал — он же без порога по дальности, а из
посторонних тревог больше всего добавляет дальний канал: +480 в одиночку, пол
без порога +99, остальные — десятки.
### 16.7. Выгрузка рамок, устройство, ROS-узел
* **Рамка в кривой стояла не там.** Боковое смещение трека отсчитано от оси
пути, а шло прямо в `x` сенсора: на радиусе 1300 м рамка уезжала на 1.2 м на
55 м и на 8.6 м на 150 м — в стену. Теперь `x = u + corridor.centre(d)`.
* **Поворот рамки был зеркальным**: `yaw = −atan(наклон)` вместо `+atan`, на
150 м это 13°. Оба случая проверяет `test_export_box_follows_a_curved_track`.
* **Устройство по умолчанию — процессор.** У Zhirik1337 `device=None` означал
«видеокарта, если есть», и обучение с выводом тихо уезжали на GPU. Вывод на
видеокарте ускоряет только ламину (п. 7.4). `auto` и `cuda` работают, если их
передать явно; Docker так и делает. Ошибка по дороге: `encode(device="auto")`
отдавал строку `auto` прямо в torch, падал, и падение навсегда помечало CUDA
сломанной.
* **ROS-узел `tools/flyguard_ros2_node.py`** отдаёт конвейеру массив numpy
вместо разобранного `PointCloud2`, публикует все треки, а не решение, и у
трека нет полей рамки. Узел ведёт интеграция, он оставлен за ними
(`TEAM_OWNED` в `export_team.py`). Рабочий узел — `ros2_ws/.../node.py`.
### 16.8. Итог
| правка | решение | почему |
|---|---|---|
| пол в колее `h_lo_core = 0.16` | **принято**, дальше 30 м (`core_from`) | лежачий P@50 0.27 → 0.50, P@150 у стоя 0.39 → 0.49; без порога по дальности вблизи провал вдвое |
| выборка и складки на новом полу | **принято** | цена пола на знакомой линии 2.2 → 0 треков на км, на незнакомой 4.1 → 2.2 |
| `lying_exempt` | не действует | правит ручную формулу, а вес целиком от модели |
| `k_sigma` | отвергнуто | +56/−16 на полигоне за +1.5 и +4.4 трека на км |
| `platform_filter` | отвергнуто | на платформах ничего, в тоннеле без платформы режет стену: +2.2 и +3.9 |
| `far_channel` | отвергнуто | та же задача, что `warn_far`, втрое дороже: +3.0 и +7.1 |
| человек лёжа в каталоге | **принято** | самый важный для метро случай и самый трудный |
| генератор на сценарий | **принято** | новый предмет больше не сдвигает цифры остальных |
| выгрузка рамок `export.py` | **принято** с двумя исправлениями | рамка в кривой и знак поворота |
| вывод на видеокарте | в коде, по умолчанию процессор | ускоряет только ламину (п. 7.4) |
Всё отвергнутое осталось в коде за флагами, с ценой в этом разделе:
отрицательный результат верен только для той конфигурации, в которой получен
(п. 15.9), и перепроверка любой правки стоит одного ключа `--set`.
**Что не сделано.** Память тоннеля (грибовидное тело) обучена на кандидатах,
собранных 19.09 при прежнем полу, и низких конструкций в колее не знает: на
знакомой линии их гасит только считывание. Выборку памяти надо собирать
проходом по 90 ГБ `new_data` — это следующий шаг, если понадобится ещё
снизить ложные на знакомой линии.