forked from Dan4ick/Lidar_Muxa
канал малых целей в лобуле, подстройка под QoS записи, новый образ
This commit is contained in:
parent
d1f350d7d6
commit
1ee53ade5d
14 changed files with 828 additions and 15 deletions
24
README.md
24
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 |
|
||||
|
|
|
|||
|
|
@ -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 м).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
оставлены прежними.
|
||||
|
|
|
|||
|
|
@ -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 → множитель улики.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Облако идёт в топик, которого нет в списке, — подписаться и на него.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 м попадают
|
||||
|
|
|
|||
|
|
@ -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 → множитель улики.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Облако идёт в топик, которого нет в списке, — подписаться и на него.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue