diff --git a/README.md b/README.md index 240e202..82a091b 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,15 @@ ros2 bag play --read-ahead-queue-size 10 /путь/к/записи ``` - Без видеокарты — та же команда без `--gpus all`, результат тот же. -- Топик с облаком узел находит сам, какое бы имя у него ни было. +- Топик с облаком узел находит сам, какое бы имя у него ни было, и сам подстраивается под QoS + записи: если она публикуется «best effort», переподписывается так же. - `--ipc host` обязателен: без него кадры с хоста до контейнера не доходят вовсе. - Результат для программ — топики `/flyguard/obstacle`, `/flyguard/detected`, `/flyguard/distance` (раздел «Выходные данные»); RViz и схема мозга мухи — раздел «Демонстрация со схемой мозга мухи». +- Всё сразу — узел, проигрывание, RViz и мозг мухи — одной командой из папки репозитория: + `bash docker/demo.sh /папка/с/записями имя_записи`; флаги видеокарты и доступ к экрану + (`xhost`) скрипт подбирает сам. --- @@ -134,18 +138,18 @@ docker load -i flyguard_image.tar.gz # на стенде PyTorch с библиотеками CUDA (раздел «Видеокарта»); архив — около 5 ГБ. Наш готовый архив — [`flyguard_image.tar` на Google Drive](https://drive.google.com/file/d/1fJLz3b0-mmJL1YsTFRozKp1CIKm5f0NX/view?usp=sharing), -4.7 ГБ (4 727 330 816 байт). Из терминала — без браузера и без предупреждения Drive о большом +4.7 ГБ (4 732 318 208 байт). Из терминала — без браузера и без предупреждения Drive о большом файле: ```bash curl -L -o flyguard_image.tar "https://drive.usercontent.google.com/download?id=1fJLz3b0-mmJL1YsTFRozKp1CIKm5f0NX&export=download&confirm=t" ``` ```bash -sha256sum flyguard_image.tar # 410b0f8d00167db8df5de9365e195b0bd934b695660d2cca2a787aae06b81422 +sha256sum flyguard_image.tar # 515bdbc527ccfcaf81c22aed9bc766a7e5438d8c52d4a7501438c9dfe4a3d104 ``` Слои в нём уже сжаты (gzip), поэтому `gzip` поверх не нужен, а `docker load -i` принимает и -`.tar`, и `.tar.gz`. Архив самодостаточный: внутри все 69 слоёв образа, хеш каждого сходится, +`.tar`, и `.tar.gz`. Архив самодостаточный: внутри все 74 слоя образа, хеш каждого сходится, так что чистому движку больше ничего не нужно. Проверено: `docker load` в Docker Desktop 29.8 (Windows 11, WSL 2) — 5.5 мин, образ работает с `--gpus all` и без него. @@ -375,11 +379,15 @@ python tools/check_formats.py ### Демонстрация со схемой мозга мухи ```bash -docker run --rm -it --network host --ipc host -e DISPLAY=$DISPLAY \ +xhost +local: +docker run --rm -it --gpus all --network host --ipc host -e DISPLAY=$DISPLAY \ -v /tmp/.X11-unix:/tmp/.X11-unix -v /path/to/bags:/data flyguard \ ros2 launch flyguard detect.launch.py bag:=/data/doubleT_obstacle rviz:=true brain_view:=true ``` +То же одной командой, с подбором флагов видеокарты под машину: +`bash docker/demo.sh /path/to/bags doubleT_obstacle`. + Топик `/flyguard/brain` отдаёт мозг дрозофилы, подсвеченный **живой активностью**: видно, как загорается ламина на контрасте, лобулярная пластинка на скорости, доли грибовидного тела на новизне и гигантское волокно в момент решения. Вид отключён по умолчанию, чтобы не попадать @@ -456,6 +464,7 @@ ros2 launch flyguard detect.launch.py fov_deg:=35.0 half_width:=1.5 | `h_lo` / `h_hi` | `0.28` / `2.3` | границы основной части габарита по высоте над головкой рельса, м | | `h_top` / `half_width_top` / `top_d_max` | `3.3` / `1.0` / `90` | верхняя секция: от `h_hi` до `h_top`, уже основной и не дальше `top_d_max` — то, что свисает со свода в путь вагона. Ищется отдельным проходом (`top_detect`), в котором свод не склеивается со свисающим; стоящее, что уходит вниз к полу, остаётся основному проходу. Верх 3.3, а не выше: у свода свои кабели и кронштейны, и на 60–90 м ошибка наклона опускает их в секцию. `h_top: 0` — выключить | | `hover_floor` | `0.5` | висящее посреди габарита: для кандидата целиком в габарите, низом выше 0.6 м, ближе 80 м, компактного вдоль пути (до 1.5 м) и не тоньше 0.2 м вероятность считывания не ниже этой. Считывание учили на стоящих предметах, и куб 0.3 м на высоте 1.2 м оно гасило: находился с 31 м, с правилом — с 63 м, ложных тревог столько же. `0` — выключить, см. [EXPERIMENTS](docs/EXPERIMENTS.md) п. 24 | +| `small_rays` | `2` | канал малых целей (LC11): кандидат из двух-трёх лучей, если он висит в пустоте посреди габарита — вся связная компонента не больше 4 лучей (`small_ctx`), низ выше 0.5 м над рельсом (`small_h`), не дальше 0.8 м от оси (`small_u`), фон за ним дальше 5 м (`small_gap`), дальность 45–100 м (`small_d_from` / `small_d_to`). Обычный порог — четыре луча, а куб 0.3 м организаторов с 57 м ложится в две ячейки образа: находился с 45 м, с каналом — с 76 м, ложных тревог столько же. `0` — выключить, см. [EXPERIMENTS](docs/EXPERIMENTS.md) п. 26 | | `flat_h` / `flat_top` / `flat_d` / `flat_w` | `0.06` / `0.26` / `70` / `0.15` | плоское у пола: высотой меньше `flat_h`, целиком ниже `flat_top` над рельсом, не дальше `flat_d` — вес наблюдения умножается на `flat_w`. Пластины на полотне, края жёлоба, порог гермозатвора: организаторы подтвердили, что в жёлобе — не препятствие. `flat_h: 0` — выключить | | `h_lo_core` / `core_from` | `0.16` / `30.0` | пол между рельсами и дальность, с которой он опущен: иначе упавший на пути человек (0.30 м) виден верхушкой в два сантиметра. Ближе 30 м пол прежний — там в полосу попадают головки рельсов, см. [EXPERIMENTS](docs/EXPERIMENTS.md) п. 16.3 | | `d_min` / `d_max` | `4.0` / `220.0` | зона поиска по дальности, м | @@ -472,7 +481,7 @@ ros2 launch flyguard detect.launch.py fov_deg:=35.0 half_width:=1.5 | `split_gap` | `6.0` | разрез компоненты, растёкшейся вдоль стены, по контрасту ламины: гладкая стена даёт ноль по построению, предмет на ней — ступеньку. `0` — выключить | | `split_near` / `split_top` | `55.0` / `1` | ближе какой дальности не резать и сколько фигур выносить из компоненты; одна фигура вместо всех — 6.4 против 9.4 ложных трека на км при той же дальности | | `enable_habituation` | `false` | привыкание внутри проезда. Выключено: измерено, что избирательности нет, см. [EXPERIMENTS](docs/EXPERIMENTS.md) п. 10 | -| `best_effort` | `false` | QoS: RELIABLE. При BEST_EFFORT потеря одного UDP-фрагмента отбрасывает весь 24-мегабайтный кадр | +| `best_effort` | `false` | QoS: RELIABLE. При BEST_EFFORT потеря одного UDP-фрагмента отбрасывает весь 24-мегабайтный кадр. Если же сама запись публикуется «best effort», надёжный подписчик с ней по правилам DDS несовместим и не получил бы ни кадра, — узел это видит и сам переподписывается «best effort» (EXPERIMENTS п. 27) | | `queue_depth` | `10` | глубина очереди подписки | | `raw_subscription` | `true` | брать кадр сырыми байтами CDR, минуя сборку Python-объекта `sensor_msgs` | | `async_worker` | `false` | обрабатывать в отдельном потоке; по умолчанию в колбэке — поток борется за GIL с приёмом | @@ -491,7 +500,7 @@ ros2 launch flyguard detect.launch.py fov_deg:=35.0 half_width:=1.5 | Метрика | Значение | |---|---| | Реальный объект (0.67 × 1.35 м на 55 м) | обнаружен в **99.5 %** кадров | -| Синтетика организаторов (`cloud_with_fake_obj`, 10 предметов) | **9 из 10** верно: из восьми предметов в габарите найдены семь (2×2 посередине — с 95 м, 2×2 сверху — с 87 м, длинный на рельсах — с 79 м, 0.3 на рельсе — с 76 м, 0.3 у края — с 63 м, 0.3 висящий посередине — с 45 м, стержень 5 см с потолка — только с 13 м), оба предмета вне габарита — без тревоги; не найден 2×2, заходящий в габарит на 0.3 м. Ложных тревог за 151 с — три коротких (`tools/eval_org_synth.py`, EXPERIMENTS п. 18–19) | +| Синтетика организаторов (`cloud_with_fake_obj`, 10 предметов) | **9 из 10** верно: из восьми предметов в габарите найдены семь (2×2 посередине — с 95 м, 2×2 сверху — с 87 м, длинный на рельсах — с 79 м, 0.3 на рельсе — с 76 м, 0.3 висящий посередине — с 76 м, 0.3 у края — с 63 м, стержень 5 см с потолка — только с 13 м), оба предмета вне габарита — без тревоги; не найден 2×2, заходящий в габарит на 0.3 м. Ложных тревог за 151 с — три коротких (`tools/eval_org_synth.py`, EXPERIMENTS п. 18–19, 26) | | Ложные тревоги, leave-one-bag-out | **5.7 разных ложных трека на километр** (8.4 % кадров) — без обученного считывания 11.9 | | То же на незнакомой линии, памяти нет | **12.8 на км** (20.7 % кадров) — без считывания 36.1 | | То же на второй половине `new_data` — другой день, 3.41 км, ни в каком виде не видена при обучении, памяти нет | **11.7 на км** (17.9 % кадров) | @@ -514,6 +523,7 @@ ros2 launch flyguard detect.launch.py fov_deg:=35.0 half_width:=1.5 | Вклад резкости считывания 1.5 | ложных на незнакомой линии 23.5 → 14.7 на км, на знакомой 6.5 → 5.7, на второй половине `new_data` 23.9 → 17.4; полигон парно +1 / −142 из 7 600 наблюдений (39 вблизи у края габарита, 82 за 90 м). Сравнение при равной строгости с моделью, обученной на `new_data`, — EXPERIMENTS п. 17.4 | | Вклад габарита 1.2 м и верхней секции (тогда до 3.7 м, теперь до 3.3 — п. 19) | синтетика организаторов 6 → 8 из 10; полигон парно +43 / −33, посторонних 793 → 742; ложных на незнакомой линии 14.7 → 13.8 на км, на знакомой 5.7 → 5.7; цена — ящик на 100 м (P@100 0.50 → 0.40). EXPERIMENTS п. 18.4 | | Вклад пола вероятности для висящего посреди габарита | куб 0.3 м у края на высоте 1.2 м: 31 → 63 м; висящий посередине: 43 → 45 м. Ложных треков столько же на синтетике (3), знакомой линии (5.7 на км), незнакомой (12.8) и `new_data` (11.7); полигон парно +10 / −0. Считывание учили на стоящих предметах, и предмет в воздухе оно гасило. EXPERIMENTS п. 24 | +| Вклад канала малых целей (LC11) | куб 0.3 м, висящий посередине габарита: **45 → 76 м**. С 57 м его точки ложатся в две ячейки образа, а кандидату нужно четыре луча; канал берёт пятно из двух-трёх лучей, если оно висит в пустоте: вся компонента не больше четырёх лучей, низ выше 0.5 м, не дальше 0.8 м от оси, фон за ним дальше 5 м. Ложных столько же на синтетике (3), знакомой линии (5.7 на км), незнакомой (12.8) и `new_data` (11.7); полигон парно 0 / −0, посторонних 758 = 758; время кадра то же. EXPERIMENTS п. 26 | | Вклад сброса при скачке времени | вторая половина `new_data` (70 с выпавших кадров): 17.4 → 13.8 ложного трека на км; на записях без разрывов не меняет ничего. EXPERIMENTS п. 18.9 | | Вклад штрафа за плоское у пола и прохода по верхней секции | синтетика 8 → 9 из 10, фантомов на ней 5 → 3; пустые записи 3 → 2 ложных трека (73 → 45 кадров); незнакомая линия 13.8 → 12.8 на км, вторая половина `new_data` 14.4 → 11.7; знакомая 5.7 → 5.7; полигон парно 0 / −3 (каска), посторонних 742 → 758. EXPERIMENTS п. 19 | | Вклад пола в колее (с переобучением считывания) | лёжа P@50 0.27 → 0.50, P@150 стоя 0.39 → 0.49, ящик 0.06 → 0.26; парно +685 наблюдений против −33; ложных на знакомой линии 8.0 → 8.0, на незнакомой 21.3 → 23.5 | diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md index cb48f1b..655bce1 100644 --- a/docs/ALGORITHM.md +++ b/docs/ALGORITHM.md @@ -194,6 +194,18 @@ LC11 у мухи — детектор мелкого объекта: возбу проценты клеток Кеньона. Измерено: одна фигура вместо всех даёт 6.4 ложных трека на километр против 9.4 **при одинаковой дальности обнаружения**. +**Канал малых целей.** Кандидату нужно четыре луча (за 90 м — три): вблизи обрывок из трёх +лучей — это кусок чего-то большего. Но LC11 у мухи настроен как раз на цель в один-три +омматидия на пустом фоне, и у нас такая цель есть: куб 0.3 м организаторов, висящий посреди +габарита, с 57 м даёт в образе две ячейки. Поэтому у лобулы второй вход +(`lobula._small_targets`): пятно из двух-трёх лучей становится кандидатом, если вся его +связная компонента — не больше четырёх лучей (висит в пустоте, а не отколото от стены или +свода), низ выше 0.5 м над рельсом, |u| ≤ 0.8 м (у края вдали не отличить «внутри» от +«снаружи»), фон за ним дальше 5 м, дальность 45–100 м. Вес такому наблюдению даёт ручная +формула без опоры снизу и без считывания MBON — двухлучевых кандидатов в его выборке не было; +знакомость остаётся. Куб находится с 76 м вместо 45, ложных тревог столько же на всех +проверках (EXPERIMENTS п. 26). + ### 3.7. MUSHROOM BODY — память нормального тоннеля Геометрия честно сообщает обо всём, что торчит в габарит, и вместе с препятствиями выдаёт @@ -323,3 +335,16 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1 компенсируются стабилизацией плоскости, но в модель движения не входят. 6. **LPLC2 считается, но пока не влияет на решение** — канал надвигания подготовлен и визуализируется, его вклад в улику ещё не откалиброван. +7. **Предмет у края габарита вблизи теряется, особенно у платформы.** На полигоне при смещении + 0.9 м от оси крупные предметы видны в 46 % кадров на 0–15 м и в 34 % на 15–40 м (на оси — + 100 % и 82 %), у платформы на 0–15 м — в 12 %. Предмет склеивается с кромкой платформы или + продольной конструкцией у стены в одну компоненту длиной 4–140 м, и она выбрасывается целиком: + разрез по контрасту ближе 55 м запрещён. Разрешить его для растянутых компонент + (`near_long`) — +15 п.п. у края на 15–40 м ценой 12.8 → 16.8 ложных трека на км на незнакомой + линии; не принято (EXPERIMENTS п. 12.7, 26.4). +8. **Предмет, который стоит в основном снаружи и заходит в габарит на десятки сантиметров, + не находится** (на синтетике организаторов — ящик 2 × 2 м, заходящий на 0.3 м). По одному + кадру это та же картина, что шкаф или колонна у края габарита; на пустых записях такая + подпись встречается в 16 местах (EXPERIMENTS п. 20.3). +9. **Прозрачное и тонкое.** Стекло для 905 нм почти прозрачно; стержень 5 см с потолка + находится только с 13 м — он тоньше шага развёртки (0.1° — 17 см на 100 м). diff --git a/docs/EXPERIMENTS.md b/docs/EXPERIMENTS.md index f95d977..224532e 100644 --- a/docs/EXPERIMENTS.md +++ b/docs/EXPERIMENTS.md @@ -3647,3 +3647,198 @@ gpu_background=True)` в узле). Конвейер тем временем с Цена: в редком случае поворота — копия кадра (около 10 мс на 24 МБ); раскладка по углам, как и прежде, дороже раскладки по порядку (п. 18.1). Тестов 63: калибровка при другом порядке точек, облако без яркости, поворот и его обратимость. + +## 26. Канал малых целей в лобуле — LC11 для цели в два луча (28.09) + +Вопрос: что ещё в «мозге» ограничивает дальность на синтетике организаторов. Разбор по поясам +дальности для каждого предмета — кандидат, ближайший к эталону, его вес и улика трека +(как в п. 24.1, но до 130 м и для всех десяти) — дал четыре разных причины: + +| № | предмет | виден с | первое срабатывание | что теряет его дальше | +|---:|---|---:|---:|---| +| 2 | 0.3 висит посередине | 118 м | 45 м | **лобула**: кандидат не рождается | +| 4 | 0.3 висит у края | 85 м | 63 м | считывание MBON (0.02–0.06) и край габарита | +| 8 | 2×2 сверху | 130 м | 87 м | верхняя секция кончается на 90 м | +| 9 | 2×0.2 на рельсах | 129 м | 79 м | вдали он плоский (2 см по высоте) и неотличим от порога гермозатвора | + +### 26.1. Куб в две ячейки + +С 57 до 118 м у куба № 2 в эталоне четыре точки на кадр, а в дальностном образе от них +остаются **две** — одно кольцо, два столбца. Вставленные организаторами точки не лежат на +элевациях колец (п. 18.2), сетчатка раскладывает их по ближайшему кольцу, и пары ложатся в +одну ячейку. Кандидату нужно четыре луча (за 90 м — три, п. 12.5), поэтому его нет вовсе, и +никакое считывание или накопление тут ни при чём. Кольцевая сетка Pandar128 у горизонта — +0.13°, куб 0.3 м на 100 м — 0.17°: настоящий прибор дал бы те же один-два луча по высоте. + +### 26.2. Правило + +LC11 у мухи настроен на цель в один-три омматидия на пустом фоне. В лобуле появился такой же +канал (`lobula._small_targets`): пятно из двух-трёх лучей становится кандидатом, если + +* вся связная компонента, считая контекст над габаритом и по сторонам, не больше 4 лучей — + цель висит в пустоте, а не отколота от стены, свода или пола; +* низ выше 0.5 м над рельсом, и вся она в основной секции габарита; +* |u| ≤ 0.8 м; +* фон за ней дальше 5 м (контраст ламины, переведённый в метры); +* дальность 45–100 м: ближе предмет даёт четыре луча и без канала, дальше ось пути неточна. + +Вес такого наблюдения (`central_complex._small_quality`) — опора с нижним краем 0.5 (как у +висящего в верхней секции), контраст, целостность и знакомость. Считывание MBON его не судит: +кандидатов из двух лучей в его выборке не было ни одного. Опора снизу не требуется — канал как +раз для висящего. Дальше решает, как и для всего, центральный комплекс: трек должен +удерживаться на своём месте кадр за кадром. + +Почему |u| ≤ 0.8, а не вся ширина, — замерено: на всей ширине куб у края (№ 4) находится с 76 м +вместо 63, но куб вне габарита (№ 5) даёт ложную тревогу с 81 м. Вдали ось пути ошибается на +0.2–0.3 м (п. 20.1), и по одному кадру «внутри у края» от «снаружи у края» не отличить. + +Дальше 90 м канал для № 2 не срабатывает не из-за порогов: путь уходит в кривую, и куб в +образе сливается со стеной той же дальности — компонента в 40 тысяч лучей от 4 до 150 м, +контраст ламины ноль. Первый малый кандидат — на 90 м, где за кубом впервые открывается фон. + +### 26.3. Замер + +Все прогоны парные, остальные настройки — умолчания. + +| проверка | без канала | с каналом | +|---|---|---| +| синтетика организаторов | 9 из 10; № 2 с 45.0 м | 9 из 10; **№ 2 с 76.3 м**, остальные — те же дальности | +| ложные треки на синтетике | 3 (24 кадра) | 3 (24 кадра) | +| знакомая линия (LOO) | 5.7 на км, 8.37 % | 5.7 на км, 8.37 % | +| незнакомая линия, памяти нет | 12.8 на км, 20.67 % | 12.8 на км, 20.67 % | +| вторая половина `new_data` | 40 треков, 11.7 на км, 18.0 % | 40 треков, 11.7 на км, 18.0 % | +| полигон, парно | — | 0→1: 0, 1→0: 0; посторонних 758 = 758 | +| `doubleT_obstacle` | 99.5 % | 99.5 % | +| кадр на синтетике, процессор | 52.0 / 55.8 и 52.9 / 61.9 мс | 52.5 / 56.3 и 52.2 / 56.1 мс | + +Малых кандидатов за всё время: 17 на 151 с синтетики, 2 на шести записях с поезда, 5 на 5650 +кадрах `new_data`, 84 на полигоне (15 560 наблюдений, ни одно решение не изменилось). +Стоящие предметы канал не трогает по построению — у них опора снизу. Время кадра не +изменилось: канал считает два `bincount` по уже готовым меткам кластеризации и собирает +кандидатов только для крошечных компонент. + +Что проверено и не взято: + +* **верхняя секция до 130 м** (`top_d_max=130`): «2×2 сверху» 87 → 93 м — мало, чтобы + рисковать сводом на незнакомой линии; +* **канал на всю ширину габарита** — выше; +* **переобучить считывание MBON с висящими предметами** — настоящее лечение для № 4 (и замена + пола из п. 24), но по воронке выигрыш — 63 → около 70 м на одном предмете, а менялось бы всё + считывание; не успевается с четырьмя проверками до сдачи. + +Отдельное наблюдение о синтетике: предмет № 3 («0.3 на рельсе») вдали висит в воздухе — на +81 м его точки на 3.9 м выше, чем вблизи, на 60–80 м подъём около 10 %, чего путь метро не +допускает. Похоже, генератор ставит предмет по высоте того, что видно в этом месте. Если +контрольная запись собрана так же, лежащее на пути вдали может выглядеть висящим, поэтому +висящее посреди габарита стоит видеть — и пол из п. 24, и этот канал об этом. + +### 26.4. Чего не находим — сводка на 28.09 + +Доля кадров с тревогой, когда предмет виден (полигон, итоговая конфигурация, крупные предметы — +человек стоя, сидя, лёжа, чемодан, ящик, ведро): + +| смещение от оси | 0–15 м | 15–40 м | 40–55 м | 55–90 м | 90–130 м | 130–170 м | +|---|---:|---:|---:|---:|---:|---:| +| 0.0 м | 1.00 | 0.82 | 0.60 | 0.70 | 0.30 | 0.25 | +| 0.9 м (у края) | **0.46** | **0.34** | 0.60 | 0.43 | 0.40 | 0.29 | + +У края вблизи хуже всего у платформы: на 0–15 м кандидат рождается в 21 % видимых наблюдений +(без платформы — в 82 %). Разбор ящика у края на записи без платформы: на 12–45 м его лучи — +часть компоненты в 37–41 тысячу лучей длиной 4–140 м (продольная конструкция у стены), и она +выбрасывается целиком; причина та же, что в п. 12.7. + +`near_long` из п. 12.7 перемерен на итоговой конфигурации (`--set near_long=8`), парно: + +| проверка | без | `near_long = 8` | +|---|---|---| +| у края, 15–40 м | 0.34 | 0.49 | +| на оси, 55–90 м | 0.70 | 0.66 | +| полигон: переворотов | — | 0→1: 168, 1→0: 40 | +| полигон: посторонних тревог | 758 | 983 | +| знакомая линия | 5.7 на км, 8.37 % | 6.6 на км, 9.12 % | +| незнакомая линия | 12.8 на км, 20.67 % | 16.8 на км, 22.68 % | +| вторая половина `new_data` | 11.7 на км, 18.0 % | 14.4 на км, 21.3 % | +| синтетика организаторов | 9 из 10, 3 ложных трека | то же | + +Не принято и теперь: четверть лишних ложных тревог за полосу, где при служебном торможении +поезд и так в пределах тормозного пути. + +Остальное, чего мы не находим: + +* **ниже пола габарита** (0.28 м, в колее дальше 30 м — 0.16 м): камень 22 см, каска, бутылка, + кабель 6 см — на полигоне 0 на любой дальности. Это граница габарита, а не слепота: по ответу + организаторов лежащее в жёлобе препятствием не считается, а их предметы на рельсах (0.3 м и + брус 0.2 м) находятся; +* **заходящее в габарит снаружи** (№ 6) — п. 20.3; +* **вдали плоское на полотне** — брус 2 × 0.2 м организаторов за 80 м (2 см по высоте в + образе) неотличим от порога гермозатвора; находится с 79 м; +* **тонкое** — стержень 5 см, с 13 м; **прозрачное** — стекло (README, «Про стекло честно»); +* **за изгибом тоннеля** — дальше 130–170 м обнаружение 0.25–0.45, за 170 м около нуля: линии + взгляда нет (п. 3); +* **лежащий человек за 90 м** — 0.04 на оси, **ведро за 90 м** — 0; +* **ближе 4 м** (`d_min`) и в тени деталей самого поезда (п. 18.11); первые 11 кадров после + запуска — калибровка решётки. + +Не проверялось вовсе, данных нет: предмет, движущийся вдоль пути навстречу (тележка, человек +бегом), дым, пыль, вода на полотне. + +Выключить — `small_rays: 0` в `config/flyguard.yaml` или `--set small_rays=0` у инструментов. +Тестов 66: канал рождает кандидата из двух лучей в пустоте и не рождает его у стены, у края +габарита, у полотна, с близким фоном и вне своего диапазона дальности; вес не зависит от MBON и +гасится знакомостью. + +## 27. Чужая запись: QoS «best effort», подсказка про сеть, образ 28.09 + +Вопрос пользователя: запустит ли жюри решение на своей записи. Формат облака, имя топика, +раскладка скана и высота лидара уже подстраиваются сами (п. 18.1, 25). Оставались две вещи, +при которых узел молча не получает ни одного кадра. + +### 27.1. Запись, опубликованная «best effort» + +По правилам DDS надёжный подписчик с издателем «best effort» несовместим — соединения нет +вовсе. Узел подписывается надёжно (`best_effort: false`: при «best effort» потеря одного +UDP-фрагмента выбрасывает весь 24-мегабайтный кадр), а `ros2 bag play` публикует с тем QoS, +который записан в бэге. Все шесть выданных записей и синтетика организаторов записаны надёжно +(`reliability: 1` в `metadata.yaml`), но драйвер лидара обычно публикует облако как «best +effort», и запись с живого поезда может прийти такой. + +Теперь сторож входа, увидев издателя «best effort» на своём топике, переподписывается так же и +пишет об этом в журнал (`node._match_publisher_qos`); при надёжном издателе ничего не меняется. +Проверка — та же запись, проигранная «best effort» (`--qos-profile-overrides-path`): + +| образ | принято кадров | +|---|---:| +| прежний (26.09) | **0 из 252** | +| новый | **252 из 252**, в журнале — предупреждение о переподписке | + +### 27.2. Контейнер без `--network host` + +Без него обнаружение DDS до хоста не доходит, издателя узел не видит и молча ждёт — снаружи это +неотличимо от пустого тоннеля. Если издателя нет ни на одном топике 15 с, узел пишет раз в +30 с, что при проигрывании на хосте контейнер нужен с `--network host --ipc host`. Проверено: +контейнер без флага — предупреждение через 15 с. + +### 27.3. Образ и архив + +Образ пересобран поверх прежнего (`flyguard:2609`, 26.09: слои apt и PyTorch те же, код — +заново) своим dockerd в WSL: `c49ded640d29`. Архив `flyguard_image.tar` — 4 732 318 208 байт, +sha256 `515bdbc527ccfcaf81c22aed9bc766a7e5438d8c52d4a7501438c9dfe4a3d104`; 65 blob, хеш +каждого сходится, индекс указывает на `c49ded640d29`, 74 слоя. + +В контейнере: + +| проверка | результат | +|---|---| +| `doubleT_obstacle`, видеокарта (4 прогона, старый и новый образ попеременно) | 201 из 201, тревога в 189 кадрах из 190, 55.8 м — во всех | +| синтетика, процессор | 1510 из 1510; «0.3 посередине» — `ПРЕПЯТСТВИЕ: 76.3 м` в консоли узла | +| ядро уровня жюри (квота 0.61), синтетика, старый / новый образ парно | 69.9 / 80.9 и 67.8 / 77.5 мс против 68.9 / 77.7 и 69.4 / 79.0 мс; 1510 из 1510 во всех четырёх | +| ядро уровня жюри, `doubleT_obstacle`, старый / новый образ парно, по три прогона | медиана 59.5, 66.9, 72.0 мс против 63.2, 71.2, 55.6; принято 185, 201, 174 из 201 против 170, 198, 201 (26.09–27.09: 55 / 65 мс, 201 из 201) | + +Абсолютное время в WSL 28.09 гуляло между одинаковыми прогонами сильнее, чем меняет код: тот +же прежний образ на видеокарте — 29.2 и 45.0 мс на `doubleT_obstacle`, новый на процессоре на +синтетике — 44.8 и 60.2 мс; кадры терялись и у прежнего образа (на `doubleT_obstacle` до 27 из +201), чего 26–27.09 не было ни разу. WSL перезапускался, кэш страниц у него почти пуст, и запись +(24 МБ на кадр, 240 МБ/с) каждый раз читается с диска, заполненного на 98 %: проигрыватель +отстаёт и отдаёт кадры пачками, а узел под квотой не успевает их принять. Поэтому о цене правок судим по парным прогонам (они совпадают) и по +прямому замеру канала малых целей — около 1 мс на кадр (п. 26.3). Цифры времени в README +оставлены прежними. diff --git a/flyguard/central_complex.py b/flyguard/central_complex.py index eb0c12e..678e354 100644 --- a/flyguard/central_complex.py +++ b/flyguard/central_complex.py @@ -331,6 +331,8 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0, # дальности. Пояс над вагоном в исправном тоннеле пуст, поэтому нижний # край для висящего поднят до 0.5. EXPERIMENTS п. 19. top = c.h_min >= top_from + if c.extra and c.extra.get("small"): + return _small_quality(c, nov_fade_from, nov_fade_to) support = float(np.clip(c.n_rays / expected_rays(c.d), 0.5 if top else 0.25, 1.0)) # Контраст к фону — главная улика вблизи и недоступная вдали. На 170 м @@ -424,6 +426,30 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0, return float(np.clip(hand * novel, 0.0, 1.0)) +def _small_quality(c: Candidate, nov_fade_from: float, nov_fade_to: float) -> float: + """Вес наблюдения из канала малых целей (`lobula._small_targets`). + + Лобула уже потребовала от такого кандидата всё, что отличает висящий в + пустоте предмет от конструкции: крошечная компонента, низ над полотном, + ближе к оси, далёкий фон за ним. Поэтому из ручной формулы остаются опора + (с тем же нижним краем 0.5, что у висящего сверху: предмет в два луча — не + сомнительный, а маленький), контраст и целостность, а опора снизу и + компактность — нет: первая наказала бы как раз висящее, вторая у двух + лучей всегда единица. Обученное считывание MBON такой цели не судит: его + выборка собрана при обычном пороге, и кандидатов из двух лучей в ней не + было ни одного. Знакомость остаётся — память тоннеля по-прежнему гасит + то, что на этом месте видела. + """ + support = float(np.clip(c.n_rays / expected_rays(c.d), 0.5, 1.0)) + contrast = float(np.clip(c.gap / 3.0, 0.2, 1.0)) + whole = float(np.clip((c.containment - 0.25) / 0.45, 0.05, 1.0)) + novel = novelty_gain(c.novelty) + if nov_fade_from > 0.0 and c.d > nov_fade_from: + span = max(nov_fade_to - nov_fade_from, 1e-3) + novel = novel ** (1.0 - min((c.d - nov_fade_from) / span, 1.0)) + return float(np.clip(support * contrast * whole * novel, 0.0, 1.0)) + + def novelty_gain(novelty: float, lo: float = 0.15, hi: float = 0.50, floor: float = 0.05) -> float: """Ответ MBON → множитель улики. diff --git a/flyguard/lobula.py b/flyguard/lobula.py index 39d2522..cfbc49f 100644 --- a/flyguard/lobula.py +++ b/flyguard/lobula.py @@ -292,7 +292,10 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, platform_h_hi: float = 1.25, h_top: float = 0.0, half_width_top: float = 0.0, top_d_max: float = 90.0, top_detect: bool = False, - top_min_rays: int = 3, cluster=None) -> list[Candidate]: + top_min_rays: int = 3, cluster=None, + small_rays: int = 0, small_ctx: int = 4, small_h: float = 0.5, + small_u: float = 0.8, small_gap: float = 5.0, + small_d: tuple[float, float] = (45.0, 100.0)) -> list[Candidate]: """Выделить кандидатов: связные пятна лучей, попавших в габарит. Кластеризация ведётся по **расширенной** области, а членство в габарите @@ -300,6 +303,9 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, срезанный границей коридора, неотличим от предмета: обрезка сама создаёт компактное пятно нужного размера. Доля лучей, оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край стены. + + `small_rays` > 0 включает канал малых целей (`_small_targets`): пятно из + двух-трёх лучей, висящее в пустом пространстве габарита. """ if masks is None: masks = gauge_mask(tf, corridor, half_width=half_width, h_lo=h_lo, @@ -342,6 +348,14 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, labels, n = (cluster or cluster_by_depth)(context, tf.r, col_reach=dilate_cols + 1) if n == 0: return [] + # До разреза по фигуре: крошечную компоненту он не меняет, а метки после + # него перенумерованы. + small = (_small_targets(labels, n, inside, tf, lat, gap_img, h_hi=h_hi, + min_rays=min_rays, min_rays_far=min_rays_far, + min_rays_far_from=min_rays_far_from, + rays=small_rays, max_ctx=small_ctx, h_min=small_h, + u_max=small_u, gap_min=small_gap, d_range=small_d) + if small_rays > 0 else []) if split_gap > 0: # Контраст к окружению как признак фигуры. Если задан и порог движения, # выполняются оба условия сразу: стена должна и приближаться как предмет, @@ -402,7 +416,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, need = np.where(dmean >= min_rays_far_from, float(min_rays_far), float(min_rays)) keep = np.flatnonzero(inner >= need) - if keep.size == 0: + if keep.size == 0 and not small: return [] if keep.size > max_candidates: keep = keep[np.argsort(inner[keep])[::-1][:max_candidates]] @@ -471,7 +485,15 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, if cand is not None: out.append(cand) - if use_top: + for sel, n_ctx in small: + cand = build(sel, n_ctx) + if cand is not None: + cand.extra["small"] = True + out.append(cand) + + # `keep.size` — как было до канала малых целей: в кадре без обычных + # кандидатов проход по верхней секции не запускался и теперь не запускается + if use_top and keep.size: out += _hanging(tf, abs_lat, in_range, build, h_hi=h_hi, h_top=h_top, half_width_top=half_width_top, top_d_max=top_d_max, min_rays=top_min_rays, col_reach=dilate_cols + 1) @@ -480,6 +502,78 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, return out +def _small_targets(labels: np.ndarray, n: int, inside: np.ndarray, tf: TrackFrame, + lat: np.ndarray, gap_img: np.ndarray, *, h_hi: float, + min_rays: int, min_rays_far: int, min_rays_far_from: float, + rays: int, max_ctx: int, h_min: float, u_max: float, + gap_min: float, d_range: tuple[float, float]) -> list: + """Канал малых целей — то, чем LC11 и занят у мухи. + + LC11 настроен на цель в один-три омматидия на пустом фоне и молчит, когда + пятно крупнее или сливается с окружением. Обычный порог кандидата (четыре + луча, за 90 м — три) такую цель не пропускает вовсе: куб 0.3 м из синтетики + организаторов висит посреди габарита, и с 57 до 118 м его четыре точки + ложатся в ДВЕ ячейки дальностного образа — одно кольцо, два столбца. + Кандидат не рождался, и первое срабатывание было на 45 м. + + Два-три луча — мало, поэтому остальное требуется строго, и каждое + требование отсекает свой источник ложных тревог: + + * вся связная компонента крошечная (`max_ctx` лучей, считая контекст над + габаритом и по сторонам): цель висит в пустоте, а не отколота от стены, + свода или пола; + * низ выше `h_min` над рельсом и вся — в основной секции габарита: + полотно и кромки на нём сюда не попадают, свисающее сверху ведёт свой + проход (`_hanging`); + * |u| не больше `u_max`: у края габарита вдали не различить, внутри + предмет или снаружи, — ошибка оси на 60–100 м 0.2–0.3 м (EXPERIMENTS + п. 20.1). На всей ширине куб у края находился с 76 м вместо 63, но куб + вне габарита давал ложную тревогу с 81 м; + * за ней далеко (`gap_min`, м): сзади должен быть фон, а не стена на той + же дальности; + * дальность в `d_range`: ближе предмет и так даёт четыре луча, дальше ось + пути неточна. + + Вес такому наблюдению даёт `central_complex._quality` (ветка `small`), а + копится оно, как и всё, в центральном комплексе: одиночная вспышка трека + не поднимет, цель должна держаться на своём месте кадр за кадром. + Возвращает список (лучи в габарите, размер компоненты). EXPERIMENTS п. 26. + """ + flat = labels.ravel() + ins = inside.ravel() + counts = np.bincount(flat, minlength=n + 1) + inner = np.bincount(flat, weights=ins.astype(np.float64), minlength=n + 1) + counts[0] = 0 + pick = (inner >= rays) & (counts <= max_ctx) + pick[0] = False + if not pick.any(): + return [] + idx = np.flatnonzero(pick[flat]) # лучи отобранных компонент + lab = flat[idx] + o = np.argsort(lab, kind="stable") + idx, lab = idx[o], lab[o] + d_all, h_all = tf.d.ravel(), tf.h.ravel() + u_all, g_all = lat.ravel(), gap_img.ravel() + out = [] + for grp in np.split(idx, np.flatnonzero(np.diff(lab)) + 1): + sel = grp[ins[grp]] + d = float(d_all[sel].mean()) + need = min_rays_far if (min_rays_far > 0 and d >= min_rays_far_from) else min_rays + if sel.size >= need: + continue # это обычный кандидат + if not (d_range[0] <= d <= d_range[1]): + continue + h = h_all[sel] + if h.min() < h_min or h.max() >= h_hi: + continue + if abs(float(np.median(u_all[sel]))) > u_max: + continue + if float(np.median(g_all[sel])) < gap_min: + continue + out.append((sel, grp.size)) + return out + + def _hanging(tf: TrackFrame, abs_lat, in_range, build, *, h_hi: float, h_top: float, half_width_top: float, top_d_max: float, min_rays: int, col_reach: int, margin_u: float = 0.3, below: float = 0.4) -> list[Candidate]: diff --git a/flyguard/node.py b/flyguard/node.py index 317ac12..9b543a4 100644 --- a/flyguard/node.py +++ b/flyguard/node.py @@ -67,6 +67,8 @@ class FlyGuardNode(Node): ("half_width_top", 1.0), # висящее посреди габарита: пол вероятности считывания, 0 — выключить ("hover_floor", 0.5), + # канал малых целей (LC11): кандидат из 2–3 лучей в пустоте габарита, 0 — выключить + ("small_rays", 2), ("top_d_max", 90.0), ("h_lo_core", 0.16), ("core_from", 30.0), @@ -104,6 +106,7 @@ class FlyGuardNode(Node): h_top=float(self.par["h_top"]), half_width_top=float(self.par["half_width_top"]), hover_floor=float(self.par["hover_floor"]), + small_rays=int(self.par["small_rays"]), top_d_max=float(self.par["top_d_max"]), h_lo_core=float(self.par["h_lo_core"]), core_from=float(self.par["core_from"]), @@ -206,6 +209,7 @@ class FlyGuardNode(Node): # Без подсказки такой запуск выглядит как пустой тоннель. self._topics = topics self._pub_seen_at: float | None = None + self._started_at = time.monotonic() self._watch = self.create_timer(1.0, self._check_input) self.pub_status = self.create_publisher(ObstacleStatus, "/flyguard/obstacle", 10) @@ -254,7 +258,17 @@ class FlyGuardNode(Node): pubs = sum(self.count_publishers(t) for t in self._topics) if not pubs: self._adopt_foreign_cloud() + # Издателей не видно вовсе: запись ещё не запущена — или контейнер без + # --network host, и обнаружение DDS до хоста не доходит. Без подсказки + # второе выглядит как узел, который просто ждёт. + waited = time.monotonic() - self._started_at + if waited >= 15.0: + self.get_logger().warn( + f"облака точек нет уже {waited:.0f} с: издателя не видно ни на одном " + f"топике. Если запись проигрывается на хосте, контейнер нужен с " + f"--network host --ipc host", throttle_duration_sec=30.0) return + self._match_publisher_qos() now = time.monotonic() if self._pub_seen_at is None: self._pub_seen_at = now @@ -265,6 +279,39 @@ class FlyGuardNode(Node): f"запустите контейнер с --ipc host: без него кадры идут через " f"/dev/shm, общей у контейнера с хостом нет", throttle_duration_sec=10.0) + def _match_publisher_qos(self) -> None: + """Издатель шлёт облако «best effort» — подписаться так же. + + Надёжный подписчик с таким издателем по правилам DDS несовместим: кадры + не придут вовсе, и узел будет выглядеть как пустой тоннель. Наши записи + и синтетика организаторов записаны с надёжной доставкой, а + `ros2 bag play` публикует с записанным QoS, — но драйвер лидара обычно + публикует облако как «best effort», и запись с живого поезда может + прийти такой. Подписчик «best effort» принимает от издателей обоих + видов, однако крупный кадр надёжнее доставляется надёжно (конфиг, + `best_effort`), поэтому переключение — только по факту несовместимости. + """ + if self._qos.reliability != QoSReliabilityPolicy.RELIABLE: + return + loose = sorted({t for t in self._topics + for info in self.get_publishers_info_by_topic(t) + if info.qos_profile.reliability == QoSReliabilityPolicy.BEST_EFFORT}) + if not loose: + return + self._qos = QoSProfile(history=self._qos.history, depth=self._qos.depth, + reliability=QoSReliabilityPolicy.BEST_EFFORT, + durability=self._qos.durability) + for sub in self.subs: + self.destroy_subscription(sub) + self.subs = [self.create_subscription(PointCloud2, t, self._on_cloud, self._qos, + raw=self.raw) + for t in self._topics] + self._pub_seen_at = None + self.get_logger().warn( + f"{', '.join(loose)}: издатель шлёт облако «best effort», а узел ждал " + f"надёжной доставки — такие не соединяются вовсе. Переподписался " + f"«best effort» (явно: best_effort:=true)") + def _adopt_foreign_cloud(self) -> None: """Облако идёт в топик, которого нет в списке, — подписаться и на него. diff --git a/flyguard/pipeline.py b/flyguard/pipeline.py index 312fc68..15f45ae 100644 --- a/flyguard/pipeline.py +++ b/flyguard/pipeline.py @@ -167,6 +167,20 @@ class Params: hover_d: float = 80.0 # дальше — не действует: ось пути вдали неточна hover_depth: float = 1.5 # протяжённость вдоль пути до, м: длиннее — труба, поручень hover_width: float = 0.2 # ширина от, м: уже — провод, кронштейн, тонкая деталь + # Канал малых целей (LC11, см. lobula._small_targets): кандидат из двух-трёх + # лучей, если он висит в пустом пространстве габарита — вся связная + # компонента не больше `small_ctx` лучей, низ выше `small_h` над рельсом, + # |u| не больше `small_u`, фон за ним дальше `small_gap`, дальность от + # `small_d_from` до `small_d_to`. Куб 0.3 м посередине синтетики + # организаторов при обычном пороге в четыре луча находился с 45 м: с 57 м + # его точки ложатся в две ячейки образа. 0 — выключено. EXPERIMENTS п. 26. + small_rays: int = 2 + small_ctx: int = 4 + small_h: float = 0.5 + small_u: float = 0.8 + small_gap: float = 5.0 + small_d_from: float = 45.0 + small_d_to: float = 100.0 # Поправка оценки модели на распространённость предметов: за этой # дальностью из логита вычитается типичный для полосы. 0 — не # поправлять. Замер — EXPERIMENTS п. 12.3. @@ -589,7 +603,14 @@ class FlyGuard: half_width_top=self.p.half_width_top, top_d_max=self.p.top_d_max, top_detect=self.p.top_detect, - cluster=self._cluster) + cluster=self._cluster, + small_rays=self.p.small_rays, + small_ctx=self.p.small_ctx, + small_h=self.p.small_h, + small_u=self.p.small_u, + small_gap=self.p.small_gap, + small_d=(self.p.small_d_from, + self.p.small_d_to)) with t("mushroom"): cands = self.memory.annotate(cands) diff --git a/ros2_ws/src/flyguard/config/flyguard.yaml b/ros2_ws/src/flyguard/config/flyguard.yaml index 7ec97ff..f233ac5 100644 --- a/ros2_ws/src/flyguard/config/flyguard.yaml +++ b/ros2_ws/src/flyguard/config/flyguard.yaml @@ -41,6 +41,13 @@ flyguard: # 0.3 м на высоте 1.2 м синтетики находится с 63 м вместо 31, ложных # тревог столько же. 0 — выключить. EXPERIMENTS п. 24. hover_floor: 0.5 + # Канал малых целей (LC11): кандидат из двух-трёх лучей, если он висит в + # пустоте посреди габарита — вся связная компонента не больше 4 лучей, низ + # выше 0.5 м над рельсом, |u| до 0.8 м, фон за ним дальше 5 м, 45–100 м. + # Обычный порог — четыре луча, а куб 0.3 м организаторов с 57 м ложится в + # две ячейки образа: находился с 45 м, с каналом — с 76 м; ложных тревог + # столько же на всех проверках. 0 — выключить. EXPERIMENTS п. 26. + small_rays: 2 # Пол между рельсами (|u| <= 0.85 м) опущен, но только дальше core_from: # лежащий человек высотой 0.30 м при поле 0.28 м виден верхушкой в два # сантиметра. Ближе пол прежний — там в полосу 0.16…0.28 м попадают diff --git a/ros2_ws/src/flyguard/flyguard/central_complex.py b/ros2_ws/src/flyguard/flyguard/central_complex.py index eb0c12e..678e354 100644 --- a/ros2_ws/src/flyguard/flyguard/central_complex.py +++ b/ros2_ws/src/flyguard/flyguard/central_complex.py @@ -331,6 +331,8 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0, # дальности. Пояс над вагоном в исправном тоннеле пуст, поэтому нижний # край для висящего поднят до 0.5. EXPERIMENTS п. 19. top = c.h_min >= top_from + if c.extra and c.extra.get("small"): + return _small_quality(c, nov_fade_from, nov_fade_to) support = float(np.clip(c.n_rays / expected_rays(c.d), 0.5 if top else 0.25, 1.0)) # Контраст к фону — главная улика вблизи и недоступная вдали. На 170 м @@ -424,6 +426,30 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0, return float(np.clip(hand * novel, 0.0, 1.0)) +def _small_quality(c: Candidate, nov_fade_from: float, nov_fade_to: float) -> float: + """Вес наблюдения из канала малых целей (`lobula._small_targets`). + + Лобула уже потребовала от такого кандидата всё, что отличает висящий в + пустоте предмет от конструкции: крошечная компонента, низ над полотном, + ближе к оси, далёкий фон за ним. Поэтому из ручной формулы остаются опора + (с тем же нижним краем 0.5, что у висящего сверху: предмет в два луча — не + сомнительный, а маленький), контраст и целостность, а опора снизу и + компактность — нет: первая наказала бы как раз висящее, вторая у двух + лучей всегда единица. Обученное считывание MBON такой цели не судит: его + выборка собрана при обычном пороге, и кандидатов из двух лучей в ней не + было ни одного. Знакомость остаётся — память тоннеля по-прежнему гасит + то, что на этом месте видела. + """ + support = float(np.clip(c.n_rays / expected_rays(c.d), 0.5, 1.0)) + contrast = float(np.clip(c.gap / 3.0, 0.2, 1.0)) + whole = float(np.clip((c.containment - 0.25) / 0.45, 0.05, 1.0)) + novel = novelty_gain(c.novelty) + if nov_fade_from > 0.0 and c.d > nov_fade_from: + span = max(nov_fade_to - nov_fade_from, 1e-3) + novel = novel ** (1.0 - min((c.d - nov_fade_from) / span, 1.0)) + return float(np.clip(support * contrast * whole * novel, 0.0, 1.0)) + + def novelty_gain(novelty: float, lo: float = 0.15, hi: float = 0.50, floor: float = 0.05) -> float: """Ответ MBON → множитель улики. diff --git a/ros2_ws/src/flyguard/flyguard/lobula.py b/ros2_ws/src/flyguard/flyguard/lobula.py index 39d2522..cfbc49f 100644 --- a/ros2_ws/src/flyguard/flyguard/lobula.py +++ b/ros2_ws/src/flyguard/flyguard/lobula.py @@ -292,7 +292,10 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, platform_h_hi: float = 1.25, h_top: float = 0.0, half_width_top: float = 0.0, top_d_max: float = 90.0, top_detect: bool = False, - top_min_rays: int = 3, cluster=None) -> list[Candidate]: + top_min_rays: int = 3, cluster=None, + small_rays: int = 0, small_ctx: int = 4, small_h: float = 0.5, + small_u: float = 0.8, small_gap: float = 5.0, + small_d: tuple[float, float] = (45.0, 100.0)) -> list[Candidate]: """Выделить кандидатов: связные пятна лучей, попавших в габарит. Кластеризация ведётся по **расширенной** области, а членство в габарите @@ -300,6 +303,9 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, срезанный границей коридора, неотличим от предмета: обрезка сама создаёт компактное пятно нужного размера. Доля лучей, оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край стены. + + `small_rays` > 0 включает канал малых целей (`_small_targets`): пятно из + двух-трёх лучей, висящее в пустом пространстве габарита. """ if masks is None: masks = gauge_mask(tf, corridor, half_width=half_width, h_lo=h_lo, @@ -342,6 +348,14 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, labels, n = (cluster or cluster_by_depth)(context, tf.r, col_reach=dilate_cols + 1) if n == 0: return [] + # До разреза по фигуре: крошечную компоненту он не меняет, а метки после + # него перенумерованы. + small = (_small_targets(labels, n, inside, tf, lat, gap_img, h_hi=h_hi, + min_rays=min_rays, min_rays_far=min_rays_far, + min_rays_far_from=min_rays_far_from, + rays=small_rays, max_ctx=small_ctx, h_min=small_h, + u_max=small_u, gap_min=small_gap, d_range=small_d) + if small_rays > 0 else []) if split_gap > 0: # Контраст к окружению как признак фигуры. Если задан и порог движения, # выполняются оба условия сразу: стена должна и приближаться как предмет, @@ -402,7 +416,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, need = np.where(dmean >= min_rays_far_from, float(min_rays_far), float(min_rays)) keep = np.flatnonzero(inner >= need) - if keep.size == 0: + if keep.size == 0 and not small: return [] if keep.size > max_candidates: keep = keep[np.argsort(inner[keep])[::-1][:max_candidates]] @@ -471,7 +485,15 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, if cand is not None: out.append(cand) - if use_top: + for sel, n_ctx in small: + cand = build(sel, n_ctx) + if cand is not None: + cand.extra["small"] = True + out.append(cand) + + # `keep.size` — как было до канала малых целей: в кадре без обычных + # кандидатов проход по верхней секции не запускался и теперь не запускается + if use_top and keep.size: out += _hanging(tf, abs_lat, in_range, build, h_hi=h_hi, h_top=h_top, half_width_top=half_width_top, top_d_max=top_d_max, min_rays=top_min_rays, col_reach=dilate_cols + 1) @@ -480,6 +502,78 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, return out +def _small_targets(labels: np.ndarray, n: int, inside: np.ndarray, tf: TrackFrame, + lat: np.ndarray, gap_img: np.ndarray, *, h_hi: float, + min_rays: int, min_rays_far: int, min_rays_far_from: float, + rays: int, max_ctx: int, h_min: float, u_max: float, + gap_min: float, d_range: tuple[float, float]) -> list: + """Канал малых целей — то, чем LC11 и занят у мухи. + + LC11 настроен на цель в один-три омматидия на пустом фоне и молчит, когда + пятно крупнее или сливается с окружением. Обычный порог кандидата (четыре + луча, за 90 м — три) такую цель не пропускает вовсе: куб 0.3 м из синтетики + организаторов висит посреди габарита, и с 57 до 118 м его четыре точки + ложатся в ДВЕ ячейки дальностного образа — одно кольцо, два столбца. + Кандидат не рождался, и первое срабатывание было на 45 м. + + Два-три луча — мало, поэтому остальное требуется строго, и каждое + требование отсекает свой источник ложных тревог: + + * вся связная компонента крошечная (`max_ctx` лучей, считая контекст над + габаритом и по сторонам): цель висит в пустоте, а не отколота от стены, + свода или пола; + * низ выше `h_min` над рельсом и вся — в основной секции габарита: + полотно и кромки на нём сюда не попадают, свисающее сверху ведёт свой + проход (`_hanging`); + * |u| не больше `u_max`: у края габарита вдали не различить, внутри + предмет или снаружи, — ошибка оси на 60–100 м 0.2–0.3 м (EXPERIMENTS + п. 20.1). На всей ширине куб у края находился с 76 м вместо 63, но куб + вне габарита давал ложную тревогу с 81 м; + * за ней далеко (`gap_min`, м): сзади должен быть фон, а не стена на той + же дальности; + * дальность в `d_range`: ближе предмет и так даёт четыре луча, дальше ось + пути неточна. + + Вес такому наблюдению даёт `central_complex._quality` (ветка `small`), а + копится оно, как и всё, в центральном комплексе: одиночная вспышка трека + не поднимет, цель должна держаться на своём месте кадр за кадром. + Возвращает список (лучи в габарите, размер компоненты). EXPERIMENTS п. 26. + """ + flat = labels.ravel() + ins = inside.ravel() + counts = np.bincount(flat, minlength=n + 1) + inner = np.bincount(flat, weights=ins.astype(np.float64), minlength=n + 1) + counts[0] = 0 + pick = (inner >= rays) & (counts <= max_ctx) + pick[0] = False + if not pick.any(): + return [] + idx = np.flatnonzero(pick[flat]) # лучи отобранных компонент + lab = flat[idx] + o = np.argsort(lab, kind="stable") + idx, lab = idx[o], lab[o] + d_all, h_all = tf.d.ravel(), tf.h.ravel() + u_all, g_all = lat.ravel(), gap_img.ravel() + out = [] + for grp in np.split(idx, np.flatnonzero(np.diff(lab)) + 1): + sel = grp[ins[grp]] + d = float(d_all[sel].mean()) + need = min_rays_far if (min_rays_far > 0 and d >= min_rays_far_from) else min_rays + if sel.size >= need: + continue # это обычный кандидат + if not (d_range[0] <= d <= d_range[1]): + continue + h = h_all[sel] + if h.min() < h_min or h.max() >= h_hi: + continue + if abs(float(np.median(u_all[sel]))) > u_max: + continue + if float(np.median(g_all[sel])) < gap_min: + continue + out.append((sel, grp.size)) + return out + + def _hanging(tf: TrackFrame, abs_lat, in_range, build, *, h_hi: float, h_top: float, half_width_top: float, top_d_max: float, min_rays: int, col_reach: int, margin_u: float = 0.3, below: float = 0.4) -> list[Candidate]: diff --git a/ros2_ws/src/flyguard/flyguard/node.py b/ros2_ws/src/flyguard/flyguard/node.py index 317ac12..9b543a4 100644 --- a/ros2_ws/src/flyguard/flyguard/node.py +++ b/ros2_ws/src/flyguard/flyguard/node.py @@ -67,6 +67,8 @@ class FlyGuardNode(Node): ("half_width_top", 1.0), # висящее посреди габарита: пол вероятности считывания, 0 — выключить ("hover_floor", 0.5), + # канал малых целей (LC11): кандидат из 2–3 лучей в пустоте габарита, 0 — выключить + ("small_rays", 2), ("top_d_max", 90.0), ("h_lo_core", 0.16), ("core_from", 30.0), @@ -104,6 +106,7 @@ class FlyGuardNode(Node): h_top=float(self.par["h_top"]), half_width_top=float(self.par["half_width_top"]), hover_floor=float(self.par["hover_floor"]), + small_rays=int(self.par["small_rays"]), top_d_max=float(self.par["top_d_max"]), h_lo_core=float(self.par["h_lo_core"]), core_from=float(self.par["core_from"]), @@ -206,6 +209,7 @@ class FlyGuardNode(Node): # Без подсказки такой запуск выглядит как пустой тоннель. self._topics = topics self._pub_seen_at: float | None = None + self._started_at = time.monotonic() self._watch = self.create_timer(1.0, self._check_input) self.pub_status = self.create_publisher(ObstacleStatus, "/flyguard/obstacle", 10) @@ -254,7 +258,17 @@ class FlyGuardNode(Node): pubs = sum(self.count_publishers(t) for t in self._topics) if not pubs: self._adopt_foreign_cloud() + # Издателей не видно вовсе: запись ещё не запущена — или контейнер без + # --network host, и обнаружение DDS до хоста не доходит. Без подсказки + # второе выглядит как узел, который просто ждёт. + waited = time.monotonic() - self._started_at + if waited >= 15.0: + self.get_logger().warn( + f"облака точек нет уже {waited:.0f} с: издателя не видно ни на одном " + f"топике. Если запись проигрывается на хосте, контейнер нужен с " + f"--network host --ipc host", throttle_duration_sec=30.0) return + self._match_publisher_qos() now = time.monotonic() if self._pub_seen_at is None: self._pub_seen_at = now @@ -265,6 +279,39 @@ class FlyGuardNode(Node): f"запустите контейнер с --ipc host: без него кадры идут через " f"/dev/shm, общей у контейнера с хостом нет", throttle_duration_sec=10.0) + def _match_publisher_qos(self) -> None: + """Издатель шлёт облако «best effort» — подписаться так же. + + Надёжный подписчик с таким издателем по правилам DDS несовместим: кадры + не придут вовсе, и узел будет выглядеть как пустой тоннель. Наши записи + и синтетика организаторов записаны с надёжной доставкой, а + `ros2 bag play` публикует с записанным QoS, — но драйвер лидара обычно + публикует облако как «best effort», и запись с живого поезда может + прийти такой. Подписчик «best effort» принимает от издателей обоих + видов, однако крупный кадр надёжнее доставляется надёжно (конфиг, + `best_effort`), поэтому переключение — только по факту несовместимости. + """ + if self._qos.reliability != QoSReliabilityPolicy.RELIABLE: + return + loose = sorted({t for t in self._topics + for info in self.get_publishers_info_by_topic(t) + if info.qos_profile.reliability == QoSReliabilityPolicy.BEST_EFFORT}) + if not loose: + return + self._qos = QoSProfile(history=self._qos.history, depth=self._qos.depth, + reliability=QoSReliabilityPolicy.BEST_EFFORT, + durability=self._qos.durability) + for sub in self.subs: + self.destroy_subscription(sub) + self.subs = [self.create_subscription(PointCloud2, t, self._on_cloud, self._qos, + raw=self.raw) + for t in self._topics] + self._pub_seen_at = None + self.get_logger().warn( + f"{', '.join(loose)}: издатель шлёт облако «best effort», а узел ждал " + f"надёжной доставки — такие не соединяются вовсе. Переподписался " + f"«best effort» (явно: best_effort:=true)") + def _adopt_foreign_cloud(self) -> None: """Облако идёт в топик, которого нет в списке, — подписаться и на него. diff --git a/ros2_ws/src/flyguard/flyguard/pipeline.py b/ros2_ws/src/flyguard/flyguard/pipeline.py index 312fc68..15f45ae 100644 --- a/ros2_ws/src/flyguard/flyguard/pipeline.py +++ b/ros2_ws/src/flyguard/flyguard/pipeline.py @@ -167,6 +167,20 @@ class Params: hover_d: float = 80.0 # дальше — не действует: ось пути вдали неточна hover_depth: float = 1.5 # протяжённость вдоль пути до, м: длиннее — труба, поручень hover_width: float = 0.2 # ширина от, м: уже — провод, кронштейн, тонкая деталь + # Канал малых целей (LC11, см. lobula._small_targets): кандидат из двух-трёх + # лучей, если он висит в пустом пространстве габарита — вся связная + # компонента не больше `small_ctx` лучей, низ выше `small_h` над рельсом, + # |u| не больше `small_u`, фон за ним дальше `small_gap`, дальность от + # `small_d_from` до `small_d_to`. Куб 0.3 м посередине синтетики + # организаторов при обычном пороге в четыре луча находился с 45 м: с 57 м + # его точки ложатся в две ячейки образа. 0 — выключено. EXPERIMENTS п. 26. + small_rays: int = 2 + small_ctx: int = 4 + small_h: float = 0.5 + small_u: float = 0.8 + small_gap: float = 5.0 + small_d_from: float = 45.0 + small_d_to: float = 100.0 # Поправка оценки модели на распространённость предметов: за этой # дальностью из логита вычитается типичный для полосы. 0 — не # поправлять. Замер — EXPERIMENTS п. 12.3. @@ -589,7 +603,14 @@ class FlyGuard: half_width_top=self.p.half_width_top, top_d_max=self.p.top_d_max, top_detect=self.p.top_detect, - cluster=self._cluster) + cluster=self._cluster, + small_rays=self.p.small_rays, + small_ctx=self.p.small_ctx, + small_h=self.p.small_h, + small_u=self.p.small_u, + small_gap=self.p.small_gap, + small_d=(self.p.small_d_from, + self.p.small_d_to)) with t("mushroom"): cands = self.memory.annotate(cands) diff --git a/ros2_ws/src/flyguard/test/test_pipeline.py b/ros2_ws/src/flyguard/test/test_pipeline.py index bdafdb7..81699ad 100644 --- a/ros2_ws/src/flyguard/test/test_pipeline.py +++ b/ros2_ws/src/flyguard/test/test_pipeline.py @@ -883,6 +883,106 @@ def test_gauge_mask_filters_platform_surface(): assert inside[1] # препятствие на колее сохранено +class _LobulaFrame(_MockTrackFrame): + """Кадр в координатах пути со всем, что читает `find_candidates`.""" + + def __init__(self, d, u, h, valid): + super().__init__(d, u, h, valid) + self.r = np.where(self.valid, self.d, 0.0).astype(np.float32) # «нет эха» — 0, как у сетчатки + self.floor_r = np.full(self.d.shape, np.inf, np.float32) + self.inten = np.zeros(self.d.shape, np.float32) + rows, cols = self.d.shape + self.layout = type("Layout", (), dict( + az_grid_deg=np.linspace(-2.0, 2.0, cols, dtype=np.float32), + el_deg=np.linspace(1.0, -1.0, rows, dtype=np.float32)))() + + +def _small_scene(): + """Два луча на 80 м посреди габарита на высоте 1.5 м; вокруг эха нет, + а ON-контраст ламины такой, что фон за ними на 20 м дальше.""" + shape = (12, 40) + d = np.full(shape, 150.0, np.float32) + u = np.zeros(shape, np.float32) + h = np.full(shape, 1.5, np.float32) + valid = np.zeros(shape, bool) + on = np.zeros(shape, np.float32) + d[6, 20:22] = 80.0 + u[6, 20:22] = (0.15, 0.30) + valid[6, 20:22] = True + on[6, 20:22] = 0.0025 # x = on·r = 0.2 → разрыв r·x/(1−x) = 20 м + return d, u, h, valid, on + + +def _find(d, u, h, valid, on, **kw): + from flyguard.geometry import STRAIGHT + from flyguard.lobula import find_candidates + + tf = _LobulaFrame(d, u, h, valid) + lam = type("Lam", (), dict(on=on, hole=np.zeros(on.shape, np.float32)))() + return find_candidates(tf, lam, STRAIGHT, half_width=1.2, h_lo=0.28, h_hi=2.3, + **kw) + + +def test_small_target_hanging_in_free_space_becomes_a_candidate(): + """Канал малых целей: два луча в пустоте — кандидат, при обычном + пороге в четыре луча — ничего, и выключенный канал ничего не меняет.""" + scene = _small_scene() + assert _find(*scene) == [] # по умолчанию выключен + assert _find(*scene, small_rays=0) == [] + out = _find(*scene, small_rays=2) + assert len(out) == 1 + c = out[0] + assert c.extra.get("small") and c.n_rays == 2 + assert abs(c.d - 80.0) < 1e-3 and c.containment == 1.0 and c.gap > 15.0 + + +def test_small_target_rejects_what_is_attached_near_the_edge_or_on_the_wall(): + """Те же два луча не проходят, если пятно — часть стены, стоит у края + габарита, фон за ним близко или дальность вне диапазона канала.""" + d, u, h, valid, on = _small_scene() + + # стенка той же дальности рядом, за краем габарита: компонента большая + wd, wu, wh, wv = d.copy(), u.copy(), h.copy(), valid.copy() + wd[2:11, 23] = 80.5 + wu[2:11, 23] = 1.6 + wh[2:11, 23] = np.linspace(0.5, 2.0, 9) + wv[2:11, 23] = True + assert _find(wd, wu, wh, wv, on, small_rays=2) == [] + + # у края габарита: вдали внутри он или снаружи — не различить + eu = u.copy() + eu[6, 20:22] = (1.0, 1.1) + assert _find(d, eu, h, valid, on, small_rays=2) == [] + + # фон вплотную — не висит в пустоте + assert _find(d, u, h, valid, on * 0.05, small_rays=2) == [] + + # у самого полотна — это не канал малых целей + lh = h.copy() + lh[6, 20:22] = 0.35 + assert _find(d, u, lh, valid, on, small_rays=2) == [] + + # ближе диапазона канала предмет даёт четыре луча и без него + assert _find(d, u, h, valid, on, small_rays=2, small_d=(90.0, 130.0)) == [] + + +def test_small_target_weight_ignores_mbon_but_not_familiarity(): + """Вес наблюдения малой цели: считывание MBON её не судит (двухлучевых + кандидатов в его выборке не было), а знакомость — судит.""" + from flyguard.central_complex import _quality + + c = _cand(d=80.0, n_rays=2, gap=20.0, containment=1.0, h_min=1.5, + depth=0.0, width=0.15, height=0.0) + c.novelty = 0.6 + c.extra["mbon"] = 0.01 + ordinary = _quality(c, mbon_power=1.5, mbon_blend=1.0) + c.extra["small"] = True + w = _quality(c, mbon_power=1.5, mbon_blend=1.0) + assert ordinary < 0.01 < 0.45 < w # модель больше не гасит + c.novelty = 0.1 + assert _quality(c, mbon_power=1.5, mbon_blend=1.0) < 0.1 * w # память гасит + + def test_descending_far_soft_warning_triggers_without_emergency(): """На 130 м устойчивый трек поднимает предупреждение, но не экстренное торможение.""" from flyguard.central_complex import CentralComplex, Track diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 41ef585..8d7091a 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -885,6 +885,106 @@ def test_gauge_mask_filters_platform_surface(): assert inside[1] # препятствие на колее сохранено +class _LobulaFrame(_MockTrackFrame): + """Кадр в координатах пути со всем, что читает `find_candidates`.""" + + def __init__(self, d, u, h, valid): + super().__init__(d, u, h, valid) + self.r = np.where(self.valid, self.d, 0.0).astype(np.float32) # «нет эха» — 0, как у сетчатки + self.floor_r = np.full(self.d.shape, np.inf, np.float32) + self.inten = np.zeros(self.d.shape, np.float32) + rows, cols = self.d.shape + self.layout = type("Layout", (), dict( + az_grid_deg=np.linspace(-2.0, 2.0, cols, dtype=np.float32), + el_deg=np.linspace(1.0, -1.0, rows, dtype=np.float32)))() + + +def _small_scene(): + """Два луча на 80 м посреди габарита на высоте 1.5 м; вокруг эха нет, + а ON-контраст ламины такой, что фон за ними на 20 м дальше.""" + shape = (12, 40) + d = np.full(shape, 150.0, np.float32) + u = np.zeros(shape, np.float32) + h = np.full(shape, 1.5, np.float32) + valid = np.zeros(shape, bool) + on = np.zeros(shape, np.float32) + d[6, 20:22] = 80.0 + u[6, 20:22] = (0.15, 0.30) + valid[6, 20:22] = True + on[6, 20:22] = 0.0025 # x = on·r = 0.2 → разрыв r·x/(1−x) = 20 м + return d, u, h, valid, on + + +def _find(d, u, h, valid, on, **kw): + from flyguard.geometry import STRAIGHT + from flyguard.lobula import find_candidates + + tf = _LobulaFrame(d, u, h, valid) + lam = type("Lam", (), dict(on=on, hole=np.zeros(on.shape, np.float32)))() + return find_candidates(tf, lam, STRAIGHT, half_width=1.2, h_lo=0.28, h_hi=2.3, + **kw) + + +def test_small_target_hanging_in_free_space_becomes_a_candidate(): + """Канал малых целей: два луча в пустоте — кандидат, при обычном + пороге в четыре луча — ничего, и выключенный канал ничего не меняет.""" + scene = _small_scene() + assert _find(*scene) == [] # по умолчанию выключен + assert _find(*scene, small_rays=0) == [] + out = _find(*scene, small_rays=2) + assert len(out) == 1 + c = out[0] + assert c.extra.get("small") and c.n_rays == 2 + assert abs(c.d - 80.0) < 1e-3 and c.containment == 1.0 and c.gap > 15.0 + + +def test_small_target_rejects_what_is_attached_near_the_edge_or_on_the_wall(): + """Те же два луча не проходят, если пятно — часть стены, стоит у края + габарита, фон за ним близко или дальность вне диапазона канала.""" + d, u, h, valid, on = _small_scene() + + # стенка той же дальности рядом, за краем габарита: компонента большая + wd, wu, wh, wv = d.copy(), u.copy(), h.copy(), valid.copy() + wd[2:11, 23] = 80.5 + wu[2:11, 23] = 1.6 + wh[2:11, 23] = np.linspace(0.5, 2.0, 9) + wv[2:11, 23] = True + assert _find(wd, wu, wh, wv, on, small_rays=2) == [] + + # у края габарита: вдали внутри он или снаружи — не различить + eu = u.copy() + eu[6, 20:22] = (1.0, 1.1) + assert _find(d, eu, h, valid, on, small_rays=2) == [] + + # фон вплотную — не висит в пустоте + assert _find(d, u, h, valid, on * 0.05, small_rays=2) == [] + + # у самого полотна — это не канал малых целей + lh = h.copy() + lh[6, 20:22] = 0.35 + assert _find(d, u, lh, valid, on, small_rays=2) == [] + + # ближе диапазона канала предмет даёт четыре луча и без него + assert _find(d, u, h, valid, on, small_rays=2, small_d=(90.0, 130.0)) == [] + + +def test_small_target_weight_ignores_mbon_but_not_familiarity(): + """Вес наблюдения малой цели: считывание MBON её не судит (двухлучевых + кандидатов в его выборке не было), а знакомость — судит.""" + from flyguard.central_complex import _quality + + c = _cand(d=80.0, n_rays=2, gap=20.0, containment=1.0, h_min=1.5, + depth=0.0, width=0.15, height=0.0) + c.novelty = 0.6 + c.extra["mbon"] = 0.01 + ordinary = _quality(c, mbon_power=1.5, mbon_blend=1.0) + c.extra["small"] = True + w = _quality(c, mbon_power=1.5, mbon_blend=1.0) + assert ordinary < 0.01 < 0.45 < w # модель больше не гасит + c.novelty = 0.1 + assert _quality(c, mbon_power=1.5, mbon_blend=1.0) < 0.1 * w # память гасит + + def test_descending_far_soft_warning_triggers_without_emergency(): """На 130 м устойчивый трек поднимает предупреждение, но не экстренное торможение.""" from flyguard.central_complex import CentralComplex, Track