Lidar_Muxa/docs/EXPERIMENTS.md

259 KiB
Raw Blame History

Эксперименты

Все числа в документе получены скриптами из tools/ на предоставленных данных и воспроизводятся командами, указанными в каждом разделе. Там, где результат оказался хуже ожидаемого, он приведён как есть.

Машина разработки: Ryzen 5 7600X, 32 ГБ, RTX 5070 Ti. Стенд жюри слабее по CPU (i7-9700E, 8 ядер, 2.6 ГГц), поэтому замеры задержки приведены с запасом и обсуждаются отдельно в разделе 7.


1. Что на самом деле в данных

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. Проверка калибровки по паспорту

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. Сколько тоннель вообще позволяет увидеть

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 м/с).

python tools/check_obstacle.py --memory artifacts/mushroom_body.npz
Метрика Значение
Попал в кандидаты 100 % кадров
Подтверждён треком 98.9 % кадров
Новизна (ответ MBON) 0.62 при фоне 0.13

5. Обобщаемость: leave-one-bag-out

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. Абляция: что именно работает

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. Скорость

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. Размеченный полигон: дальность обнаружения

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 — синтетическом датасете препятствий для железной дороги — ровно эта проблема решается иначе: авторы генерируют случайные полигоны, заливают их случайными текстурами и учат отдельный класс «неизвестное препятствие». Приём напрашивался и у нас: добавить в каталог составные тела случайных пропорций.

Замер выше показал, что это не нужно: привязки к каталогу нет, и добавлять формы было бы решением несуществующей задачи. Полтора часа работы сэкономлены одним прогоном на двадцать минут.

Попутно та же статья даёт точку сравнения по дальности. Их детекторы (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 — это следующий шаг, если понадобится ещё снизить ложные на знакомой линии. Сделано — п. 17.1.

17. После переноса: память на новом полу, край габарита, вторая половина new_data

Три хвоста п. 16, каждый мерился так же: полигон парно, ложные треки на двух сценах, настоящий объект.

17.1. Память тоннеля на новом полу

Кэш кандидатов памяти был собран 19.09, при старом полу, и низких конструкций между рельсами память не знала (п. 16.8). Пересобран целиком: шесть записей (tune_memory.py --collect-only) и весь new_data (stream_new_data.py, 39 707 кандидатов против 30 902), память переобучена.

было новая память
ложных треков/км, знакомая линия 8.0 6.5
кадров с тревогой 13.8 % 9.0 %
посторонних на полигоне 1226 1166
полигон парно +78 / −95
настоящий объект 99.5 % 99.5 %

Незнакомой линии память не касается — там её нет по построению.

При резкости считывания 1.5, принятой в п. 17.4, разница между памятями исчезает: на знакомой линии 5.7 и 5.7 ложных трека на км, полигон парно +90 / −86, на записи с настоящим объектом кадров с посторонней тревогой 80.5 % против 96.3 %. Принята новая: она собрана на том же полу габарита, что и детектор, и знает низкие конструкции между рельсами, которые детектор теперь видит.

Все ближние потери — один сценарий: на roundT_pressureGate_roundT поезд стоит в 14.1 м от сидящего человека или ящика у края габарита, рядом гермозатвор, и предмет не подтверждается: 26 кадров подряд у сидящего, 10 у ящика. Кандидат там — слипшееся пятно (≈950 лучей, высота 0.40 м), считывание в нём не уверено (p = 0.23–0.30), и держится он только на новизне: у старой памяти 0.27–0.28, у новой 0.20–0.21. При нижнем пороге новизны 0.15 вес наблюдения падает в 2.4 раза, улика 0.36–0.39 против 0.04–0.05. Случай пограничный при любой памяти, новая переводит его через край. У настоящего объекта новизна тоже ниже (медиана 0.63 → 0.51), но выше 0.50, где множитель новизны уже равен единице.

17.2. Край габарита у платформы

Шестнадцать ближних потерь после переобучения (п. 16.3) разобраны. У края габарита (0.9 м) рядом с платформой нижняя часть человека слипается с кромкой, и от кадра к кадру кандидат то слипшееся пятно (p ≈ 0.0–0.2 у любых складок), то «верх»: обрывок 0.2 × 0.2 м на высоте 1.5–1.7 м, целиком в габарите, с нулевым дефицитом до пола — по признакам навесное оборудование. Такой верх старые складки оценивали в 0.74–0.99, новые — в 0.43–0.84. Улика не добирает до порога 0.5, и решение приходит на 8.8–8.9 м вместо 10.7–17 м.

В среднем новые складки на ближних предметах почти так же уверены, как старые (отложенные записи, 0–15 м: медиана p 0.993 против 0.996, доля p ≥ 0.9 — 0.864 против 0.891), а уверенного фона на 60–120 м у них вшестеро меньше (0.1 % против 0.7 %). Отсюда и падение ложных тревог: новое считывание строже в целом и к нетипичному обрывку в частности.

Попытка лечения — положение 0.9 м в обучающей выборке: полигон его проверяет, а в выборке были только 0, ±0.6 и ±1.2 м. Выборка 466 715 кандидатов, AUC 0.9849. Край это не вылечило: человек стоя на 0–15 м +5 / −5, сидя +0 / −4. Зато считывание стало строже к фону:

было с ±0.9
ложных треков/км, незнакомая линия 23.5 21.9
то же, знакомая (новая память) 6.5 6.5
посторонних на полигоне 1226 1175
полигон парно +9 / −25
настоящий объект 99.5 % 99.5 %

На второй половине new_data (п. 17.3) — ничего: 23.9 и 23.9 ложных трека на км. Не принято.

17.3. Считывание, обученное на new_data

Пять коротких записей сняты в один день (02.09) и вместе дают чуть больше километра пути. new_data — двадцать минут непрерывной езды 17.09: 11 271 кадр в 221 шарде, 90 ГБ несжатого tar. Места на диске меньше архива, поэтому он читается кусками прямо из tar: tools/_new_data.py распаковывает по пять шардов во временный каталог и удаляет за собой.

Делить пришлось по времени, а не по кадрам: вставки в первую половину (шарды 0–109) идут в обучение, ложные тревоги меряются на второй (шарды 110–220, 3.27 км пути), которую модель не видела ни в каком виде. Памяти тоннеля в этой проверке нет, как на незнакомой линии. Выборка — прежние 333 739 кандидатов плюс 827 314 из 22 кусков первой половины, предметов 5.4 %, AUC складок 0.981.

считывание вторая половина new_data, ложных/км кадров с тревогой незнакомая линия знакомая линия
прежнее 23.9 38.5 % 23.5 6.5
с ±0.9 (п. 17.2) 23.9 39.0 % 21.9 6.5
с new_data 13.2 20.2 % 13.2 6.2

Ложных тревог почти вдвое меньше, и прокси незнакомой линии совпал с честной проверкой. Но полигон парно — +39 / −259: у стоящего человека P@50 0.71 → 0.62, у лежачего P@100 0.22 → 0.11, у ящика 0.53 → 0.40.

Воспроизводится так:

python tools/make_training_set.py --new-data 0:110 --out data/cache/training_set_nd.npz
python tools/train_mbon.py --device cuda --data data/cache/training_set.npz \
    --data data/cache/training_set_nd.npz --train-only new_data_ \
    --save-folds artifacts/mbon_folds_nd --out artifacts/mbon_readout_nd.npz
python tools/eval_new_data.py --shards 110: --readout было=artifacts/mbon_readout.npz \
    --readout nd=artifacts/mbon_readout_nd.npz --out nd_eval.json

--train-only new_data_ — куски new_data только учат и складок не получают: проверяются по-прежнему пять записей.

17.4. Строже или умнее: сравнение при равной строгости

Модель, которая вдвое реже тревожится и заметно реже видит предмет, может быть не лучше, а просто строже. Это проверяется сравнением при одинаковой строгости, и ручка для неё есть с п. 11: mbon_power, вес наблюдения p^power. Все строки — с новой памятью (п. 17.1).

считывание, резкость вторая половина new_data незнакомая знакомая посторонних на полигоне полигон парно к первой строке
прежнее, 1.0 23.9 23.5 6.5 1166
прежнее, 1.5 17.4 14.7 5.7 793 +1 / −142
прежнее, 2.0 15.0 14.3 5.7 628 +0 / −268
new_data, 1.0 13.2 13.2 6.2 822 +39 / −259
new_data, 0.7 24.8 16.4 7.7 927 +72 / −184
  • Больше половины выигрыша — строгость. Прежнее считывание при резкости 1.5 срезает ложные на незнакомой линии с 23.5 до 14.7, а на полигоне теряет 142 наблюдения из 7 600: 39 вблизи у края габарита (тот же пограничный случай, что в п. 17.1–17.2) и 82 за 90 м.
  • Но не весь. При сопоставимых ложных на второй половине new_data (15.0 и 13.2) модель на new_data теряет на полигоне меньше, чем прежняя при 2.0 (−220 против −268 в сумме). Другой день записи действительно научил её тому, чего в пяти записях нет. Ослаблять её бесполезно: при 0.7 ложные возвращаются к 24.8.
  • Между «прежнее, 1.5» и «new_data, 1.0» — размен, а не доминирование. Вторая на четверть реже тревожится на данных своего дня, первая держит дальность: P@50 у стоящего 0.71 против 0.62, P@100 у лежачего 0.22 против 0.11, у ящика 0.50 против 0.40. На пяти записях без памяти, которых при проверке не видела ни одна из двух, они почти равны (14.7 и 13.2), на знакомой линии прежняя лучше (5.7 и 6.2).

Принято: прежнее считывание, резкость 1.5. ТЗ просит баланс дальности и ложных тревог, постановщик — прежде всего не видеть того, чего нет; резкость 1.5 даёт второе почти без цены в первом. Модель на new_data остаётся рецептом из п. 17.3: если появятся ещё записи других дней, учить на них выгоднее, чем ужесточать.

17.5. Итог

Новая память (п. 17.1), прежнее считывание, резкость 1.5 (п. 17.4) — против опубликованного 23.09:

23.09 сейчас
ложных треков/км, знакомая линия 8.0 (13.8 % кадров) 5.7 (8.8 %)
то же, незнакомая линия 23.5 (34.6 %) 14.7 (25.9 %)
то же, вторая половина new_data 23.9 (38.5 %) 17.4 (31.4 %)
посторонних тревог на полигоне 1226 793
настоящий объект 99.5 % 99.5 %
человек стоя: рабочая дальность; P@50 / P@100 / P@150 80 м; 0.71 / 0.48 / 0.49 100 м; 0.71 / 0.50 / 0.46
человек сидя 148 м; 0.67 / 0.52 / 0.49 122 м; 0.67 / 0.54 / 0.39
человек лёжа 32 м; 0.50 / 0.22 / 0.02 20 м; 0.50 / 0.22 / 0.00
ящик 20 м; 0.54 / 0.50 / 0.26 20 м; 0.57 / 0.50 / 0.22
чемодан 80 м; 0.61 / 0.45 / 0.24 80 м; 0.61 / 0.46 / 0.24

Ложных тревог примерно на треть меньше на всех трёх сценах, ближняя и средняя дальность на месте. Цена — хвост за 130 м у сидящего человека (P@150 0.49 → 0.39) и рабочая дальность лежачего 32 → 20 м: доля в полосе 20–32 м у него ходит у самого порога 0.5, и сдвинула её новая память, а не резкость (P@50 тот же, 0.50). Без обученного считывания при тех же настройках было бы 11.9 и 36.1 ложных трека на км.

Отдельно найдено при проверке сдачи: образ Docker не загружал считывание — mbon_path был пустым, файла модели в образе не было, и узел молча работал на ручной формуле. Все цифры выше замерены со считыванием, образ теперь тоже с ним (docker/Dockerfile, detect.launch.py), а дымовой тест образа проверяет, что модель на месте.

17.6. Раздельный запуск: узел в контейнере, проигрывание снаружи

ТЗ описывает приёмку как docker build → docker run → ros2 bag play. Все прежние замеры шли внутри одного контейнера — узел и проигрыватель вместе. Раздельный запуск, как у инженера на стенде, проверен впервые (запись roundT_doubleT, 252 кадра):

узел проигрыватель принято кадров
--network host другой контейнер, наш профиль DDS 0
--network host, умолчания Fast DDS другой контейнер, умолчания Fast DDS 0
--network host --ipc host другой контейнер --ipc host, умолчания Fast DDS — как ROS на хосте 252
--network host --ipc host другой контейнер --ipc host, наш профиль 252

Причина: с --network host Fast DDS видит у собеседника тот же хост и шлёт кадры через /dev/shm, а без --ipc host у контейнера она своя — сегмент собеседника не виден, и кадры пропадают молча, без единой ошибки в журнале. Это общая ловушка ROS 2 в Docker, наш профиль ни при чём: на умолчаниях то же.

Что сделано:

  • --ipc host — во всех командах README;
  • точка входа видит собственную /dev/shm (источник монтирования shm) и переводит транспорт на UDP (docker/fastdds_udp.xml): проигрывание внутри контейнера и второй контейнер из этого же образа тогда работают и без флага (252 из 252). Проигрывателю на хосте со своими умолчаниями это не помогает — он всё равно идёт через память (0 кадров), поэтому
  • узел следит за входом: если издатель на топике есть, а кадров нет пять секунд, он пишет в журнал, что контейнер нужно запускать с --ipc host.

Попутно: запуск одной командой (bag:=) проигрывал запись без --read-ahead-queue-size 10, и на кадрах по 24 МБ доходило 119 из 201. Флаг и задержка старта проигрывания на 4 с добавлены в launch — 201 из 201, с RViz и без. Сквозная проверка после всех правок, doubleT_obstacle в реальном времени: 201 кадр из 201, объект в 189 кадрах из 190 на 55.8 м, обработка кадра 33.4 / 40.3 мс (медиана / p95).

17.7. RViz: почему тормозило и где пропадало облако

При просмотре в RViz на записи с препятствием облака не было вовсе, а со схемой мозга всё заметно тормозило. Обе причины — в обвязке, не в конвейере.

  • Топик. RViz слушал /lidar_points, а doubleT_obstacle пишет в /sensing/lidar/hesai128/pointcloud (остальные пять записей — в /lidar_points). Узел подписан на оба, поэтому рамка «55 м» была, а точек — нет.
  • Цена сообщений в rclpy. Присваивание bytes полю data rclpy проверяет поэлементно на Python: картинка мозга в 2.8 МБ — 142 мс, против 2.4 мс у array('B'). Обработка идёт в колбэке, и узел со схемой мозга успевал около трёх кадров в секунду.
RViz схема мозга принято кадров из 201: до после
есть нет 183 201
нет есть 135 201
есть есть 80 201

Сделано: облако обзора /flyguard/view_cloud — сектор обработки из самого конвейера, до 77 тысяч точек вместо 900 тысяч, в координатах пути (пол на сетке, рамки препятствий на полу); оно есть при любом входном топике и публикуется, только когда на него подписаны. В обоих местах — array('B'). Для схемы мозга — отдельный конфиг RViz, где она справа во всю высоту: раскладку панелей Qt хранит сериализацией, и строка для конфига собирается tools/rviz_layout.py по исходникам Qt 5.15, без самого Qt.

Раз уж топик у записей разный, у контрольной записи он может оказаться третьим. Сторож входа теперь и это закрывает: если наши топики молчат, а в системе есть другой топик с облаком точек, узел через секунду подписывается на него сам и пишет об этом в журнал. Проверено переименованием топика при проигрывании (--remap /lidar_points:=/my/cloud): 248 кадров из 252 — четыре первых ушли, пока узел искал.

И ещё одна находка в самой проверке. docker/check_all.sh гоняет записи подряд в одном контейнере через demo_test.sh, а тот гасил launch сигналом SIGTERM — узел его переживал. Узлы копились: на шестой записи их работало пять, «принятых» кадров выходило 2287 при 877 в записи, а время кадра росло от записи к записи с 37 до 82 мс. Теперь SIGINT и ожидание, пока детектор не выйдет.

Все шесть записей в контейнере после правок (docker/check_all.sh; память и считывание из образа, так что все записи им знакомы):

запись кадров кадров с тревогой кадр, медиана / p95, мс
doubleT_obstacle 201 189 (99.5 %) — настоящий объект, 55.7 м 37.3 / 44.8
doubleT_platform 345 0 40.8 / 49.2
roundT_doubleT 252 0 43.4 / 58.8
roundT_pressureGate_roundT 268 0 51.2 / 62.1
roundT_squareT_pressureGate_squareT 545 28 (5.2 %) 56.0 / 68.5
squareT_platform_squareT_switch 877 50 (5.8 %) 47.9 / 65.8

Записи читались с диска Windows через WSL, медленнее реального времени, и узел обработал все кадры до одного. Тревоги на двух последних записях — три трека, неподвижных в координатах пути (точка держится с точностью до 1.2 м):

запись когда дальность что видит узел уверенность
roundT_squareT_pressureGate_squareT 28–31 с 65 → 30 м плоская полоса 1.3 м × 3 см на 0.19 м над рельсом, по оси ≤ 0.26
squareT_platform_squareT_switch 21–23 с 103 → 92 м 0.2 × 0.4 м, низ на 0.48 м над рельсом, по оси ≤ 0.34
squareT_platform_squareT_switch 70–74 с 147 → 136 м 2.5 × 1.0 м на высоте 0.8–1.8 м, в 0.8 м левее оси ≤ 0.36

Первая — почти наверняка порог гермозатвора: пол в колее опущен до 0.16 м (п. 16.3), и полоса на 0.19 м над рельсом оказывается над ним. Две другие нужно смотреть глазами: по словам организаторов, препятствий в данных «не больше двух», и одно из них в doubleT_obstacle — второе может оказаться здесь.

18. Синтетика организаторов, стенд жюри, RViz на видеокарте (24.09)

24.09 организаторы выложили бэг с синтетикой «в первом приближении» (cloud_with_fake_obj, 7.4 ГБ, 151 с, 1510 кадров на /lidar_points): десять предметов примерно через 100 м, в объявленном порядке — 2×2 м посередине габарита, 0.3×0.3 м посередине, 0.3 на рельсе, 0.3 у края, 0.3 вне габарита рядом, 2×2 у края в пределах габарита, 2×2 вне габарита, 2×2 сверху, длинный низкий 2×0.2 м на рельсах, узкий (5 см) длинный с потолка. Это первый бэг в формате, близком к контрольному, и он вскрыл три вещи сразу.

18.1. Формат: нет ring, порядок точек нарушен

В облаке только x, y, z, intensity — ни ring, ни timestamp. Калибровка решётки брала число колец из поля ring и падала на первом же кадре, то есть узел на этом бэге не обработал бы ни одного кадра — сбой ловился в колбэке, и в журнал шло «сбой обработки кадра» десять раз в секунду.

Хуже, чем отсутствие поля: число точек в кадре гуляет от 213 988 до 352 977. Синтетика собрана из настоящей записи так: заслонённые предметом точки удалены, точки предмета вписаны в середину массива, и всё, что дальше, сдвинуто. У 757 кадров ровно 307 200 точек, но и в них порядок сбит — удалили и вставили поровну. Ни в одном из первых двенадцати кадров порядок не цел.

Что сделано (retina.py):

  • проверка порядка в каждом кадре. У целого кадра элевация каждой точки совпадает с элевацией её кольца до 0.0001° — замерено на всех шести записях и на чистых кадрах синтетики (азимут — тоже до 0.0001°). Ближайшие кольца Pandar128 разнесены на 0.086°, вставленные точки уходят от колец на 0.02–0.1°, сдвинутые — на целое кольцо. Допуск 0.01°. Проверка идёт по z против r·sin(элевация кольца), около миллисекунды;
  • раскладка по углам точки для кадра, не прошедшего проверку: кольцо — ближайшее по элевации, столбец — по азимуту с поправкой кольца, в ячейке ближняя точка идёт в r_near, дальняя — в r_far. На целых кадрах roundT_doubleT и doubleT_obstacle результат совпал с быстрым путём бит в бит (0 расхождений из 56 863 и 56 020 лучей). Цена — 13 против 6 мс на 120° и 22 против 6 мс на 360° (Windows, NumPy 2.4);
  • калибровка без ring: кольца — по гистограмме элевации (кольцо ложится в один бин 0.002°, вставленные точки рассыпаны и отсекаются порогом веса); в калибровку по порядку идут только кадры, прошедшие проверку; если таких нет — решётка строится целиком по углам (шаг — самая частая разность азимутов внутри кольца, сдвиг кольца — круговое среднее фазы). На записях с полем ring, у которых поле вырезано, калибровка без него дала ту же решётку до последнего знака.

На синтетике сработал последний вариант: целых кадров среди первых двенадцати нет, решётка построена по углам, и все кадры идут медленным путём. В контейнере: принято 1510, обработано 1499 (11 — калибровка), потерь 0, кадр 43.7 / 50.2 мс (медиана / p95).

18.2. Эталон: вставленные точки не лежат на кольцах

Разметки к бэгу нет, но она и не нужна: у вставленных точек элевация не совпадает ни с одним кольцом (отклонение больше 0.002°, у настоящих — до 0.0001°). Кластеры таких точек по кадрам, связанные от кадра к кадру по дальности, дают все десять предметов в объявленном порядке. Путь для этого не годится: между вторым и третьим предметом поезд стоит (37–40 с), одометрия замирает, и по положению вдоль пути они сливаются.

Где стоят предметы (x, z — у ближней точки, в системе лидара; до рельса 1.32 м вниз):

№ предмет поперёк x, м по высоте z, м виден с
1 2×2 посередине −0.99…+1.00 стоит на полотне 99 м
2 0.3 посередине −0.18…+0.17 −0.16…+0.16 (висит на высоте лидара) 118 м
3 0.3 на рельсе +0.60…+0.90 −1.14…−0.84 (на правом рельсе) 81 м
4 0.3 у края +0.84…+1.13 на высоте лидара 85 м
5 0.3 вне габарита −1.47…−1.14 на высоте лидара 94 м
6 2×2 у края внутри +0.92…+3.3 стоит на полотне 128 м
7 2×2 вне габарита −3.7…−1.25 стоит на полотне 127 м
8 2×2 сверху −1.15…+1.11 +1.72…+3.8 (низ на 3.0 м над рельсом) 130 м
9 2×0.2 на рельсах −0.95…+1.02 −1.33…−1.02 129 м
10 стержень 5 см с потолка 0.00 +1.49…+3.4 (низ на 2.8 м над рельсом) 95 м

Отсюда же видно, что наша плоскость рельсов (1.32 м под лидаром) и есть головка рельса: длинный предмет «на рельсах» лежит низом на 0.05 м над ней, а ящик 2×2 стоит на полотне на 0.15 м ниже. Организаторы в чате назвали 1075 мм — на этих данных это не подтверждается.

И ещё одно свойство синтетики: отдельные вставленные точки встречаются на 200–280 м и на 6–17 м выше пути — предмет вставляется, похоже, без проверки, виден ли он через изгиб тоннеля.

18.3. Что узел видит на синтетике при нынешних настройках

Сверка решений с эталоном (tools/eval_org_synth.py; память и считывание из artifacts, как в образе):

№ предмет виден с первое срабатывание итог
1 2×2 посередине 99 м 95.3 м найден
2 0.3 посередине (висит на высоте лидара) 118 м 41.7 м найден
3 0.3 на рельсе 81 м 36.3 м найден
4 0.3 у края 85 м 31.3 м найден
5 0.3 вне габарита 94 м 31.1 м, p ≤ 0.68 ложная тревога
6 2×2 у края внутри 128 м — пропущен
7 2×2 вне габарита 127 м — верно молчит
8 2×2 сверху 130 м — пропущен
9 2×0.2 на рельсах 129 м 79.0 м найден
10 стержень 5 см с потолка 95 м — пропущен

Шесть из десяти, и три ошибки из четырёх — о границах габарита, а не о чувствительности:

  • ширина. Расстановка организаторов задаёт полуширину около 1.15 м: «у края» (№ 4) кончается на 1.13 м от оси, «вне габарита, но близко» (№ 5) начинается с 1.14–1.15 м, «2×2 вне» (№ 7) — с 1.25 м. У нас 1.6 м, и № 5 целиком наш. В doubleT_obstacle то же видно по настоящей обстановке: колонны между путями на 17, 34 и 47 м заходят в полосу ±1.6 м (u от 0.83 м);
  • высота. Верх габарита у нас 2.3 м над головкой рельса, а № 8 висит низом на 3.0 м и № 10 — на 2.8 м. Вагон метро выше 3.5 м, и оба в его путь попадают. 2.3 м когда-то выбиралось, чтобы не цеплять кабели и светильники свода;
  • № 6 заходит в габарит на 0.3 м, а остальные 2 м его — снаружи. Для конвейера это ровно то же, что шкаф или кромка платформы: доля лучей внутри габарита (containment) мала, и вес улики гасится. Отличить предмет, который въехал в габарит, от конструкции, которая всегда там стояла, по одному кадру нельзя; это работа памяти тоннеля, а синтетика встала на чужом для неё участке;
  • № 10 — 5 см при шаге развёртки 0.1°: до 30 м в стержень попадает один столбец лучей, дальше — через раз. В эталоне у него 3–13 точек на кадр до 20 м. Ниже разрешения прибора, честный предел.

Вне эталона — четыре коротких ложных трека (39 кадров за 151 с), все с уверенностью не выше 0.47.

18.5. Стенд жюри на своём процессоре

У жюри i7-9700E (8 ядер без гиперпотоков, 2.6 ГГц, турбо 4.4), у нас Ryzen 5 7600X. Узел однопоточный (п. 18.7), поэтому решает производительность одного ядра: по PassMark single thread 2511 против 4129, отношение 0.61. docker/jury_cpu_test.sh запускает узел в отдельном контейнере с квотой 0.61 ядра (периоды по 10 мс, чтобы не было рывков по 61/39 мс), а проигрыватель и подсчёт — во втором контейнере без квоты, как на стенде, где у проигрывателя свои ядра.

запись квота принято кадр, медиана / p95 / макс, мс
doubleT_obstacle 1.0 201 из 201 35.8 / 40.7 / 50.0
doubleT_obstacle 0.61 201 из 201 56.7 / 63.3 / 97.9
cloud_with_fake_obj 1.0 1510 из 1510 48.0 / 56.8 / 124.5
cloud_with_fake_obj 0.61 1498 из 1510 72.5 / 106.8 / 200.4

Обнаружение не меняется: 189 кадров с тревогой из 190 при обеих квотах. На реальной записи запас до 100 мс остаётся и на слабом ядре. На синтетике — нет: p95 106.8 мс, и 12 кадров из 1510 (0.8 %) не дошли, пока узел доедал очередь. Синтетика дороже по двум причинам: каждый её кадр идёт раскладкой по углам (п. 18.1, +5 мс), и лобула на ней тяжелее — сцена сложнее. Квота — грубое подобие: у 9700E другие кэш и память, а в одном потоке он уходит в турбо, так что оценка скорее с запасом.

Таблица выше снята до смены габарита (п. 18.4). С итоговыми настройками лучей в габарите меньше, и кадр дешевле — повтор на итоговом образе:

запись квота принято кадр, медиана / p95 / макс, мс
doubleT_obstacle 1.0 201 из 201 30.7 / 33.2 / 36.0
doubleT_obstacle 0.61 201 из 201 49.7 / 60.2 / 67.8
cloud_with_fake_obj 1.0 1510 из 1510 41.8 / 45.9 / 53.7
cloud_with_fake_obj 0.61 1510 из 1510 62.7 / 71.2 / 99.9

На слабом ядре укладываются обе записи, кадры больше не теряются.

18.6. RViz: 30 кадров в настройках, 2–5 на деле

Жалоба: при вращении сцены мышью изображение идёт рывками, хотя в конфиге Frame Rate: 30. Причина — в контейнере нет видеокарты, и OpenGL рисуется программно (Mesa llvmpipe на процессоре); 30 — потолок, а не факт. docker/gl_probe.py показывает, чем рисует контейнер, без окна (EGL на pbuffer 1×1):

запуск renderer процессор у RViz при проигрывании
как было llvmpipe (LLVM 15.0.7, 256 bits) 188 %
--device /dev/dxg -v /usr/lib/wsl:/usr/lib/wsl:ro -e LD_LIBRARY_PATH=/usr/lib/wsl/lib D3D12 (NVIDIA GeForce RTX 5070 Ti) 21 %

Это Docker в WSL 2; на Linux то же дают --gpus all (NVIDIA) или --device /dev/dri (Intel/AMD), в README — таблица. Детектор обработал в обоих случаях одни и те же 190 кадров из 190.

18.7. Потоки BLAS: три ядра впустую

Замер процессора по /proc/<pid>/stat за 10 с проигрывания показал, что узел занимает 297 % ядра при кадре 33 мс. Причина — OPENBLAS_NUM_THREADS=4 в образе: матрицы конвейера мелкие, и потоки OpenBLAS не ускоряют кадр, а крутятся в ожидании работы. С одним потоком:

процессор узла doubleT_obstacle, мс синтетика, мс
4 потока BLAS 297 % 33.2 / 40.5 45.9 / 54.7
1 поток 41 % 33.9 / 39.3 43.7 / 50.2

Кадр не медленнее, а по p95 даже быстрее: потоки BLAS отбирали ядра у самого узла и у проигрывателя. На стенде с 8 ядрами это освобождает три ядра под ros2 bag play и RViz. Один поток теперь и в образе (ENV), и в launch (additional_env узла) — на случай запуска без нашего образа.

18.8. Пауза и перемотка, скачок времени

Пользователь не успевал разглядеть срабатывания: проигрыватель, запущенный из launch, клавиатуры не слышит — у него нет терминала. В образе теперь пульт flyguard-keys (flyguard/player_keys.py): из второго терминала, docker exec -it <контейнер> flyguard-keys /data/<запись>, через сервисы rosbag2 — пауза, ±3 с, кадр вперёд, скорость ×0.1…×4, в начало. Время записи пульт берёт из /clock (launch запускает проигрыватель с --clock 20); подписка на /clock обязана быть best effort — с надёжной QoS не сходится, и время не приходит вовсе (поймано на первой проверке).

Перемотка назад вскрыла дыру в конвейере: треки, одометрия, ось пути и веерное тело живут в координатах пути, и после скачка назад предмет с прошлого места всплыл бы там, где его нет. То же — на каждом круге ros2 bag play --loop и при выпадении данных на живом поезде. Теперь скачок времени записи больше 2 с или назад сбрасывает всё, что копится от кадра к кадру (FlyGuard._init_temporal; решётка лучей остаётся — она от сенсора). Узел пишет об этом в журнал. Проверено: после перемотки на 3 с назад узел сбросил состояние и заново выдал ПРЕПЯТСТВИЕ: 56.0 м за 0.2 с; в офлайне второй круг по тем же 40 кадрам — 39 тревог из 40.

Ещё две мелочи оттуда же. demo_test.sh гасил узел kill -INT, а фоновые задания неинтерактивного bash к SIGINT глухи — узел висел 10 с и уходил по kill -9, без итоговой строки. Теперь set -m, узел выходит за 0.5 с. И подпись рамки в RViz была «55 м» с пустым местом вместо «м»: в шрифте RViz нет кириллицы — теперь «55 m».

18.4. Габарит 1.2 м и верхняя секция до 3.7 м — принято

Правка по п. 18.3: полуширина 1.6 → 1.2 м, и над основной частью (до 2.3 м) добавлена верхняя секция до 3.7 м над рельсом — уже основной, |u| < 1.0 м, и не дальше 90 м (h_top, half_width_top, top_d_max). Узкая она потому, что у стен на этой высоте кабели и светильники, короткая — потому, что дальше ошибка наклона плоскости и оси поднимает конструкции свода в габарит: на пустых записях без ограничения по дальности в секцию лезли сотни кандидатов.

Висящему в верхней секции не нужна опора снизу, и обученное считывание его не судит: модель учили на стоящих предметах, и она гасила висящее (central_complex._quality, top_from). Без этого «2×2 сверху» не находился и с секцией.

Сравнение с прежними настройками, все прогоны парные:

проверка 1.6 м, до 2.3 м 1.2 м + верхняя секция
синтетика организаторов 6 из 10 8 из 10: № 5 теперь молчит, № 8 найден с 83.5 м
полигон, наблюдения — 0→1: 43, 1→0: 33
полигон, посторонние тревоги 793 742
знакомая линия (LOO, с памятью) 5.7 на км, 8.8 % кадров 5.7 на км, 8.4 %
незнакомая (без памяти) 14.7 на км, 25.9 % 13.8 на км, 24.3 %
вторая половина new_data (со сбросом, п. 18.9) 13.8 на км, 29.0 % 14.4 на км, 20.3 %
doubleT_obstacle 99.5 % 99.5 %

На полигоне выиграли человек стоя (P@100 0.50 → 0.52) и чемодан (рабочая дальность 80 → 100 м, P@100 0.46 → 0.50), проиграли ящик на 90–130 м (P@100 0.50 → 0.40) и лежащий человек (0.22 → 0.19). Оба проигравших у края: при положении ±0.9 м их край доходит до 1.15–1.8 м, и доля лучей внутри габарита падает.

Физически половина ширины вагона метро — 1.35 м, и габарит 1.2 м уже вагона. Но оценивают по расстановке организаторов, а она задаёт границу около 1.15 м. Вернуть прежний габарит — half_width:=1.6 h_top:=0.

Не найдены по-прежнему № 6 (2×2, в габарит заходит на 0.3 м) и № 10 (стержень 5 см) — причины в п. 18.3.

18.9. Сброс при скачке времени на new_data

Сброс из п. 18.8 задуман для перемотки, но первым сработал на данных. Во второй половине new_data 70 с выпавших кадров: по metadata.yaml в 17 шардах набегает больше 2 с сверх 0.1 с на кадр (в первой половине — ни в одном). Парный замер (eval_new_data.py, reset_gap_s=0 против умолчания):

ложных треков путь на км кадров с тревогой
без сброса 57 3.27 км 17.4 31.4 %
со сбросом 47 3.41 км 13.8 29.0 %

Без сброса получается ровно прежняя цифра 17.4 из README — значит, других изменений на реальных данных нет. После разрыва оценщик собственного движения сравнивал профили кадров, разнесённых на секунды, как соседние, и занижал путь: треки уезжали из своего места и заводились заново под новыми номерами. На записях for_hackathon разрывов нет, и там сброс ничего не меняет (полигон при прежних настройках — 0 переворотов из 15 560).

18.10. Итог

  • Узел читает облака без ring и с нарушенным порядком точек — без этого синтетику (и, скорее всего, контрольный бэг) он не обработал бы вовсе.
  • Габарит 1.2 м + верхняя секция до 3.7 м: синтетика 6 → 8 из 10, остальное не хуже. Артефакты benchmark.json, generalisation.json, gen_cold.json и рисунок дальности пересобраны под новые настройки.
  • Сброс при скачке времени: пульт с перемоткой и --loop работают честно, new_data 17.4 → 13.8 ложного трека на км.
  • BLAS в один поток: узел 3 ядра → 0.4. RViz на видеокарте: 1.9 ядра → 0.2.
  • На ядре стенда жюри (квота 0.61) с итоговыми настройками укладываются обе записи: реальная — p95 60 мс, синтетика — p95 71 мс, все 1510 кадров (до смены габарита синтетика давала p95 107 мс и теряла 0.8 % кадров).
  • Три тревоги на двух записях, подозревавшиеся как второе препятствие, — не препятствия: плоская полоса у гермозатвора (п. 17.7), плоская деталь на полотне в 0.19 м над рельсом (вблизи — 1–6 см высотой, память её знает) и дальнее пятно на 147 м, которого при подъезде нет. Настоящий объект в for_hackathon один — в doubleT_obstacle.

18.11. «Человек» слева в RViz — тень детали у лидара

На doubleT_obstacle в RViz слева от габарита виден тёмный силуэт, похожий на человека. Это не предмет: тёмное в облаке обзора — место без точек, а человек выглядел бы пятном точек, как объект на 55 м. Между краем габарита и левой стеной от 3 до 9 м выше 1.1 м нет ни одной точки, в 6 м вокруг лидара — только стена, низкий короб у пути (0.3–0.5 м) и части самого поезда.

Дыру даёт деталь в 0.48–0.62 м от лидара — спереди-слева и ниже него (азимут 14–34° влево, угол места 12–25° вниз), во всех 200 кадрах на месте, по форме — перевёрнутое «Г». Лучи, которые она перекрывает, иначе легли бы на пол в 3.3–5.5 м впереди от края габарита до стены и на низ стены. Саму деталь облако обзора не показывает (оно начинается с 0.5 м), поэтому видна одна тень (docs/figures/doubleT_mount_shadow.png). Это и слепое пятно прибора: полоса пола у левого края габарита в 3–5 м впереди на этой записи не наблюдается.

19. Фантомы и висящее: плоское у пола, отдельный проход по верхней секции (25.09)

Задача от пользователя: «чтобы всё проходило на cloud_with_fake_obj» и убрать фантомы — «предмет появляется на 1–2 секунды, а при приближении исчезает».

19.1. Откуда фантомы

Подробный журнал конвейера (у каждого кандидата вес улики, отсчёт считывания MBON, новизна; у каждого трека ход улики) на синтетике и шести записях. Настоящие предметы копят улику до 1.0 и живут десятки кадров; фантомы живут 9–21 кадр. Все восемь (пять на синтетике, три на записях с поезда) — трёх видов:

вид где признак
плоское у пола синтетика 40→25 и 32→22 м; порог у гермозатвора 65→31 м (28 кадров) высота 0.01–0.05 м, верх 0.18–0.23 м над рельсом
дальнее синтетика 178 и 120 м; squareT_… 103→85 и 146→136 м 3–8 лучей, вблизи на этом месте ничего нет
у края габарита синтетика 16→14 м, u −1.01…−1.17 конструкция, выходящая из габарита по мере подъезда

Важная деталь: при mbon_blend = 1 вес наблюдения целиком решает обученное считывание, и плоским фантомам оно ставит 0.78–0.99 — в его обучении был кабель на путях. Значит, правило должно стоять поверх модели.

19.2. Плоское у пола

Вблизи (до 70 м) кольца лидара идут достаточно часто, чтобы судить о высоте: настоящий низкий предмет (0.3 м на рельсе, 2×0.2 м поперёк путей) даёт 0.1–0.4 м по высоте и верх выше 0.4 м. Кандидат с высотой меньше 0.06 м и верхом ниже 0.26 м над рельсом теперь получает вес ×0.15 (flat_h/flat_top/flat_d/flat_w). Множитель, а не запрет: предмет, хоть раз показавший высоту, своё доберёт. Организаторы подтвердили, что лежащее в жёлобе препятствием не считается.

Итог: порог гермозатвора и оба плоских фантома синтетики ушли; на пустых записях 3 → 2 ложных трека, 73 → 45 кадров с тревогой.

19.3. Висящее со свода

Стержень 5 см (№ 10) не находился вовсе. Основной проход тянет контекст кластеризации до свода (ctx_up) — иначе срез колонны по верху габарита сам выглядит предметом. Но поэтому всё свисающее склеивается со сводом, доля лучей в габарите у стержня — ноль, вес ~0.005.

Первая попытка — не пускать свод над верхней секцией в контекст (top_ceiling_cut). Стержень нашёлся с 13 м, но на roundT_doubleT вышла новая тревога: на 33 м по оси — стойка от пола до 2.75 м с коробкой на 2.0–2.6 м, похоже на светофор у стрелки, к которому поезд не едет (двухпутный тоннель, наша ось смотрит прямо). Раньше стойку держал в компоненте свод, над которым она крепится. Срез освободил и её. Отвергнуто.

Принято: отдельный проход по верхней секции (lobula._hanging, top_detect). Его контекст — только сама секция с запасом 0.3 м по сторонам и 0.4 м вниз, без свода. Компонента, у которой есть лучи ниже h_hi, уходит к полу — это стоящий предмет, его ведёт основной проход (светофор так и остаётся без тревоги). Кандидаты основного прохода, целиком лежащие в верхней секции, отбрасываются, чтобы не двоить трек. Ещё три вещи понадобились по ходу:

  • новизна и MBON в верхней секции выключены. Обе обученные части собирались до того, как у габарита появилась верхняя секция, и таких форм не видели. Опора по числу лучей для висящего не ниже 0.5 — нормировка рассчитана на предмет с человека, а стержень тонок по природе и упирался в нижний край 0.25;
  • плоское у верхней границы — свод. На 83 м ошибка наклона плоскости пути опустила свод до 3.66–3.69 м, в секцию; такой кандидат отбрасывается;
  • верх секции 3.3 м, а не 3.7. После выключения новизны в тревоги пошли кабели и кронштейны свода на 3.2–3.6 м (doubleT_platform 61 м, roundT_doubleT 22 м). Новизна их не отделяет (медиана 0.24–0.41 у них, 0.29–0.57 у стержня), высота низа — отделяет: у деталей свода он в основном выше 3.2 м, у предметов организаторов — 2.8 и 3.0 м.

Итог: стержень найден, но поздно — с 13.4 м: в поясе до 3.3 м на нём вдвое меньше лучей, чем до 3.7 (при 3.7 он находился с 50 м, но ценой двух ложных треков на записях). «2×2 сверху» держится 48 кадров вместо 24, с 87 м. Попутно «0.3 на рельсе» находится с 76 м вместо 36. Механизм не разбирался; вероятно, раньше рядом жили мусорные треки из верхней секции, склеенные со сводом, и торможение соседних треков (_inhibit) гасило его улику.

19.4. Дальние фантомы: возраст трека — отвергнуто

Дальний фантом по одному кадру неотличим от настоящего предмета: те же 3–4 луча, отсчёт MBON 0.2–0.8, новизна на дальности выключена (п. 12.3). Отличает его только время: он пропадает при подъезде. Проверено правило «дальше 100 м тревога только за треком, прожившим 15 кадров» (far_confirm): на синтетике фантомов 3 → 2 и кадров тревоги 24 → 9, но полигон теряет 200 наблюдений из 15 560, P@150 у человека стоя 0.46 → 0.37, сидя 0.39 → 0.21, у чемодана и ящика вдвое. Посторонних тревог на полигоне меньше на 14 %, но такой ценой дальности не берём. Параметр оставлен выключенным.

19.5. Итог

Принято: flat_h = 0.06, top_detect = true, h_top = 3.3. Все прогоны парные:

проверка было стало
синтетика организаторов 8 из 10, фантомов 5 (43 кадра) 9 из 10, фантомов 3 (24 кадра)
пустые записи с поезда 3 трека, 73 кадра 2 трека, 45 кадров
знакомая линия (LOO) 5.7 на км, 8.4 % кадров 5.7 на км, 8.4 %
незнакомая (без памяти) 13.8 на км, 24.3 % 12.8 на км, 20.7 %
вторая половина new_data 49 треков, 14.4 на км, 20.3 % 40 треков, 11.7 на км, 17.9 %
полигон, наблюдения — 0→1: 0, 1→0: 3 (каска)
полигон, посторонние 742 758
doubleT_obstacle 99.5 % 99.5 %
кадр в контейнере, doubleT_obstacle / синтетика 30.7 / 41.8 мс 33.4 / 43.5 мс
на ядре уровня жюри (квота 0.61), p95 60.2 / 71.2 мс 64–66 / 79.7 мс

Что не взято и почему:

  • № 6, 2×2 у края. В габарит он заходит на 0.06–0.28 м (граница оценки оси плавает от кадра к кадру), остальные 2 м — снаружи. Для конвейера это то же, что шкаф у стены: MBON даёт 0.00–0.02, доля лучей в габарите — ноль. Правило «компактное, стоит на полу, уходит за габарит» усилило бы и фантом у края на 16 м, который как раз такой.
  • Дальние фантомы — п. 19.4.
  • Фантом у края на 16 м: настоящая конструкция, заходящая в габарит на 0.03–0.19 м; с габаритом 1.2 м он граничный.

20. Габарит на кривой и край габарита: ось против эталона, рамки в RViz, № 6 (25.09, ночь)

Вопрос пользователя: соответствует ли габарит, нарисованный в RViz, тому, что проверяется, если поезд поворачивает, — и поможет ли усиленная проверка у края габарита найти № 6 (2×2, заходит в габарит на 0.06–0.28 м).

20.1. Ось пути против эталона организаторов

Предметы синтетики неподвижны в мире и стоят на пути, поэтому по ним ось пути впервые проверяется не нашей же оценкой (как на полигоне, п. 13), а чужой разметкой. № 2 стоит на левой кривой радиусом около 760 м: на 118 м он в 9.1 м левее оси лидара. Центр предмета против нашей оси c(d) в том же кадре:

дальность, м 22 31 39 48 64 87 118
эталон, м −0.32 −0.60 −1.02 −1.49 −2.77 −4.89 −9.10
наша ось, м −0.36 −0.72 −1.17 −1.70 −3.04 −5.11 −8.14
ошибка, м 0.04 0.12 0.15 0.21 0.27 0.22 0.96

На прямых участках (№ 1, 8, 9) ось иногда изгибается сама: медиана ошибки дальше 20 м 0.05–0.42 м, до 1.1 м на 57 м (ящик 2×2 посередине сам сдвигает центр сечения) и до 1.2 м на 110–130 м. Поэтому проверяется объединение прямого и изогнутого габаритов (TrackFrame.lateral): неточная ось не сужает зону поиска.

20.2. Рисование в RViz

Облако обзора выровнено по плоскости рельсов, но в кривой не выпрямлено, а габарит рисовался прямым коробом. Рамки препятствий ставились по смещению от оси пути, то есть в другой системе, чем точки: на кривой синтетики рамка № 2 на 39 м стояла на +0.13 м при точках на −1.05 м, на 31 м — на +0.12 при −0.59.

Теперь кандидат несёт медиану смещения своих лучей в системе лидара (extra["u_raw"]), трек сглаживает его так же, как u, и рамка рисуется по нему (DetectedObject.sensor_x); в сообщении lateral по-прежнему от оси пути. Контур габарита в кривой — два: яркий вдоль оценённой оси, бледный прямой; проверяется объединение обоих. На прямом пути они совпадают, и рисуется один (export.gauge_outline, тесты test_track_carries_the_sensor_lateral_for_drawing, test_gauge_outline_bends_with_the_track). Решения не менялись: на синтетике тот же список находок и фантомов с теми же дальностями. Кадр узла в контейнере на этой кривой — docs/figures/curve_gauge.png.

20.3. № 6: усиленная проверка у края — отвергнуто по замеру

№ 6 даёт кандидатов с 72 до 18.7 м: полоса в габарите шириной 0.1–0.33 м и высотой 0.8–1.96 м, перепад дальности к фону 7.5–12.6 м, доля лучей в габарите 0.00–0.01 (с 50 м), новизна 0.13–0.57, отсчёт MBON 0.001–0.046. По перепаду он резко отличается от фантома у края на 16 м (0–1.9 м), и правило «полоса у края, высокая, с сильным перепадом» его бы нашло. Но на записях без препятствий та же подпись встречается постоянно. Перепись кандидатов с перепадом от 5 м, высотой от 0.7 м, |u| от 0.7 м и дальностью до 80 м (scratchpad/edge_census.py по прогону принятой конфигурации):

запись мест, где подпись держится 3+ кадра
roundT_doubleT 8
squareT_platform_squareT_switch 5, одно — 451 кадр
doubleT_platform 2
doubleT_obstacle (кроме самого объекта) 1

Место на squareT_… — u +1.08 м, высота 2.0 м, ширина 0.27 м, перепад 11 м, новизна 0.23, доля в габарите 0.34 — по цифрам тот же № 6 (u +1.06, 1.43, 0.26, 9.6, 0.27, 0.29). Даже ближе 45 м и выше 1 м остаётся одно место на roundT_doubleT: 40→22 м, u +1.09…+1.17, перепад 5.2 м.

Отличает № 6 от этих конструкций не форма, а поперечное положение: настоящая конструкция стоит не ближе 1.35 м от оси пути, иначе в неё врежется вагон, а у нас оказывается на 1.0–1.1 м, потому что ошибка оси на 40–80 м — 0.2–0.4 м (п. 20.1), и лидар стоит не точно над серединой колеи. Чтобы взять № 6 без тревог на конструкциях, нужна точность поперечной координаты у края около 0.1 м на этих дальностях. Опереть ось на рельсы там не на что: с 50 м полотно пути даёт десятки точек за кадр на всю ширину тоннеля (сечения по дальности на синтетике). Не взято.

20.4. Итог

что было стало
рамка № 2 на кривой, 39 м в 1.2 м от точек у точек (−1.11 против −1.05)
контур габарита в кривой прямой короб, изогнутой части не видно оба контура
решения узла — без изменений (синтетика: те же находки, фантомы, дальности и число кадров)
тесты 48 50

21. Плотные стадии на видеокарте с откатом на процессор (26.09)

Решение пользователя: считать на видеокарте, если она есть, и только при её отсутствии или сбое — на процессоре. Раньше (п. 7.4) вывод держался на процессоре: на видеокарту была перенесена одна ламина, выигрыш 3 мс из 43. Перенос одной стадии и правда ничего не даёт — выигрыш даёт перенос всех плотных стадий разом, с данными, которые между стадиями не ездят.

21.1. Что переносить

Профиль кадра (cProfile, синтетика, 150 кадров; под профилировщиком всё медленнее, важны доли): кластеризация с учётом глубины 20 мс, раскладка точек по углам 14, ламина 8, оценка движения 8.5, ось пути 6.5, плоскость 3.9 из 74. Без профилировщика кадр — 49 мс на синтетике и 36 мс на записи с поезда, из них на раскладку, ламину и кластеризацию приходится 22–34 мс. Они работают с целым образом 128 × 600 лучей — это и перенесено (flyguard/gpu.py). Плоскость, ось, движение, треки и решение остались на процессоре: там мелкие массивы и ветвистая логика, копирование на видеокарту дороже расчёта.

21.2. Те же операции в тех же типах

Цель — не «похожий», а тот же результат, иначе откат на процессор менял бы решения посреди поездки.

  • Сетчатка — выборки, умножения и корень в float32, отдельными ядрами (без слияния умножения со сложением). На видеокарту уходит только окно сырых столбцов, нужное сектору (5 МБ вместо 24).
  • Ламина. scipy.ndimage.uniform_filter считает окно скользящим средним в double. Суммы чисел float32 такого диапазона (диспаритет 0.003…20, окно до 121) в double точны при любом порядке сложения, поэтому окно берётся из накопленной суммы, делится на размер и округляется в float32 после каждой оси — как в scipy. Сверка окон: 0 расхождений на 24.5 млн значений (запись) и 20.6 млн (синтетика). Вся ламина записана в граф CUDA: видеокарта считает её за 0.4 мс, а запуск сотни мелких операций из Python стоил 3 мс; с графом — 0.6 мс вместе с выгрузкой.
  • Кластеризация — рёбра сразу для всех 17 соседей, компоненты подвешиванием к меньшему номеру со сжатием путей: корень компоненты — её наименьший луч, и нумерация совпадает со scipy.sparse.csgraph. На 42 тыс. лучей и 581 тыс. рёбер — 8 раундов (медиана).

Исключение — раскладка по углам точек (кадры со сбитым порядком, как в синтетике организаторов): арктангенс видеокарты и процессора расходится в последнем знаке, а numpy сортирует ячейки неустойчиво.

21.3. Сверка

По стадиям, 80 кадров: на roundT_doubleT все три — бит в бит; на синтетике ламина и кластеризация — бит в бит, раскладка по углам разошлась в 13 кадрах из 80, 326 значений из 300 тыс.

Конвейер целиком, синтетика и шесть записей, покадрово против процессорного прогона той же конфигурации (v_t33): на всех шести записях совпали решения, объекты, кандидаты и треки в каждом кадре; на синтетике решения, объекты и треки — во всех 1499 кадрах, кандидаты разошлись в трёх (раскладка по углам), на треки это не повлияло. Синтетика 9 из 10, те же дальности и число кадров.

21.4. Откат

Любое исключение видеокарты — нет драйвера, не хватило памяти, ошибка ядра — ловит конвейер (FlyGuard._gpu_off): до конца работы он считает на процессоре, а текущий кадр досчитывается там же. Тест test_pipeline_survives_gpu_failure_mid_frame роняет видеокарту на 20-м кадре и сверяет решения с чисто процессорным прогоном. Узел пишет в журнал, где идёт счёт, и предупреждает, если видеокарта отказала на ходу; в диагностике — поле device.

21.5. В контейнере

Образ с PyTorch 2.9.1 + CUDA 12.8, Docker в WSL, видеокарта через /dev/dxg (на стенде — --gpus all). Узел, ros2 bag play в том же контейнере:

запись счёт кадр, медиана / p95 обнаружение
doubleT_obstacle видеокарта 23.5 / 31.3 мс 189 из 190, 55.8 м
doubleT_obstacle процессор 32.3 / 36.2 мс 189 из 190, 55.8 м
синтетика видеокарта 26.8 / 43.1 мс 390 кадров с тревогой
синтетика процессор 42.3 / 47.0 мс 398 кадров с тревогой

Строка doubleT_obstacle с видеокартой — после правки прогрева. До неё кадров с видеокартой приходило меньше (195 и 1502 против 201 и 1510, отсюда и 390 кадров тревоги на синтетике против 398): первый кадр шёл 1.0–1.7 с — при первом вызове подгружаются ядра CUDA раскладки, и очередь подписки переполнялась. Прогрев (GpuStages.warmup) теперь гоняет и раскладку, на маленькой выдуманной решётке обоими путями: принято 201 из 201, худший кадр 38 мс.

21.6. Образ

PyTorch с библиотеками CUDA — 6.6 ГБ (сами библиотеки NVIDIA 4.3 ГБ, cuDNN и cuBLAS из них почти половина); образ — 9.3 ГБ вместо 2.7. Нашим стадиям cuDNN, cuBLAS и прочие не нужны, но PyTorch без них не загружается. Ставится одним слоем без кэша pip. Колёса — из индекса PyTorch или, где сети нет, с HTTP-адреса TORCH_WHEELS (docker/fetch_wheels.py, docker/wheels/README.md). Через контекст сборки колёса не передаются: первая попытка так делала, и контекст в 4.1 ГБ плюс слой с колёсами плюс установка чуть не заняли весь диск C:.